@klarkxy/dsh-deep-view 0.1.0 → 0.2.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/lib/answer-text.d.ts +15 -0
- package/lib/answer-text.js +110 -0
- package/lib/answer-text.js.map +1 -0
- package/lib/answers.d.ts +49 -0
- package/lib/answers.js +60 -0
- package/lib/answers.js.map +1 -0
- package/lib/client.inner.cjs +362 -12
- package/lib/client.inner.cjs.map +1 -1
- package/lib/client.js +362 -12
- package/lib/contracts.d.ts +2 -98
- package/lib/contracts.js +2 -1
- package/lib/contracts.js.map +1 -1
- package/lib/contracts2.d.ts +103 -0
- package/lib/copy.d.ts +37 -0
- package/lib/copy.js +69 -0
- package/lib/copy.js.map +1 -0
- package/lib/core.d.ts +57 -0
- package/lib/core.js +2 -0
- package/lib/core2.js +1079 -0
- package/lib/core2.js.map +1 -0
- package/lib/index.d.ts +35 -4
- package/lib/index.js +847 -1130
- package/lib/index.js.map +1 -1
- package/lib/widget.d.ts +71 -0
- package/lib/widget.js +1365 -0
- package/lib/widget.js.map +1 -0
- package/package.json +28 -3
package/lib/index.js
CHANGED
|
@@ -1,12 +1,666 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { _ as viewSummary, i as checkShownView, p as validateViewAnswer } from "./core2.js";
|
|
2
|
+
import { HOST_SERVICE, PLUGIN_NAME, RPC_CHANNEL, VIEW_OWNER, isRecord } from "./contracts.js";
|
|
3
|
+
import { answerText } from "./answer-text.js";
|
|
2
4
|
import { randomUUID } from "node:crypto";
|
|
3
5
|
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
6
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
7
|
+
import { appendFileSync, existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, renameSync, unlinkSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
//#region ../dsh-plugin-kit/src/log-shared.ts
|
|
12
|
+
const PLUGIN_LOG_LEVELS = [
|
|
13
|
+
"debug",
|
|
14
|
+
"info",
|
|
15
|
+
"warn",
|
|
16
|
+
"error",
|
|
17
|
+
"fault"
|
|
18
|
+
];
|
|
19
|
+
const DEFAULT_PLUGIN_LOG_LEVEL = "info";
|
|
20
|
+
const PLUGIN_LOG_MAX_MESSAGE_CHARS = 16e3;
|
|
21
|
+
const SOURCE_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
22
|
+
const EVENT_RE = /^[a-z][a-z0-9._-]{0,127}$/;
|
|
23
|
+
const CONTEXT_KEY_RE = /^[a-zA-Z][a-zA-Z0-9._-]{0,63}$/;
|
|
24
|
+
const SECRET_KEY_RE = /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|bearer|authorization|cookie|set[_-]?cookie|nonce|credential|private[_-]?key|password|secret|app[_-]?secret|client[_-]?secret|z_c0|d_c0)$/i;
|
|
25
|
+
const CHAT_PAYLOAD_RE = /"role"\s*:\s*"(?:system|user|assistant|tool)"|"(?:messages|content|prompt|input_text|input_image|image_url)"\s*:\s*[\[{"']/i;
|
|
26
|
+
const CREDENTIAL_BODY_RE = /-----BEGIN (?:RSA |EC |OPENSSH |PRIVATE )?PRIVATE KEY-----|"type"\s*:\s*"service_account"|credentials\.json|\.dsh[/\\](?:credentials|auth)/i;
|
|
27
|
+
const AUTH_HEADER_RE = /\b(Authorization|Proxy-Authorization|Cookie|Set-Cookie)\s*:\s*[^\r\n]*/gi;
|
|
28
|
+
const BEARER_RE = /\bBearer\s+[A-Za-z0-9._\-+=/]{8,}/g;
|
|
29
|
+
const SECRET_FIELD_RE = /(["']?(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|bearer|authorization|cookie|set-cookie|nonce|credential|private[_-]?key|password|secret|app[_-]?secret|client[_-]?secret|password|z_c0|d_c0)["']?\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,&;}]+)/gi;
|
|
30
|
+
const SECRET_QUERY_RE = /([?&](?:token|api[_-]?key|key|secret|password|bootstrap|code|sig|signature|credential|password)=)[^\s&#"']+/gi;
|
|
31
|
+
const URL_CREDENTIAL_RE = /\b(https?:\/\/)[^\s/:@]+:[^\s/@]+@/gi;
|
|
32
|
+
const BOOTSTRAP_RE = /\/bootstrap\/[^\s"'?&#]+/gi;
|
|
33
|
+
const KNOWN_TOKEN_RE = /\b(?:sk-[A-Za-z0-9_-]{10,}|gh[pousr]_[A-Za-z0-9_]{16,}|github_pat_[A-Za-z0-9_]{20,}|tvly-[A-Za-z0-9_-]{10,}|AKIA[0-9A-Z]{16})\b/g;
|
|
34
|
+
const PATH_RE = /(?:[A-Za-z]:\\|\\\\)[^\s"'`<>|]+|(?:\/(?:home|Users|tmp|var|root)\/|~\/)[^\s"'`<>|]+/g;
|
|
35
|
+
function isPluginLogLevel(value) {
|
|
36
|
+
return typeof value === "string" && PLUGIN_LOG_LEVELS.includes(value);
|
|
37
|
+
}
|
|
38
|
+
function pluginLogLevelEnabled(level, minimum) {
|
|
39
|
+
return PLUGIN_LOG_LEVELS.indexOf(level) >= PLUGIN_LOG_LEVELS.indexOf(minimum);
|
|
40
|
+
}
|
|
41
|
+
function normalizePluginLogSource(raw) {
|
|
42
|
+
const stripped = String(raw || "").trim().replace(/^@[^/]+\//, "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
43
|
+
const bounded = (/^[a-z]/.test(stripped) ? stripped : `p-${stripped}`).slice(0, 64).replace(/-+$/g, "");
|
|
44
|
+
return SOURCE_RE.test(bounded) ? bounded : "dsh-plugin";
|
|
45
|
+
}
|
|
46
|
+
function normalizePluginLogEvent(raw) {
|
|
47
|
+
const cleaned = String(raw || "").trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z]+/, "").slice(0, 128);
|
|
48
|
+
return EVENT_RE.test(cleaned) ? cleaned : "plugin.event";
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Parse a level specification like `debug` or `dsh-zhihu=debug,dsh-dot:warn,info`.
|
|
52
|
+
* Source-specific rules win over a bare fallback level.
|
|
53
|
+
*/
|
|
54
|
+
function parsePluginLogLevel(spec, source, fallback = DEFAULT_PLUGIN_LOG_LEVEL) {
|
|
55
|
+
if (!spec || typeof spec !== "string") return fallback;
|
|
56
|
+
const normalizedSource = source ? normalizePluginLogSource(source) : void 0;
|
|
57
|
+
let wildcard;
|
|
58
|
+
for (const token of spec.split(",").map((part) => part.trim()).filter(Boolean)) {
|
|
59
|
+
const lowered = token.toLowerCase();
|
|
60
|
+
if (isPluginLogLevel(lowered)) {
|
|
61
|
+
wildcard = lowered;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const match = /^([^=:]+)[=:]([a-z]+)$/i.exec(token);
|
|
65
|
+
if (!match) continue;
|
|
66
|
+
const target = match[1].trim();
|
|
67
|
+
const candidate = match[2].trim().toLowerCase();
|
|
68
|
+
if (!isPluginLogLevel(candidate)) continue;
|
|
69
|
+
if (target === "*") {
|
|
70
|
+
wildcard = candidate;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (normalizedSource && normalizePluginLogSource(target) === normalizedSource) return candidate;
|
|
74
|
+
}
|
|
75
|
+
return wildcard ?? fallback;
|
|
76
|
+
}
|
|
77
|
+
function sanitizePluginLogText(text, maxChars = PLUGIN_LOG_MAX_MESSAGE_CHARS) {
|
|
78
|
+
if (!text) return "";
|
|
79
|
+
if (CREDENTIAL_BODY_RE.test(text)) return "[omitted credential material]";
|
|
80
|
+
if (CHAT_PAYLOAD_RE.test(text)) return "[omitted chat payload]";
|
|
81
|
+
return truncateLogText(text.replace(AUTH_HEADER_RE, "$1: [redacted]").replace(BEARER_RE, "Bearer [redacted]").replace(SECRET_FIELD_RE, (full, prefix) => {
|
|
82
|
+
const value = full.slice(prefix.length);
|
|
83
|
+
if (value.startsWith("\"")) return `${prefix}"[redacted]"`;
|
|
84
|
+
if (value.startsWith("'")) return `${prefix}'[redacted]'`;
|
|
85
|
+
return `${prefix}[redacted]`;
|
|
86
|
+
}).replace(SECRET_QUERY_RE, "$1[redacted]").replace(URL_CREDENTIAL_RE, "$1[redacted]@").replace(BOOTSTRAP_RE, "/bootstrap/[redacted]").replace(KNOWN_TOKEN_RE, "[redacted]").replace(PATH_RE, "[path]"), maxChars);
|
|
87
|
+
}
|
|
88
|
+
function sanitizePluginLogContext(input) {
|
|
89
|
+
if (!input) return void 0;
|
|
90
|
+
const output = {};
|
|
91
|
+
for (const [key, value] of Object.entries(input).slice(0, 32)) {
|
|
92
|
+
if (value === void 0 || !CONTEXT_KEY_RE.test(key)) continue;
|
|
93
|
+
if (typeof value === "number" && !Number.isFinite(value)) continue;
|
|
94
|
+
if (SECRET_KEY_RE.test(key)) {
|
|
95
|
+
output[key] = "[redacted]";
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
output[key] = typeof value === "string" ? sanitizePluginLogText(value, 1e3) : value;
|
|
99
|
+
}
|
|
100
|
+
if (!Object.keys(output).length) return void 0;
|
|
101
|
+
if (JSON.stringify(output).length <= 8e3) return output;
|
|
102
|
+
return {
|
|
103
|
+
omitted: true,
|
|
104
|
+
reason: "Diagnostic context exceeded its size limit."
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function sanitizePluginLogError(error) {
|
|
108
|
+
if (error === void 0) return void 0;
|
|
109
|
+
const row = error && typeof error === "object" ? error : {};
|
|
110
|
+
const rawMessage = typeof row.message === "string" ? row.message : String(error);
|
|
111
|
+
const result = {
|
|
112
|
+
name: typeof row.name === "string" && row.name.trim() ? truncateLogText(row.name.trim(), 100) : "Error",
|
|
113
|
+
message: sanitizePluginLogText(rawMessage) || "Unknown error"
|
|
114
|
+
};
|
|
115
|
+
if (typeof row.stack === "string" && row.stack.trim()) result.stack = sanitizePluginLogText(row.stack, 24e3);
|
|
116
|
+
if (typeof row.code === "string" && /^[A-Za-z0-9_./:-]{1,100}$/.test(row.code)) result.code = row.code;
|
|
117
|
+
else if (typeof row.code === "number" && Number.isFinite(row.code)) result.code = String(row.code);
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
function mergePluginLogContext(base, extra) {
|
|
121
|
+
if (!base) return extra;
|
|
122
|
+
if (!extra) return base;
|
|
123
|
+
return {
|
|
124
|
+
...base,
|
|
125
|
+
...extra
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function buildPluginLogEntry(input, meta) {
|
|
129
|
+
const entry = {
|
|
130
|
+
version: 1,
|
|
131
|
+
at: meta.at.toISOString(),
|
|
132
|
+
level: isPluginLogLevel(input.level) ? input.level : "info",
|
|
133
|
+
source: normalizePluginLogSource(meta.source),
|
|
134
|
+
surface: meta.surface,
|
|
135
|
+
...meta.profile ? { profile: meta.profile } : {},
|
|
136
|
+
event: normalizePluginLogEvent(input.event),
|
|
137
|
+
message: sanitizePluginLogText(input.message || "No diagnostic message was provided.", PLUGIN_LOG_MAX_MESSAGE_CHARS),
|
|
138
|
+
...meta.pid !== void 0 ? { pid: meta.pid } : {}
|
|
139
|
+
};
|
|
140
|
+
const context = sanitizePluginLogContext(mergePluginLogContext(meta.correlation, input.context));
|
|
141
|
+
if (context) entry.context = context;
|
|
142
|
+
const error = sanitizePluginLogError(input.error);
|
|
143
|
+
if (error) entry.error = error;
|
|
144
|
+
return entry;
|
|
145
|
+
}
|
|
146
|
+
function createChildPluginLogger(parent, bound) {
|
|
147
|
+
return {
|
|
148
|
+
get source() {
|
|
149
|
+
return parent.source;
|
|
150
|
+
},
|
|
151
|
+
get file() {
|
|
152
|
+
return parent.file;
|
|
153
|
+
},
|
|
154
|
+
get lastWriteError() {
|
|
155
|
+
return parent.lastWriteError;
|
|
156
|
+
},
|
|
157
|
+
debug: (event, message, context) => parent.debug(event, message, mergePluginLogContext(bound, context)),
|
|
158
|
+
info: (event, message, context) => parent.info(event, message, mergePluginLogContext(bound, context)),
|
|
159
|
+
warn: (event, message, error, context) => parent.warn(event, message, error, mergePluginLogContext(bound, context)),
|
|
160
|
+
error: (event, message, error, context) => parent.error(event, message, error, mergePluginLogContext(bound, context)),
|
|
161
|
+
fault: (event, message, error, context) => parent.fault(event, message, error, mergePluginLogContext(bound, context)),
|
|
162
|
+
write: (input) => parent.write({
|
|
163
|
+
...input,
|
|
164
|
+
context: mergePluginLogContext(bound, input.context)
|
|
165
|
+
}),
|
|
166
|
+
child: (next) => createChildPluginLogger(parent, mergePluginLogContext(bound, next) ?? {})
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function truncateLogText(text, maxChars) {
|
|
170
|
+
if (text.length <= maxChars) return text;
|
|
171
|
+
const marker = "[truncated]\n";
|
|
172
|
+
if (maxChars <= 12) return text.slice(0, Math.max(0, maxChars));
|
|
173
|
+
return `${marker}${text.slice(-(maxChars - 12))}`;
|
|
174
|
+
}
|
|
175
|
+
const PROFILE_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
|
|
176
|
+
const ARCHIVE_RE = /^[a-z][a-z0-9-]{0,63}\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z\.\d+\.\d+\.jsonl$/;
|
|
177
|
+
const correlationStorage = new AsyncLocalStorage();
|
|
178
|
+
const processFileLoggers = /* @__PURE__ */ new Map();
|
|
179
|
+
let operationSequence = 0;
|
|
180
|
+
function safeProfileName(profile) {
|
|
181
|
+
const trimmed = String(profile ?? "").trim();
|
|
182
|
+
if (PROFILE_RE.test(trimmed)) return trimmed.toLowerCase();
|
|
183
|
+
const cleaned = trimmed.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
|
|
184
|
+
return cleaned && PROFILE_RE.test(cleaned) ? cleaned : "default";
|
|
185
|
+
}
|
|
186
|
+
function pluginLogDirectory(home, profile = "default") {
|
|
187
|
+
return join(resolve(home), "logs", "plugins", safeProfileName(profile));
|
|
188
|
+
}
|
|
189
|
+
function resolveHostLogTarget(ctx, options = {}) {
|
|
190
|
+
const env = options.env ?? process.env;
|
|
191
|
+
if (options.directory?.trim()) {
|
|
192
|
+
const dir = resolve(options.directory.trim());
|
|
193
|
+
return {
|
|
194
|
+
directory: dir,
|
|
195
|
+
anchor: dir,
|
|
196
|
+
...options.profile ? { profile: safeProfileName(options.profile) } : {}
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
if (options.home?.trim()) {
|
|
200
|
+
const home = resolve(options.home.trim());
|
|
201
|
+
const profile = safeProfileName(options.profile);
|
|
202
|
+
return {
|
|
203
|
+
directory: pluginLogDirectory(home, profile),
|
|
204
|
+
anchor: home,
|
|
205
|
+
profile
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (env.DSH_PLUGIN_LOG_DIR?.trim()) {
|
|
209
|
+
const dir = resolve(env.DSH_PLUGIN_LOG_DIR.trim());
|
|
210
|
+
return {
|
|
211
|
+
directory: dir,
|
|
212
|
+
anchor: dir,
|
|
213
|
+
...options.profile ? { profile: safeProfileName(options.profile) } : {}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
const host = ctx && typeof ctx === "object" ? ctx : void 0;
|
|
217
|
+
let profileCtx;
|
|
218
|
+
try {
|
|
219
|
+
profileCtx = host?.get?.("profileContext", false);
|
|
220
|
+
} catch {
|
|
221
|
+
try {
|
|
222
|
+
profileCtx = host?.get?.("profileContext");
|
|
223
|
+
} catch {
|
|
224
|
+
profileCtx = void 0;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (typeof profileCtx?.home === "string" && profileCtx.home.trim() && typeof profileCtx.dir === "string" && profileCtx.dir.trim()) {
|
|
228
|
+
const home = resolve(profileCtx.home.trim());
|
|
229
|
+
const dir = resolve(profileCtx.dir.trim());
|
|
230
|
+
if (isInside(home, dir)) {
|
|
231
|
+
const profile = safeProfileName(typeof profileCtx.name === "string" ? profileCtx.name : basename(dir));
|
|
232
|
+
return {
|
|
233
|
+
directory: pluginLogDirectory(home, profile),
|
|
234
|
+
anchor: home,
|
|
235
|
+
profile
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (!Boolean(env.VITEST || env.NODE_TEST_CONTEXT) && typeof host?.baseUrl === "string" && host.baseUrl.trim()) {
|
|
240
|
+
const basePath = pathFromBaseUrl(host.baseUrl);
|
|
241
|
+
const configuredHome = resolve((env.DSH_HOME?.trim() || join(homedir(), ".dsh")).trim());
|
|
242
|
+
const profilesRoot = join(configuredHome, "profiles");
|
|
243
|
+
if (basePath && isInside(profilesRoot, basePath) && !samePath(profilesRoot, basePath)) {
|
|
244
|
+
const firstSegment = basePath.slice(profilesRoot.length).replace(/^[/\\]+/, "").split(/[/\\]/)[0];
|
|
245
|
+
if (firstSegment) {
|
|
246
|
+
const profile = safeProfileName(firstSegment);
|
|
247
|
+
return {
|
|
248
|
+
directory: pluginLogDirectory(configuredHome, profile),
|
|
249
|
+
anchor: configuredHome,
|
|
250
|
+
profile
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
directory: null,
|
|
257
|
+
anchor: null,
|
|
258
|
+
...options.profile ? { profile: safeProfileName(options.profile) } : {}
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Synchronous, append-only rotating JSONL logger modeled on DSH Spaces
|
|
263
|
+
* diagnostics: 7-day or 100 MiB active rotation, 7-day / 100 MiB archive
|
|
264
|
+
* retention, secret and path redaction, and non-throwing write resilience.
|
|
265
|
+
*/
|
|
266
|
+
var RotatingPluginLogger = class {
|
|
267
|
+
source;
|
|
268
|
+
file;
|
|
269
|
+
lastError = null;
|
|
270
|
+
size = 0;
|
|
271
|
+
openedAt = 0;
|
|
272
|
+
initialized = false;
|
|
273
|
+
writing = false;
|
|
274
|
+
sequence = 0;
|
|
275
|
+
loggedWriteFailure = false;
|
|
276
|
+
recent = [];
|
|
277
|
+
directory;
|
|
278
|
+
anchor;
|
|
279
|
+
profile;
|
|
280
|
+
minimum;
|
|
281
|
+
now;
|
|
282
|
+
pid;
|
|
283
|
+
maxFileBytes;
|
|
284
|
+
maxArchiveBytes;
|
|
285
|
+
maxAgeMs;
|
|
286
|
+
recentLimit;
|
|
287
|
+
constructor(source, options = {}) {
|
|
288
|
+
this.source = normalizePluginLogSource(source);
|
|
289
|
+
const env = options.env ?? process.env;
|
|
290
|
+
const resolved = options.directory !== void 0 || options.home !== void 0 ? resolveHostLogTarget(void 0, options) : {
|
|
291
|
+
directory: null,
|
|
292
|
+
anchor: options.anchor ?? null,
|
|
293
|
+
profile: options.profile
|
|
294
|
+
};
|
|
295
|
+
this.directory = resolved.directory;
|
|
296
|
+
this.anchor = options.anchor ?? resolved.anchor;
|
|
297
|
+
this.profile = options.profile ?? resolved.profile;
|
|
298
|
+
this.file = this.directory ? join(this.directory, `${this.source}.jsonl`) : null;
|
|
299
|
+
this.minimum = options.minimumLevel ?? parsePluginLogLevel(env.DSH_PLUGIN_LOG_LEVEL, this.source, "info");
|
|
300
|
+
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
301
|
+
this.pid = options.pid ?? process.pid;
|
|
302
|
+
this.maxFileBytes = options.maxFileBytes ?? 104857600;
|
|
303
|
+
this.maxArchiveBytes = options.maxArchiveBytes ?? 104857600;
|
|
304
|
+
this.maxAgeMs = options.maxAgeMs ?? 6048e5;
|
|
305
|
+
this.recentLimit = Math.max(1, options.recentLimit ?? 200);
|
|
306
|
+
}
|
|
307
|
+
get lastWriteError() {
|
|
308
|
+
return this.lastError;
|
|
309
|
+
}
|
|
310
|
+
recentEntries(limit = this.recentLimit) {
|
|
311
|
+
return this.recent.slice(-Math.max(0, limit));
|
|
312
|
+
}
|
|
313
|
+
debug(event, message, context) {
|
|
314
|
+
return this.write({
|
|
315
|
+
level: "debug",
|
|
316
|
+
event,
|
|
317
|
+
message,
|
|
318
|
+
...context ? { context } : {}
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
info(event, message, context) {
|
|
322
|
+
return this.write({
|
|
323
|
+
level: "info",
|
|
324
|
+
event,
|
|
325
|
+
message,
|
|
326
|
+
...context ? { context } : {}
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
warn(event, message, error, context) {
|
|
330
|
+
return this.write({
|
|
331
|
+
level: "warn",
|
|
332
|
+
event,
|
|
333
|
+
message,
|
|
334
|
+
...error !== void 0 ? { error } : {},
|
|
335
|
+
...context ? { context } : {}
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
error(event, message, error, context) {
|
|
339
|
+
return this.write({
|
|
340
|
+
level: "error",
|
|
341
|
+
event,
|
|
342
|
+
message,
|
|
343
|
+
...error !== void 0 ? { error } : {},
|
|
344
|
+
...context ? { context } : {}
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
fault(event, message, error, context) {
|
|
348
|
+
return this.write({
|
|
349
|
+
level: "fault",
|
|
350
|
+
event,
|
|
351
|
+
message,
|
|
352
|
+
...error !== void 0 ? { error } : {},
|
|
353
|
+
...context ? { context } : {}
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
child(context) {
|
|
357
|
+
return createChildPluginLogger(this, context);
|
|
358
|
+
}
|
|
359
|
+
write(input) {
|
|
360
|
+
if (!pluginLogLevelEnabled(input.level, this.minimum) || this.writing) return false;
|
|
361
|
+
const at = this.now();
|
|
362
|
+
let entry;
|
|
363
|
+
let line;
|
|
364
|
+
try {
|
|
365
|
+
entry = buildPluginLogEntry(input, {
|
|
366
|
+
at,
|
|
367
|
+
source: this.source,
|
|
368
|
+
surface: "host",
|
|
369
|
+
profile: this.profile,
|
|
370
|
+
pid: this.pid,
|
|
371
|
+
correlation: correlationStorage.getStore()
|
|
372
|
+
});
|
|
373
|
+
line = `${JSON.stringify(entry)}\n`;
|
|
374
|
+
} catch {
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
this.pushRecent(entry);
|
|
378
|
+
if (!this.directory || !this.file) return true;
|
|
379
|
+
this.writing = true;
|
|
380
|
+
try {
|
|
381
|
+
this.openManagedDirectory();
|
|
382
|
+
this.prepare(at);
|
|
383
|
+
if (this.size > 0 && (this.size + Buffer.byteLength(line) > this.maxFileBytes || at.getTime() - this.openedAt >= this.maxAgeMs)) this.rotate(at);
|
|
384
|
+
appendFileSync(this.file, line, {
|
|
385
|
+
encoding: "utf8",
|
|
386
|
+
mode: 384
|
|
387
|
+
});
|
|
388
|
+
this.size += Buffer.byteLength(line);
|
|
389
|
+
this.prune(at);
|
|
390
|
+
this.lastError = null;
|
|
391
|
+
this.loggedWriteFailure = false;
|
|
392
|
+
return true;
|
|
393
|
+
} catch (error) {
|
|
394
|
+
const detail = sanitizePluginLogText(error instanceof Error ? error.message : String(error), 500);
|
|
395
|
+
this.lastError = `The plugin diagnostic log could not be written: ${detail}`;
|
|
396
|
+
this.initialized = false;
|
|
397
|
+
if (!this.loggedWriteFailure) {
|
|
398
|
+
this.loggedWriteFailure = true;
|
|
399
|
+
try {
|
|
400
|
+
process.stderr.write(`DSH Plugin (${this.source}): ${this.lastError}. The original operation continues unchanged.\n`);
|
|
401
|
+
} catch {}
|
|
402
|
+
}
|
|
403
|
+
return false;
|
|
404
|
+
} finally {
|
|
405
|
+
this.writing = false;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
pushRecent(entry) {
|
|
409
|
+
this.recent.push(entry);
|
|
410
|
+
const overflow = this.recent.length - this.recentLimit;
|
|
411
|
+
if (overflow > 0) this.recent.splice(0, overflow);
|
|
412
|
+
}
|
|
413
|
+
prepare(at) {
|
|
414
|
+
if (this.initialized || !this.file) return;
|
|
415
|
+
this.openManagedDirectory();
|
|
416
|
+
const stat = this.regularFile(this.file);
|
|
417
|
+
this.size = stat?.size ?? 0;
|
|
418
|
+
const stamps = stat ? [stat.birthtimeMs, stat.mtimeMs].filter((value) => value > 0) : [];
|
|
419
|
+
this.openedAt = stamps.length ? Math.min(...stamps) : at.getTime();
|
|
420
|
+
this.initialized = true;
|
|
421
|
+
}
|
|
422
|
+
rotate(at) {
|
|
423
|
+
if (!this.directory || !this.file) return;
|
|
424
|
+
const stamp = at.toISOString().replace(/:/g, "-");
|
|
425
|
+
const name = `${this.source}.${stamp}.${this.pid}.${this.sequence++}.jsonl`;
|
|
426
|
+
if (!ARCHIVE_RE.test(name)) throw new Error("Diagnostic log archive name is invalid.");
|
|
427
|
+
const archive = join(this.directory, name);
|
|
428
|
+
if (lexists(archive)) throw new Error("Diagnostic log archive already exists.");
|
|
429
|
+
try {
|
|
430
|
+
renameSync(this.file, archive);
|
|
431
|
+
} catch (error) {
|
|
432
|
+
if (error.code === "ENOENT") {
|
|
433
|
+
this.size = 0;
|
|
434
|
+
this.openedAt = at.getTime();
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
throw error;
|
|
438
|
+
}
|
|
439
|
+
this.size = 0;
|
|
440
|
+
this.openedAt = at.getTime();
|
|
441
|
+
}
|
|
442
|
+
rotateStaleActive(at) {
|
|
443
|
+
if (!this.file) return;
|
|
444
|
+
const stat = this.regularFile(this.file);
|
|
445
|
+
if (!stat || stat.size === 0) return;
|
|
446
|
+
if (at.getTime() - stat.mtimeMs < this.maxAgeMs) return;
|
|
447
|
+
this.rotate(at);
|
|
448
|
+
}
|
|
449
|
+
prune(at) {
|
|
450
|
+
if (!this.directory) return;
|
|
451
|
+
this.rotateStaleActive(at);
|
|
452
|
+
const archives = [];
|
|
453
|
+
let total = 0;
|
|
454
|
+
for (const name of readdirSync(this.directory)) {
|
|
455
|
+
if (!ARCHIVE_RE.test(name) || !name.startsWith(`${this.source}.`)) continue;
|
|
456
|
+
const file = join(this.directory, name);
|
|
457
|
+
const stat = lstatSync(file);
|
|
458
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("Diagnostic log archive is not a regular file.");
|
|
459
|
+
if (at.getTime() - stat.mtimeMs >= this.maxAgeMs) {
|
|
460
|
+
try {
|
|
461
|
+
unlinkSync(file);
|
|
462
|
+
} catch {}
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
archives.push({
|
|
466
|
+
name,
|
|
467
|
+
size: stat.size,
|
|
468
|
+
mtime: stat.mtimeMs
|
|
469
|
+
});
|
|
470
|
+
total += stat.size;
|
|
471
|
+
}
|
|
472
|
+
archives.sort((left, right) => left.mtime - right.mtime || left.name.localeCompare(right.name));
|
|
473
|
+
while (total > this.maxArchiveBytes && archives.length) {
|
|
474
|
+
const oldest = archives.shift();
|
|
475
|
+
try {
|
|
476
|
+
unlinkSync(join(this.directory, oldest.name));
|
|
477
|
+
} catch {}
|
|
478
|
+
total -= oldest.size;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
openManagedDirectory() {
|
|
482
|
+
if (!this.directory) return;
|
|
483
|
+
if (this.anchor) {
|
|
484
|
+
if ((existsSync(this.anchor) ? lstatSync(this.anchor) : void 0)?.isSymbolicLink()) throw new Error("Diagnostic log anchor cannot be a symbolic link.");
|
|
485
|
+
}
|
|
486
|
+
mkdirSync(this.directory, {
|
|
487
|
+
recursive: true,
|
|
488
|
+
mode: 448
|
|
489
|
+
});
|
|
490
|
+
const stat = lstatSync(this.directory);
|
|
491
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("Diagnostic log directory is not a real directory.");
|
|
492
|
+
const realDir = realpathSync(this.directory);
|
|
493
|
+
if (this.anchor) {
|
|
494
|
+
if (!isInside(realpathSync(this.anchor), realDir)) throw new Error("Diagnostic log directory escaped its managed root.");
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
regularFile(file) {
|
|
498
|
+
try {
|
|
499
|
+
const stat = lstatSync(file);
|
|
500
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("Diagnostic log is not a regular file.");
|
|
501
|
+
const expected = join(realpathSync(dirname(file)), basename(file));
|
|
502
|
+
if (!samePath(realpathSync(file), expected)) throw new Error("Diagnostic log path could not be verified.");
|
|
503
|
+
return stat;
|
|
504
|
+
} catch (error) {
|
|
505
|
+
if (error.code === "ENOENT") return void 0;
|
|
506
|
+
throw error;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
function createPluginLogger(ctxOrSource, maybeSource, maybeOptions) {
|
|
511
|
+
const hasCtx = typeof ctxOrSource !== "string";
|
|
512
|
+
const ctx = hasCtx ? ctxOrSource : void 0;
|
|
513
|
+
const source = hasCtx ? String(maybeSource ?? "dsh-plugin") : ctxOrSource;
|
|
514
|
+
const options = (hasCtx ? maybeOptions : maybeSource) ?? {};
|
|
515
|
+
const normalized = normalizePluginLogSource(source);
|
|
516
|
+
const target = resolveHostLogTarget(ctx, options);
|
|
517
|
+
let backing;
|
|
518
|
+
if (Boolean(target.directory && options.minimumLevel === void 0 && options.now === void 0 && options.pid === void 0 && options.maxFileBytes === void 0 && options.maxArchiveBytes === void 0 && options.maxAgeMs === void 0) && target.directory) {
|
|
519
|
+
const key = resolve(target.directory, `${normalized}.jsonl`);
|
|
520
|
+
let shared = processFileLoggers.get(key);
|
|
521
|
+
if (!shared) {
|
|
522
|
+
shared = new RotatingPluginLogger(normalized, {
|
|
523
|
+
...options,
|
|
524
|
+
directory: target.directory,
|
|
525
|
+
anchor: target.anchor,
|
|
526
|
+
profile: target.profile
|
|
527
|
+
});
|
|
528
|
+
processFileLoggers.set(key, shared);
|
|
529
|
+
}
|
|
530
|
+
backing = shared;
|
|
531
|
+
} else backing = new RotatingPluginLogger(normalized, {
|
|
532
|
+
...options,
|
|
533
|
+
...target.directory ? {
|
|
534
|
+
directory: target.directory,
|
|
535
|
+
anchor: target.anchor
|
|
536
|
+
} : {},
|
|
537
|
+
...target.profile ? { profile: target.profile } : {}
|
|
538
|
+
});
|
|
539
|
+
if (options.forwardHostWarnError === false || !ctx || typeof ctx !== "object") return backing;
|
|
540
|
+
return wrapWithHostForwarding(backing, ctx);
|
|
541
|
+
}
|
|
542
|
+
function installPluginLogger(ctx, source, options = {}) {
|
|
543
|
+
const logger = createPluginLogger(ctx, source, options);
|
|
544
|
+
logger.info("plugin.loaded", "Host plugin initialized.", options.loadedContext);
|
|
545
|
+
const host = ctx && typeof ctx === "object" ? ctx : void 0;
|
|
546
|
+
if (typeof host?.effect === "function") try {
|
|
547
|
+
host.effect(() => () => {
|
|
548
|
+
logger.debug("plugin.disposed", "Host plugin disposed.");
|
|
549
|
+
}, `${logger.source}.logger`);
|
|
550
|
+
} catch {}
|
|
551
|
+
return logger;
|
|
552
|
+
}
|
|
553
|
+
function wrapWithHostForwarding(backing, host) {
|
|
554
|
+
const forward = (input) => {
|
|
555
|
+
if (input.level !== "warn" && input.level !== "error" && input.level !== "fault") return;
|
|
556
|
+
try {
|
|
557
|
+
const hostLogger = host.logger;
|
|
558
|
+
if (!hostLogger) return;
|
|
559
|
+
const detail = input.error instanceof Error ? `${input.message} (${input.error.message})` : input.error !== void 0 ? `${input.message} (${String(input.error)})` : input.message;
|
|
560
|
+
const text = sanitizePluginLogText(`${backing.source} [${input.event}]: ${detail}`, 1e3);
|
|
561
|
+
if (input.level === "warn") {
|
|
562
|
+
if (typeof hostLogger.warn === "function") hostLogger.warn(text);
|
|
563
|
+
} else if (typeof hostLogger.error === "function") hostLogger.error(text);
|
|
564
|
+
else if (typeof hostLogger.warn === "function") hostLogger.warn(text);
|
|
565
|
+
} catch {}
|
|
566
|
+
};
|
|
567
|
+
const wrapped = {
|
|
568
|
+
get source() {
|
|
569
|
+
return backing.source;
|
|
570
|
+
},
|
|
571
|
+
get file() {
|
|
572
|
+
return backing.file;
|
|
573
|
+
},
|
|
574
|
+
get lastWriteError() {
|
|
575
|
+
return backing.lastWriteError;
|
|
576
|
+
},
|
|
577
|
+
debug: (event, message, context) => backing.debug(event, message, context),
|
|
578
|
+
info: (event, message, context) => backing.info(event, message, context),
|
|
579
|
+
warn: (event, message, error, context) => {
|
|
580
|
+
const wrote = backing.warn(event, message, error, context);
|
|
581
|
+
forward({
|
|
582
|
+
level: "warn",
|
|
583
|
+
event,
|
|
584
|
+
message,
|
|
585
|
+
error,
|
|
586
|
+
context
|
|
587
|
+
});
|
|
588
|
+
return wrote;
|
|
589
|
+
},
|
|
590
|
+
error: (event, message, error, context) => {
|
|
591
|
+
const wrote = backing.error(event, message, error, context);
|
|
592
|
+
forward({
|
|
593
|
+
level: "error",
|
|
594
|
+
event,
|
|
595
|
+
message,
|
|
596
|
+
error,
|
|
597
|
+
context
|
|
598
|
+
});
|
|
599
|
+
return wrote;
|
|
600
|
+
},
|
|
601
|
+
fault: (event, message, error, context) => {
|
|
602
|
+
const wrote = backing.fault(event, message, error, context);
|
|
603
|
+
forward({
|
|
604
|
+
level: "fault",
|
|
605
|
+
event,
|
|
606
|
+
message,
|
|
607
|
+
error,
|
|
608
|
+
context
|
|
609
|
+
});
|
|
610
|
+
return wrote;
|
|
611
|
+
},
|
|
612
|
+
write: (input) => {
|
|
613
|
+
const wrote = backing.write(input);
|
|
614
|
+
forward(input);
|
|
615
|
+
return wrote;
|
|
616
|
+
},
|
|
617
|
+
child: (context) => createChildPluginLogger(wrapped, context)
|
|
618
|
+
};
|
|
619
|
+
return wrapped;
|
|
620
|
+
}
|
|
621
|
+
function nextPluginOperationId(prefix = "op") {
|
|
622
|
+
return `${normalizePluginLogSource(prefix)}-${process.pid}-${++operationSequence}`;
|
|
623
|
+
}
|
|
624
|
+
function withPluginLogContext(context, action) {
|
|
625
|
+
const merged = mergePluginLogContext(correlationStorage.getStore(), context) ?? {};
|
|
626
|
+
return correlationStorage.run(merged, action);
|
|
627
|
+
}
|
|
628
|
+
function lexists(file) {
|
|
629
|
+
try {
|
|
630
|
+
lstatSync(file);
|
|
631
|
+
return true;
|
|
632
|
+
} catch (error) {
|
|
633
|
+
if (error.code === "ENOENT") return false;
|
|
634
|
+
throw error;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
function pathFromBaseUrl(baseUrl) {
|
|
638
|
+
try {
|
|
639
|
+
return baseUrl.includes("://") ? resolve(fileURLToPath(baseUrl)) : resolve(baseUrl);
|
|
640
|
+
} catch {
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
function samePath(left, right) {
|
|
645
|
+
const a = resolve(left);
|
|
646
|
+
const b = resolve(right);
|
|
647
|
+
return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
648
|
+
}
|
|
649
|
+
function isInside(root, target) {
|
|
650
|
+
const rootResolved = resolve(root);
|
|
651
|
+
const targetResolved = resolve(target);
|
|
652
|
+
if (samePath(rootResolved, targetResolved)) return true;
|
|
653
|
+
const prefix = rootResolved.endsWith(sep) ? rootResolved : rootResolved + sep;
|
|
654
|
+
return process.platform === "win32" ? targetResolved.toLowerCase().startsWith(prefix.toLowerCase()) : targetResolved.startsWith(prefix);
|
|
655
|
+
}
|
|
656
|
+
//#endregion
|
|
4
657
|
//#region ../dsh-plugin-kit/src/host-rpc.ts
|
|
5
658
|
/** The shared authenticated HTTP boundary. Each plugin still owns its RPC envelope semantics. */
|
|
6
659
|
function registerHostRpcTransport(ctx, channel, dispatch, options = {}) {
|
|
7
660
|
const bodyLimit = options.maxBodyBytes ?? 65536;
|
|
8
661
|
if (!Number.isSafeInteger(bodyLimit) || bodyLimit <= 0) throw new Error("Invalid RPC body limit");
|
|
9
662
|
const isEndpoint = options.isEndpoint ?? ((endpoint) => /^[a-z.]+$/.test(endpoint));
|
|
663
|
+
const log = options.logger;
|
|
10
664
|
return ctx.webServer.register({
|
|
11
665
|
kind: "prefix",
|
|
12
666
|
path: channel,
|
|
@@ -17,29 +671,54 @@ function registerHostRpcTransport(ctx, channel, dispatch, options = {}) {
|
|
|
17
671
|
else res.writeHead(status);
|
|
18
672
|
res.end(override ?? options.errorBody?.(status, error) ?? (status === 500 ? "rpc request failed" : ""));
|
|
19
673
|
};
|
|
674
|
+
let endpoint = "";
|
|
20
675
|
try {
|
|
21
676
|
if (typeof ctx.connection.requestRejection !== "function") {
|
|
677
|
+
log?.error("rpc.admission.unavailable", "Host RPC admission policy is unavailable.", void 0, {
|
|
678
|
+
channel,
|
|
679
|
+
status: 503
|
|
680
|
+
});
|
|
22
681
|
reject(503);
|
|
23
682
|
return;
|
|
24
683
|
}
|
|
25
684
|
const rejection = ctx.connection.requestRejection(req);
|
|
26
685
|
if (rejection !== void 0) {
|
|
686
|
+
log?.warn("rpc.admission.rejected", "Host RPC request rejected by connection policy.", void 0, {
|
|
687
|
+
channel,
|
|
688
|
+
status: rejection
|
|
689
|
+
});
|
|
27
690
|
reject(rejection);
|
|
28
691
|
return;
|
|
29
692
|
}
|
|
30
693
|
const pathname = new URL(req.url ?? "/", "http://dsh.internal").pathname;
|
|
31
|
-
|
|
694
|
+
endpoint = pathname.startsWith(`${channel}/`) ? pathname.slice(channel.length + 1) : "";
|
|
32
695
|
if (req.method !== "POST" || !isEndpoint(endpoint)) {
|
|
696
|
+
log?.debug("rpc.route.rejected", "Host RPC route or method rejected.", {
|
|
697
|
+
channel,
|
|
698
|
+
endpoint: endpoint || null,
|
|
699
|
+
method: req.method ?? null,
|
|
700
|
+
status: 404
|
|
701
|
+
});
|
|
33
702
|
reject(404);
|
|
34
703
|
return;
|
|
35
704
|
}
|
|
36
705
|
const contentType = String(req.headers["content-type"] ?? "").split(";", 1)[0]?.trim();
|
|
37
706
|
if ((options.caseInsensitiveContentType ? contentType.toLowerCase() : contentType) !== "application/json") {
|
|
707
|
+
log?.debug("rpc.content-type.rejected", "Host RPC content-type rejected.", {
|
|
708
|
+
channel,
|
|
709
|
+
endpoint,
|
|
710
|
+
status: 415
|
|
711
|
+
});
|
|
38
712
|
reject(415);
|
|
39
713
|
return;
|
|
40
714
|
}
|
|
41
715
|
const guarded = options.beforeBody?.(req, endpoint);
|
|
42
716
|
if (guarded) {
|
|
717
|
+
log?.debug("rpc.guard.rejected", "Host RPC request rejected before body read.", {
|
|
718
|
+
channel,
|
|
719
|
+
endpoint,
|
|
720
|
+
status: guarded.status
|
|
721
|
+
});
|
|
43
722
|
reject(guarded.status, void 0, guarded.body);
|
|
44
723
|
return;
|
|
45
724
|
}
|
|
@@ -53,6 +732,11 @@ function registerHostRpcTransport(ctx, channel, dispatch, options = {}) {
|
|
|
53
732
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
54
733
|
size += buffer.length;
|
|
55
734
|
if (size > bodyLimit) {
|
|
735
|
+
log?.warn("rpc.body.too-large", "Host RPC request exceeded body limit.", void 0, {
|
|
736
|
+
channel,
|
|
737
|
+
endpoint,
|
|
738
|
+
maxBodyBytes: bodyLimit
|
|
739
|
+
});
|
|
56
740
|
reject(413);
|
|
57
741
|
req.destroy();
|
|
58
742
|
return;
|
|
@@ -63,11 +747,26 @@ function registerHostRpcTransport(ctx, channel, dispatch, options = {}) {
|
|
|
63
747
|
try {
|
|
64
748
|
body = JSON.parse(Buffer.concat(chunks).toString("utf8") || (options.emptyBodyAsNull ? "null" : ""));
|
|
65
749
|
} catch {
|
|
750
|
+
log?.debug("rpc.body.invalid-json", "Host RPC request body is not valid JSON.", {
|
|
751
|
+
channel,
|
|
752
|
+
endpoint,
|
|
753
|
+
status: 400
|
|
754
|
+
});
|
|
66
755
|
reject(400);
|
|
67
756
|
return;
|
|
68
757
|
}
|
|
69
|
-
|
|
758
|
+
if (log) await withPluginLogContext({
|
|
759
|
+
rpcChannel: channel,
|
|
760
|
+
rpcEndpoint: endpoint,
|
|
761
|
+
operationId: nextPluginOperationId("rpc")
|
|
762
|
+
}, () => dispatch(endpoint, body, controller.signal, res));
|
|
763
|
+
else await dispatch(endpoint, body, controller.signal, res);
|
|
70
764
|
} catch (error) {
|
|
765
|
+
log?.error("rpc.dispatch.failed", "Unhandled host RPC handler error.", error, {
|
|
766
|
+
channel,
|
|
767
|
+
endpoint: endpoint || null,
|
|
768
|
+
status: 500
|
|
769
|
+
});
|
|
71
770
|
reject(500, error);
|
|
72
771
|
}
|
|
73
772
|
}
|
|
@@ -88,19 +787,41 @@ function sendHostRpcResponse(response, rpcId, result, noStore = true) {
|
|
|
88
787
|
}
|
|
89
788
|
/** Safe host RPC registration. Missing requestRejection fails closed. */
|
|
90
789
|
function registerHostRpc(ctx, channel, call, options = {}) {
|
|
790
|
+
const log = options.logger;
|
|
91
791
|
return registerHostRpcTransport(ctx, channel, async (endpoint, body, signal, res) => {
|
|
92
792
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
793
|
+
log?.debug("rpc.envelope.invalid", "Host RPC envelope must be a JSON object.", {
|
|
794
|
+
channel,
|
|
795
|
+
endpoint
|
|
796
|
+
});
|
|
93
797
|
res.writeHead(400);
|
|
94
798
|
res.end();
|
|
95
799
|
return;
|
|
96
800
|
}
|
|
97
801
|
const request = body;
|
|
98
802
|
if (request.type !== "client-request" || request.method !== endpoint || typeof request.rpcId !== "string" || options.requirePayload && !Object.hasOwn(request, "payload")) {
|
|
803
|
+
log?.debug("rpc.envelope.invalid", "Host RPC envelope failed validation.", {
|
|
804
|
+
channel,
|
|
805
|
+
endpoint
|
|
806
|
+
});
|
|
99
807
|
res.writeHead(400);
|
|
100
808
|
res.end();
|
|
101
809
|
return;
|
|
102
810
|
}
|
|
811
|
+
const started = Date.now();
|
|
103
812
|
const raw = await call(endpoint, request.payload, signal);
|
|
813
|
+
const durationMs = Date.now() - started;
|
|
814
|
+
if (raw.ok) log?.debug("rpc.call.completed", "Host RPC call succeeded.", {
|
|
815
|
+
channel,
|
|
816
|
+
endpoint,
|
|
817
|
+
durationMs
|
|
818
|
+
});
|
|
819
|
+
else log?.warn("rpc.call.error", "Host RPC call returned an application error.", void 0, {
|
|
820
|
+
channel,
|
|
821
|
+
endpoint,
|
|
822
|
+
code: raw.error?.code ?? null,
|
|
823
|
+
durationMs
|
|
824
|
+
});
|
|
104
825
|
const result = raw.ok ? raw : {
|
|
105
826
|
...raw,
|
|
106
827
|
error: {
|
|
@@ -112,1102 +833,6 @@ function registerHostRpc(ctx, channel, call, options = {}) {
|
|
|
112
833
|
}, options);
|
|
113
834
|
}
|
|
114
835
|
//#endregion
|
|
115
|
-
//#region src/core/formula.js
|
|
116
|
-
const FUNCTIONS = {
|
|
117
|
-
min: {
|
|
118
|
-
args: [1, Infinity],
|
|
119
|
-
run: (...values) => Math.min(...values.map(num))
|
|
120
|
-
},
|
|
121
|
-
max: {
|
|
122
|
-
args: [1, Infinity],
|
|
123
|
-
run: (...values) => Math.max(...values.map(num))
|
|
124
|
-
},
|
|
125
|
-
abs: {
|
|
126
|
-
args: [1, 1],
|
|
127
|
-
run: (value) => Math.abs(num(value))
|
|
128
|
-
},
|
|
129
|
-
floor: {
|
|
130
|
-
args: [1, 1],
|
|
131
|
-
run: (value) => Math.floor(num(value))
|
|
132
|
-
},
|
|
133
|
-
ceil: {
|
|
134
|
-
args: [1, 1],
|
|
135
|
-
run: (value) => Math.ceil(num(value))
|
|
136
|
-
},
|
|
137
|
-
sqrt: {
|
|
138
|
-
args: [1, 1],
|
|
139
|
-
run: (value) => Math.sqrt(num(value))
|
|
140
|
-
},
|
|
141
|
-
pow: {
|
|
142
|
-
args: [2, 2],
|
|
143
|
-
run: (base, power) => num(base) ** num(power)
|
|
144
|
-
},
|
|
145
|
-
round: {
|
|
146
|
-
args: [1, 2],
|
|
147
|
-
run: (value, digits = 0) => roundTo(num(value), digitsOf(digits))
|
|
148
|
-
},
|
|
149
|
-
clamp: {
|
|
150
|
-
args: [3, 3],
|
|
151
|
-
run: (value, low, high) => Math.min(Math.max(num(value), num(low)), num(high))
|
|
152
|
-
},
|
|
153
|
-
fixed: {
|
|
154
|
-
args: [1, 2],
|
|
155
|
-
run: (value, digits = 0) => roundTo(num(value), digitsOf(digits)).toFixed(digitsOf(digits))
|
|
156
|
-
},
|
|
157
|
-
format: {
|
|
158
|
-
args: [1, 2],
|
|
159
|
-
run: (value, digits = 0) => grouped(num(value), digitsOf(digits))
|
|
160
|
-
},
|
|
161
|
-
percent: {
|
|
162
|
-
args: [1, 2],
|
|
163
|
-
run: (value, digits = 0) => `${grouped(num(value) * 100, digitsOf(digits))}%`
|
|
164
|
-
}
|
|
165
|
-
};
|
|
166
|
-
const FORMULA_FUNCTIONS = Object.keys(FUNCTIONS);
|
|
167
|
-
const CONSTANTS = /* @__PURE__ */ new Map([["true", true], ["false", false]]);
|
|
168
|
-
const MAX_NESTING = 64;
|
|
169
|
-
var FormulaError = class extends Error {};
|
|
170
|
-
const fail$1 = (message) => {
|
|
171
|
-
throw new FormulaError(message);
|
|
172
|
-
};
|
|
173
|
-
function num(value) {
|
|
174
|
-
if (typeof value === "number") return value;
|
|
175
|
-
if (typeof value === "boolean") return value ? 1 : 0;
|
|
176
|
-
const parsed = Number(value);
|
|
177
|
-
if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(parsed)) return parsed;
|
|
178
|
-
return fail$1(`${JSON.stringify(value)} is not a number`);
|
|
179
|
-
}
|
|
180
|
-
const digitsOf = (digits) => {
|
|
181
|
-
const value = num(digits);
|
|
182
|
-
if (!Number.isInteger(value) || value < 0 || value > 10) fail$1("digits must be a whole number from 0 to 10");
|
|
183
|
-
return value;
|
|
184
|
-
};
|
|
185
|
-
const roundTo = (value, digits) => {
|
|
186
|
-
const scale = 10 ** digits;
|
|
187
|
-
return Math.round(value * scale) / scale;
|
|
188
|
-
};
|
|
189
|
-
function grouped(value, digits) {
|
|
190
|
-
if (!Number.isFinite(value)) return String(value);
|
|
191
|
-
const [whole, part] = Math.abs(roundTo(value, digits)).toFixed(digits).split(".");
|
|
192
|
-
return `${value < 0 && Number(whole + (part ?? "")) !== 0 ? "-" : ""}${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}${part ? `.${part}` : ""}`;
|
|
193
|
-
}
|
|
194
|
-
const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
195
|
-
const isName = (value) => typeof value === "string" && IDENT.test(value);
|
|
196
|
-
const OPERATORS = [
|
|
197
|
-
"==",
|
|
198
|
-
"!=",
|
|
199
|
-
"<=",
|
|
200
|
-
">=",
|
|
201
|
-
"&&",
|
|
202
|
-
"||",
|
|
203
|
-
"+",
|
|
204
|
-
"-",
|
|
205
|
-
"*",
|
|
206
|
-
"/",
|
|
207
|
-
"%",
|
|
208
|
-
"<",
|
|
209
|
-
">",
|
|
210
|
-
"!",
|
|
211
|
-
"?",
|
|
212
|
-
":",
|
|
213
|
-
"(",
|
|
214
|
-
")",
|
|
215
|
-
","
|
|
216
|
-
];
|
|
217
|
-
function tokenize(source) {
|
|
218
|
-
const tokens = [];
|
|
219
|
-
let at = 0;
|
|
220
|
-
while (at < source.length) {
|
|
221
|
-
const char = source[at];
|
|
222
|
-
if (/\s/.test(char)) {
|
|
223
|
-
at += 1;
|
|
224
|
-
continue;
|
|
225
|
-
}
|
|
226
|
-
const number = /^(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/.exec(source.slice(at));
|
|
227
|
-
if (number) {
|
|
228
|
-
tokens.push({
|
|
229
|
-
kind: "number",
|
|
230
|
-
value: Number(number[0]),
|
|
231
|
-
at
|
|
232
|
-
});
|
|
233
|
-
at += number[0].length;
|
|
234
|
-
continue;
|
|
235
|
-
}
|
|
236
|
-
const name = /^[A-Za-z_][A-Za-z0-9_]*/.exec(source.slice(at));
|
|
237
|
-
if (name) {
|
|
238
|
-
tokens.push({
|
|
239
|
-
kind: "name",
|
|
240
|
-
value: name[0],
|
|
241
|
-
at
|
|
242
|
-
});
|
|
243
|
-
at += name[0].length;
|
|
244
|
-
continue;
|
|
245
|
-
}
|
|
246
|
-
if (char === "\"" || char === "'") {
|
|
247
|
-
let end = at + 1;
|
|
248
|
-
let text = "";
|
|
249
|
-
while (end < source.length && source[end] !== char) {
|
|
250
|
-
if (source[end] === "\\" && end + 1 < source.length) {
|
|
251
|
-
text += source[end + 1];
|
|
252
|
-
end += 2;
|
|
253
|
-
continue;
|
|
254
|
-
}
|
|
255
|
-
text += source[end];
|
|
256
|
-
end += 1;
|
|
257
|
-
}
|
|
258
|
-
if (end >= source.length) fail$1(`unclosed string at ${at + 1}`);
|
|
259
|
-
tokens.push({
|
|
260
|
-
kind: "string",
|
|
261
|
-
value: text,
|
|
262
|
-
at
|
|
263
|
-
});
|
|
264
|
-
at = end + 1;
|
|
265
|
-
continue;
|
|
266
|
-
}
|
|
267
|
-
const operator = OPERATORS.find((op) => source.startsWith(op, at));
|
|
268
|
-
if (operator === void 0) fail$1(`unexpected ${JSON.stringify(char)} at ${at + 1}`);
|
|
269
|
-
tokens.push({
|
|
270
|
-
kind: "op",
|
|
271
|
-
value: operator,
|
|
272
|
-
at
|
|
273
|
-
});
|
|
274
|
-
at += operator.length;
|
|
275
|
-
}
|
|
276
|
-
return tokens;
|
|
277
|
-
}
|
|
278
|
-
const BINARY = [
|
|
279
|
-
["||"],
|
|
280
|
-
["&&"],
|
|
281
|
-
["==", "!="],
|
|
282
|
-
[
|
|
283
|
-
"<",
|
|
284
|
-
"<=",
|
|
285
|
-
">",
|
|
286
|
-
">="
|
|
287
|
-
],
|
|
288
|
-
["+", "-"],
|
|
289
|
-
[
|
|
290
|
-
"*",
|
|
291
|
-
"/",
|
|
292
|
-
"%"
|
|
293
|
-
]
|
|
294
|
-
];
|
|
295
|
-
function parse(source) {
|
|
296
|
-
const tokens = tokenize(source);
|
|
297
|
-
let index = 0;
|
|
298
|
-
let depth = 0;
|
|
299
|
-
const peek = () => tokens[index];
|
|
300
|
-
const isOp = (value) => peek()?.kind === "op" && peek().value === value;
|
|
301
|
-
const expect = (value) => {
|
|
302
|
-
if (!isOp(value)) fail$1(peek() ? `expected "${value}" at ${peek().at + 1}` : `expected "${value}" at the end`);
|
|
303
|
-
index += 1;
|
|
304
|
-
};
|
|
305
|
-
const nest = (read) => {
|
|
306
|
-
depth += 1;
|
|
307
|
-
if (depth > MAX_NESTING) fail$1(`nested more than ${MAX_NESTING} levels`);
|
|
308
|
-
const node = read();
|
|
309
|
-
depth -= 1;
|
|
310
|
-
return node;
|
|
311
|
-
};
|
|
312
|
-
const ternary = () => nest(() => {
|
|
313
|
-
const test = binary(0);
|
|
314
|
-
if (!isOp("?")) return test;
|
|
315
|
-
index += 1;
|
|
316
|
-
const yes = ternary();
|
|
317
|
-
expect(":");
|
|
318
|
-
return {
|
|
319
|
-
type: "if",
|
|
320
|
-
test,
|
|
321
|
-
yes,
|
|
322
|
-
no: ternary()
|
|
323
|
-
};
|
|
324
|
-
});
|
|
325
|
-
const binary = (level) => {
|
|
326
|
-
if (level === BINARY.length) return unary();
|
|
327
|
-
let left = binary(level + 1);
|
|
328
|
-
while (peek()?.kind === "op" && BINARY[level].includes(peek().value)) {
|
|
329
|
-
const op = peek().value;
|
|
330
|
-
index += 1;
|
|
331
|
-
left = {
|
|
332
|
-
type: "binary",
|
|
333
|
-
op,
|
|
334
|
-
left,
|
|
335
|
-
right: binary(level + 1)
|
|
336
|
-
};
|
|
337
|
-
}
|
|
338
|
-
return left;
|
|
339
|
-
};
|
|
340
|
-
const unary = () => {
|
|
341
|
-
if (isOp("-") || isOp("!") || isOp("+")) {
|
|
342
|
-
const op = peek().value;
|
|
343
|
-
index += 1;
|
|
344
|
-
return nest(() => ({
|
|
345
|
-
type: "unary",
|
|
346
|
-
op,
|
|
347
|
-
value: unary()
|
|
348
|
-
}));
|
|
349
|
-
}
|
|
350
|
-
return primary();
|
|
351
|
-
};
|
|
352
|
-
const primary = () => {
|
|
353
|
-
const token = peek();
|
|
354
|
-
if (token === void 0) fail$1("the formula ends too early");
|
|
355
|
-
index += 1;
|
|
356
|
-
if (token.kind === "number" || token.kind === "string") return {
|
|
357
|
-
type: "value",
|
|
358
|
-
value: token.value
|
|
359
|
-
};
|
|
360
|
-
if (token.kind === "op" && token.value === "(") {
|
|
361
|
-
const inner = ternary();
|
|
362
|
-
expect(")");
|
|
363
|
-
return inner;
|
|
364
|
-
}
|
|
365
|
-
if (token.kind !== "name") fail$1(`unexpected "${token.value}" at ${token.at + 1}`);
|
|
366
|
-
if (!isOp("(")) return CONSTANTS.has(token.value) ? {
|
|
367
|
-
type: "value",
|
|
368
|
-
value: CONSTANTS.get(token.value)
|
|
369
|
-
} : {
|
|
370
|
-
type: "name",
|
|
371
|
-
name: token.value
|
|
372
|
-
};
|
|
373
|
-
const fn = Object.hasOwn(FUNCTIONS, token.value) ? FUNCTIONS[token.value] : void 0;
|
|
374
|
-
if (fn === void 0) fail$1(`unknown function "${token.value}"; allowed: ${FORMULA_FUNCTIONS.join(", ")}`);
|
|
375
|
-
index += 1;
|
|
376
|
-
const args = [];
|
|
377
|
-
if (!isOp(")")) {
|
|
378
|
-
args.push(ternary());
|
|
379
|
-
while (isOp(",")) {
|
|
380
|
-
index += 1;
|
|
381
|
-
args.push(ternary());
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
expect(")");
|
|
385
|
-
if (args.length < fn.args[0] || args.length > fn.args[1]) fail$1(`${token.value}() takes ${fn.args[0] === fn.args[1] ? fn.args[0] : fn.args[1] === Infinity ? `at least ${fn.args[0]}` : `${fn.args[0]} or ${fn.args[1]}`} argument${fn.args[1] === 1 ? "" : "s"}; got ${args.length}`);
|
|
386
|
-
return {
|
|
387
|
-
type: "call",
|
|
388
|
-
name: token.value,
|
|
389
|
-
args
|
|
390
|
-
};
|
|
391
|
-
};
|
|
392
|
-
if (tokens.length === 0) fail$1("the formula is empty");
|
|
393
|
-
const tree = ternary();
|
|
394
|
-
if (index < tokens.length) fail$1(`unexpected "${peek().value}" at ${peek().at + 1}`);
|
|
395
|
-
return tree;
|
|
396
|
-
}
|
|
397
|
-
function namesIn(node, found = /* @__PURE__ */ new Set()) {
|
|
398
|
-
if (node.type === "name") found.add(node.name);
|
|
399
|
-
else if (node.type === "unary") namesIn(node.value, found);
|
|
400
|
-
else if (node.type === "binary") {
|
|
401
|
-
namesIn(node.left, found);
|
|
402
|
-
namesIn(node.right, found);
|
|
403
|
-
} else if (node.type === "if") {
|
|
404
|
-
namesIn(node.test, found);
|
|
405
|
-
namesIn(node.yes, found);
|
|
406
|
-
namesIn(node.no, found);
|
|
407
|
-
} else if (node.type === "call") for (const arg of node.args) namesIn(arg, found);
|
|
408
|
-
return found;
|
|
409
|
-
}
|
|
410
|
-
function compileFormula(source) {
|
|
411
|
-
if (typeof source !== "string") return { error: "expected a formula string" };
|
|
412
|
-
try {
|
|
413
|
-
const tree = parse(source);
|
|
414
|
-
return {
|
|
415
|
-
tree,
|
|
416
|
-
names: [...namesIn(tree)]
|
|
417
|
-
};
|
|
418
|
-
} catch (error) {
|
|
419
|
-
if (error instanceof FormulaError) return { error: error.message };
|
|
420
|
-
throw error;
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
function run(node, lookup) {
|
|
424
|
-
switch (node.type) {
|
|
425
|
-
case "value": return node.value;
|
|
426
|
-
case "name": return lookup(node.name);
|
|
427
|
-
case "unary": {
|
|
428
|
-
const value = run(node.value, lookup);
|
|
429
|
-
if (node.op === "!") return !truthy(value);
|
|
430
|
-
return node.op === "-" ? -num(value) : num(value);
|
|
431
|
-
}
|
|
432
|
-
case "if": return truthy(run(node.test, lookup)) ? run(node.yes, lookup) : run(node.no, lookup);
|
|
433
|
-
case "call": return FUNCTIONS[node.name].run(...node.args.map((arg) => run(arg, lookup)));
|
|
434
|
-
default: return binary(node, lookup);
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
const truthy = (value) => typeof value === "string" ? value !== "" : Boolean(value);
|
|
438
|
-
function binary(node, lookup) {
|
|
439
|
-
if (node.op === "&&") return truthy(run(node.left, lookup)) ? run(node.right, lookup) : false;
|
|
440
|
-
if (node.op === "||") {
|
|
441
|
-
const left = run(node.left, lookup);
|
|
442
|
-
return truthy(left) ? left : run(node.right, lookup);
|
|
443
|
-
}
|
|
444
|
-
const left = run(node.left, lookup);
|
|
445
|
-
const right = run(node.right, lookup);
|
|
446
|
-
switch (node.op) {
|
|
447
|
-
case "+": return typeof left === "string" || typeof right === "string" ? `${show(left)}${show(right)}` : num(left) + num(right);
|
|
448
|
-
case "-": return num(left) - num(right);
|
|
449
|
-
case "*": return num(left) * num(right);
|
|
450
|
-
case "/": return num(left) / num(right);
|
|
451
|
-
case "%": return num(left) % num(right);
|
|
452
|
-
case "==": return left === right;
|
|
453
|
-
case "!=": return left !== right;
|
|
454
|
-
case "<": return num(left) < num(right);
|
|
455
|
-
case "<=": return num(left) <= num(right);
|
|
456
|
-
case ">": return num(left) > num(right);
|
|
457
|
-
default: return num(left) >= num(right);
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
function show(value) {
|
|
461
|
-
if (typeof value === "number") return Number.isFinite(value) ? String(Number(value.toPrecision(12))) : String(value);
|
|
462
|
-
return String(value);
|
|
463
|
-
}
|
|
464
|
-
function createScope(inputs, lets) {
|
|
465
|
-
const done = /* @__PURE__ */ new Map();
|
|
466
|
-
const busy = /* @__PURE__ */ new Set();
|
|
467
|
-
const lookup = (name) => {
|
|
468
|
-
if (inputs.has(name)) return inputs.get(name);
|
|
469
|
-
if (done.has(name)) return done.get(name);
|
|
470
|
-
const tree = lets.get(name);
|
|
471
|
-
if (tree === void 0) fail$1(`unknown name "${name}"`);
|
|
472
|
-
if (busy.has(name)) fail$1(`"${name}" refers to itself`);
|
|
473
|
-
busy.add(name);
|
|
474
|
-
try {
|
|
475
|
-
const value = run(tree, lookup);
|
|
476
|
-
done.set(name, value);
|
|
477
|
-
return value;
|
|
478
|
-
} finally {
|
|
479
|
-
busy.delete(name);
|
|
480
|
-
}
|
|
481
|
-
};
|
|
482
|
-
return lookup;
|
|
483
|
-
}
|
|
484
|
-
function evaluate(tree, lookup) {
|
|
485
|
-
try {
|
|
486
|
-
return { value: run(tree, lookup) };
|
|
487
|
-
} catch (error) {
|
|
488
|
-
if (error instanceof FormulaError) return { error: error.message };
|
|
489
|
-
if (error instanceof RangeError) return { error: "the formula is too deep to work out" };
|
|
490
|
-
throw error;
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
//#endregion
|
|
494
|
-
//#region src/core/views.js
|
|
495
|
-
const LAYOUT = ["stack", "row"];
|
|
496
|
-
const DISPLAY = [
|
|
497
|
-
"text",
|
|
498
|
-
"metric",
|
|
499
|
-
"table",
|
|
500
|
-
"chart",
|
|
501
|
-
"progress",
|
|
502
|
-
"callout",
|
|
503
|
-
"sources"
|
|
504
|
-
];
|
|
505
|
-
const INPUT_TYPES = [
|
|
506
|
-
"slider",
|
|
507
|
-
"choice",
|
|
508
|
-
"toggle",
|
|
509
|
-
"number"
|
|
510
|
-
];
|
|
511
|
-
const ANSWERS = [
|
|
512
|
-
"options",
|
|
513
|
-
"confirm",
|
|
514
|
-
"form",
|
|
515
|
-
"submit"
|
|
516
|
-
];
|
|
517
|
-
const COMPONENTS = [
|
|
518
|
-
...LAYOUT,
|
|
519
|
-
...DISPLAY,
|
|
520
|
-
...INPUT_TYPES,
|
|
521
|
-
...ANSWERS
|
|
522
|
-
];
|
|
523
|
-
const TONES = [
|
|
524
|
-
"good",
|
|
525
|
-
"warn",
|
|
526
|
-
"bad"
|
|
527
|
-
];
|
|
528
|
-
const TEXT_TONES = ["muted"];
|
|
529
|
-
const CALLOUT_TONES = [
|
|
530
|
-
"info",
|
|
531
|
-
"good",
|
|
532
|
-
"warn",
|
|
533
|
-
"bad"
|
|
534
|
-
];
|
|
535
|
-
const CHART_KINDS = [
|
|
536
|
-
"bar",
|
|
537
|
-
"hbar",
|
|
538
|
-
"line",
|
|
539
|
-
"area",
|
|
540
|
-
"donut"
|
|
541
|
-
];
|
|
542
|
-
const INTERACTIVE = new Set(ANSWERS);
|
|
543
|
-
const INPUTS = new Set(INPUT_TYPES);
|
|
544
|
-
const PROPS = {
|
|
545
|
-
stack: ["children", "let"],
|
|
546
|
-
row: ["children", "let"],
|
|
547
|
-
text: ["text", "tone"],
|
|
548
|
-
metric: [
|
|
549
|
-
"label",
|
|
550
|
-
"value",
|
|
551
|
-
"tone",
|
|
552
|
-
"caption"
|
|
553
|
-
],
|
|
554
|
-
table: ["columns", "rows"],
|
|
555
|
-
chart: [
|
|
556
|
-
"kind",
|
|
557
|
-
"categories",
|
|
558
|
-
"series",
|
|
559
|
-
"unit",
|
|
560
|
-
"caption"
|
|
561
|
-
],
|
|
562
|
-
progress: [
|
|
563
|
-
"label",
|
|
564
|
-
"percent",
|
|
565
|
-
"value",
|
|
566
|
-
"max",
|
|
567
|
-
"valueLabel",
|
|
568
|
-
"tone"
|
|
569
|
-
],
|
|
570
|
-
callout: ["text", "tone"],
|
|
571
|
-
sources: ["items"],
|
|
572
|
-
slider: [
|
|
573
|
-
"id",
|
|
574
|
-
"label",
|
|
575
|
-
"min",
|
|
576
|
-
"max",
|
|
577
|
-
"step",
|
|
578
|
-
"value",
|
|
579
|
-
"unit"
|
|
580
|
-
],
|
|
581
|
-
choice: [
|
|
582
|
-
"id",
|
|
583
|
-
"label",
|
|
584
|
-
"options",
|
|
585
|
-
"value"
|
|
586
|
-
],
|
|
587
|
-
toggle: [
|
|
588
|
-
"id",
|
|
589
|
-
"label",
|
|
590
|
-
"value"
|
|
591
|
-
],
|
|
592
|
-
number: [
|
|
593
|
-
"id",
|
|
594
|
-
"label",
|
|
595
|
-
"min",
|
|
596
|
-
"max",
|
|
597
|
-
"step",
|
|
598
|
-
"value",
|
|
599
|
-
"unit"
|
|
600
|
-
],
|
|
601
|
-
options: [
|
|
602
|
-
"id",
|
|
603
|
-
"options",
|
|
604
|
-
"multi"
|
|
605
|
-
],
|
|
606
|
-
confirm: [
|
|
607
|
-
"id",
|
|
608
|
-
"confirmLabel",
|
|
609
|
-
"cancelLabel"
|
|
610
|
-
],
|
|
611
|
-
form: [
|
|
612
|
-
"id",
|
|
613
|
-
"fields",
|
|
614
|
-
"submitLabel"
|
|
615
|
-
],
|
|
616
|
-
submit: ["id", "label"]
|
|
617
|
-
};
|
|
618
|
-
const MAX_DEPTH = 8;
|
|
619
|
-
const MAX_NODES = 64;
|
|
620
|
-
const MAX_CHILDREN = 16;
|
|
621
|
-
const MAX_TEXT = 2e3;
|
|
622
|
-
const MAX_CELL = 200;
|
|
623
|
-
const MAX_COLUMNS = 8;
|
|
624
|
-
const MAX_ROWS = 50;
|
|
625
|
-
const MAX_OPTIONS = 8;
|
|
626
|
-
const MAX_FIELDS = 6;
|
|
627
|
-
const SUMMARY_PART = 40;
|
|
628
|
-
const SUMMARY_COUNT = 8;
|
|
629
|
-
const RESERVED = /* @__PURE__ */ new Set(["true", "false"]);
|
|
630
|
-
const VIEW_BYTE_LIMIT = 8192;
|
|
631
|
-
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
632
|
-
const quoted = (value) => JSON.stringify(value);
|
|
633
|
-
const allowed = (list) => list.join(", ");
|
|
634
|
-
const isNumber = (value) => typeof value === "number" && Number.isFinite(value);
|
|
635
|
-
const isScalar = (value) => typeof value === "string" || typeof value === "boolean" || isNumber(value);
|
|
636
|
-
const isFormula = (value) => isRecord(value) && Object.keys(value).length === 1 && Object.hasOwn(value, "=");
|
|
637
|
-
function viewBytes(title, view) {
|
|
638
|
-
return new TextEncoder().encode(`${JSON.stringify(view) ?? ""}${title ?? ""}`).length;
|
|
639
|
-
}
|
|
640
|
-
const NUMERIC = [
|
|
641
|
-
"value",
|
|
642
|
-
"percent",
|
|
643
|
-
"min",
|
|
644
|
-
"max",
|
|
645
|
-
"step",
|
|
646
|
-
"height"
|
|
647
|
-
];
|
|
648
|
-
const numeric = (value) => typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value)) ? Number(value) : value;
|
|
649
|
-
function normalizeView(value) {
|
|
650
|
-
if (Array.isArray(value)) return value.map(normalizeView);
|
|
651
|
-
if (!isRecord(value)) return value;
|
|
652
|
-
const keys = Object.keys(value);
|
|
653
|
-
if (keys.length === 1 && keys[0] === "item") {
|
|
654
|
-
const inner = normalizeView(value.item);
|
|
655
|
-
if (Array.isArray(inner)) return inner;
|
|
656
|
-
}
|
|
657
|
-
const out = {};
|
|
658
|
-
for (const key of keys) Object.defineProperty(out, key, {
|
|
659
|
-
value: normalizeView(value[key]),
|
|
660
|
-
writable: true,
|
|
661
|
-
enumerable: true,
|
|
662
|
-
configurable: true
|
|
663
|
-
});
|
|
664
|
-
for (const field of NUMERIC) if (Object.hasOwn(out, field)) out[field] = numeric(out[field]);
|
|
665
|
-
if (Array.isArray(out.values)) out.values = out.values.map(numeric);
|
|
666
|
-
return out;
|
|
667
|
-
}
|
|
668
|
-
function validateView(view) {
|
|
669
|
-
const ctx = {
|
|
670
|
-
ids: /* @__PURE__ */ new Set(),
|
|
671
|
-
nodes: 0,
|
|
672
|
-
interactive: false,
|
|
673
|
-
inputs: /* @__PURE__ */ new Map(),
|
|
674
|
-
lets: /* @__PURE__ */ new Map(),
|
|
675
|
-
formulas: []
|
|
676
|
-
};
|
|
677
|
-
const error = checkNode(view, "view", 1, ctx) ?? checkFormulas(ctx);
|
|
678
|
-
return error === void 0 ? {
|
|
679
|
-
ok: true,
|
|
680
|
-
interactive: ctx.interactive
|
|
681
|
-
} : {
|
|
682
|
-
ok: false,
|
|
683
|
-
error
|
|
684
|
-
};
|
|
685
|
-
}
|
|
686
|
-
function checkShownView(title, sent) {
|
|
687
|
-
let bytes;
|
|
688
|
-
try {
|
|
689
|
-
bytes = viewBytes(title, sent);
|
|
690
|
-
} catch {
|
|
691
|
-
return { error: "The view is not JSON data." };
|
|
692
|
-
}
|
|
693
|
-
if (bytes > 8192) return { error: `The view is ${bytes} bytes; the limit is ${VIEW_BYTE_LIMIT}.` };
|
|
694
|
-
if (title !== void 0 && title !== null && typeof title !== "string") return { error: "title: expected a string" };
|
|
695
|
-
const named = String(title ?? "").trim();
|
|
696
|
-
const view = normalizeView(sent);
|
|
697
|
-
const checked = validateView(view);
|
|
698
|
-
if (!checked.ok) return { error: checked.error };
|
|
699
|
-
return {
|
|
700
|
-
title: named,
|
|
701
|
-
view,
|
|
702
|
-
interactive: checked.interactive
|
|
703
|
-
};
|
|
704
|
-
}
|
|
705
|
-
function checkNode(node, path, depth, ctx) {
|
|
706
|
-
if (!isRecord(node)) return `${path}: expected a component object`;
|
|
707
|
-
if (depth > MAX_DEPTH) return `${path}: ${depth} levels exceeds the limit of ${MAX_DEPTH}`;
|
|
708
|
-
ctx.nodes += 1;
|
|
709
|
-
if (ctx.nodes > MAX_NODES) return `${path}: ${ctx.nodes} components exceeds the limit of ${MAX_NODES}`;
|
|
710
|
-
const type = node.type;
|
|
711
|
-
if (!COMPONENTS.includes(type)) return `${path}.type: unknown component ${quoted(type)}; allowed: ${allowed(COMPONENTS)}`;
|
|
712
|
-
const props = PROPS[type];
|
|
713
|
-
for (const key of Object.keys(node)) {
|
|
714
|
-
if (key === "type" || props.includes(key)) continue;
|
|
715
|
-
return `${path}.${key}: unknown property ${quoted(key)}; allowed: ${allowed(props)}`;
|
|
716
|
-
}
|
|
717
|
-
if (INTERACTIVE.has(type)) ctx.interactive = true;
|
|
718
|
-
switch (type) {
|
|
719
|
-
case "stack":
|
|
720
|
-
case "row": return checkLayout(node, path, depth, ctx);
|
|
721
|
-
case "text": return checkText(node, path, ctx);
|
|
722
|
-
case "metric": return checkMetric(node, path, ctx);
|
|
723
|
-
case "table": return checkTable(node, path, ctx);
|
|
724
|
-
case "chart": return checkChart(node, path, ctx);
|
|
725
|
-
case "progress": return checkProgress(node, path, ctx);
|
|
726
|
-
case "callout": return checkCallout(node, path, ctx);
|
|
727
|
-
case "sources": return checkSources(node, path);
|
|
728
|
-
case "slider":
|
|
729
|
-
case "number": return checkNumberInput(node, path, ctx);
|
|
730
|
-
case "choice": return checkChoice(node, path, ctx);
|
|
731
|
-
case "toggle": return checkToggle(node, path, ctx);
|
|
732
|
-
case "options": return checkOptions(node, path, ctx);
|
|
733
|
-
case "confirm": return checkConfirm(node, path, ctx);
|
|
734
|
-
case "form": return checkForm(node, path, ctx);
|
|
735
|
-
default: return checkSubmit(node, path, ctx);
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
function checkLayout(node, path, depth, ctx) {
|
|
739
|
-
if (node.let !== void 0) {
|
|
740
|
-
if (depth !== 1) return `${path}.let: only the outermost component can define names`;
|
|
741
|
-
if (!isRecord(node.let)) return `${path}.let: expected an object of names to formulas`;
|
|
742
|
-
for (const [name, source] of Object.entries(node.let)) {
|
|
743
|
-
if (!isName(name) || RESERVED.has(name)) return `${path}.let.${name}: a name is letters, digits and _, and starts with a letter or _`;
|
|
744
|
-
if (typeof source !== "string") return `${path}.let.${name}: expected a formula string`;
|
|
745
|
-
ctx.lets.set(name, {
|
|
746
|
-
source,
|
|
747
|
-
path: `${path}.let.${name}`
|
|
748
|
-
});
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
if (!Array.isArray(node.children)) return `${path}.children: expected an array of 1 to ${MAX_CHILDREN} components`;
|
|
752
|
-
if (node.children.length < 1 || node.children.length > MAX_CHILDREN) return `${path}.children: expected 1 to ${MAX_CHILDREN} children; got ${node.children.length}`;
|
|
753
|
-
for (let index = 0; index < node.children.length; index += 1) {
|
|
754
|
-
const error = checkNode(node.children[index], `${path}.children[${index}]`, depth + 1, ctx);
|
|
755
|
-
if (error !== void 0) return error;
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
function checkValue(node, key, path, ctx, kind, { required = false, max = MAX_TEXT } = {}) {
|
|
759
|
-
const value = node[key];
|
|
760
|
-
const at = `${path}.${key}`;
|
|
761
|
-
if (value === void 0) return required ? `${at}: ${expected(kind)}` : void 0;
|
|
762
|
-
if (isFormula(value)) {
|
|
763
|
-
ctx.formulas.push({
|
|
764
|
-
source: value["="],
|
|
765
|
-
path: at,
|
|
766
|
-
kind
|
|
767
|
-
});
|
|
768
|
-
return;
|
|
769
|
-
}
|
|
770
|
-
if (kind === "number") return isNumber(value) ? void 0 : `${at}: ${expected(kind)}`;
|
|
771
|
-
if (Array.isArray(kind)) return kind.includes(value) ? void 0 : `${at}: unknown tone ${quoted(value)}; allowed: ${allowed(kind)}`;
|
|
772
|
-
if (kind === "string") {
|
|
773
|
-
if (typeof value !== "string") return `${at}: ${expected(kind)}`;
|
|
774
|
-
return value.length > max ? `${at}: ${value.length} characters exceeds the limit of ${max}` : void 0;
|
|
775
|
-
}
|
|
776
|
-
if (!isScalar(value)) return `${at}: ${expected(kind)}`;
|
|
777
|
-
}
|
|
778
|
-
const expected = (kind) => kind === "number" ? "expected a number" : kind === "string" ? "expected a string" : Array.isArray(kind) ? `expected one of ${allowed(kind)}` : "expected a string or a number";
|
|
779
|
-
function checkText(node, path, ctx) {
|
|
780
|
-
return checkValue(node, "text", path, ctx, "string", { required: true }) ?? checkValue(node, "tone", path, ctx, TEXT_TONES);
|
|
781
|
-
}
|
|
782
|
-
function checkMetric(node, path, ctx) {
|
|
783
|
-
if (typeof node.label !== "string") return `${path}.label: expected a string`;
|
|
784
|
-
if (node.value === void 0) return `${path}.value: expected a string or a number`;
|
|
785
|
-
if (typeof node.value === "number" && !Number.isFinite(node.value)) return `${path}.value: expected a finite number`;
|
|
786
|
-
return checkValue(node, "value", path, ctx, "scalar") ?? checkValue(node, "tone", path, ctx, TONES) ?? checkValue(node, "caption", path, ctx, "string");
|
|
787
|
-
}
|
|
788
|
-
function checkTable(node, path, ctx) {
|
|
789
|
-
if (!Array.isArray(node.columns)) return `${path}.columns: expected an array of at most ${MAX_COLUMNS} columns`;
|
|
790
|
-
if (node.columns.length > MAX_COLUMNS) return `${path}.columns: ${node.columns.length} columns exceeds the limit of ${MAX_COLUMNS}`;
|
|
791
|
-
for (let index = 0; index < node.columns.length; index += 1) {
|
|
792
|
-
const column = node.columns[index];
|
|
793
|
-
const at = `${path}.columns[${index}]`;
|
|
794
|
-
if (!isRecord(column)) return `${at}: expected a column object`;
|
|
795
|
-
for (const key of Object.keys(column)) if (key !== "key" && key !== "label") return `${at}.${key}: unknown property ${quoted(key)}; allowed: key, label`;
|
|
796
|
-
if (typeof column.key !== "string" || column.key === "") return `${at}.key: expected a non-empty string`;
|
|
797
|
-
if (typeof column.label !== "string") return `${at}.label: expected a string`;
|
|
798
|
-
}
|
|
799
|
-
if (!Array.isArray(node.rows)) return `${path}.rows: expected an array of at most ${MAX_ROWS} rows`;
|
|
800
|
-
if (node.rows.length > MAX_ROWS) return `${path}.rows: ${node.rows.length} rows exceeds the limit of ${MAX_ROWS}`;
|
|
801
|
-
for (let row = 0; row < node.rows.length; row += 1) {
|
|
802
|
-
const cells = node.rows[row];
|
|
803
|
-
if (!Array.isArray(cells)) return `${path}.rows[${row}]: expected an array of cells`;
|
|
804
|
-
for (let column = 0; column < cells.length; column += 1) {
|
|
805
|
-
const at = `${path}.rows[${row}][${column}]`;
|
|
806
|
-
if (isFormula(cells[column])) {
|
|
807
|
-
ctx.formulas.push({
|
|
808
|
-
source: cells[column]["="],
|
|
809
|
-
path: at,
|
|
810
|
-
kind: "scalar"
|
|
811
|
-
});
|
|
812
|
-
continue;
|
|
813
|
-
}
|
|
814
|
-
const text = cellText(cells[column]);
|
|
815
|
-
if (text.length > MAX_CELL) return `${at}: ${text.length} characters exceeds the limit of ${MAX_CELL}`;
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
function checkChart(node, path, ctx) {
|
|
820
|
-
if (node.kind !== void 0 && !CHART_KINDS.includes(node.kind)) return `${path}.kind: unknown chart ${quoted(node.kind)}; allowed: ${allowed(CHART_KINDS)}`;
|
|
821
|
-
if (!Array.isArray(node.categories) || node.categories.length === 0) return `${path}.categories: expected a non-empty array of labels`;
|
|
822
|
-
for (let index = 0; index < node.categories.length; index += 1) {
|
|
823
|
-
const label = node.categories[index];
|
|
824
|
-
if (typeof label !== "string" && !isNumber(label)) return `${path}.categories[${index}]: expected a string`;
|
|
825
|
-
}
|
|
826
|
-
if (!Array.isArray(node.series) || node.series.length === 0) return `${path}.series: expected a non-empty array of { name?, values }`;
|
|
827
|
-
if (node.kind === "donut" && node.series.length > 1) return `${path}.series: a donut takes one series; got ${node.series.length}`;
|
|
828
|
-
for (let index = 0; index < node.series.length; index += 1) {
|
|
829
|
-
const series = node.series[index];
|
|
830
|
-
const at = `${path}.series[${index}]`;
|
|
831
|
-
if (!isRecord(series)) return `${at}: expected a series object`;
|
|
832
|
-
for (const key of Object.keys(series)) if (key !== "name" && key !== "values") return `${at}.${key}: unknown property ${quoted(key)}; allowed: name, values`;
|
|
833
|
-
if (series.name !== void 0 && typeof series.name !== "string") return `${at}.name: expected a string`;
|
|
834
|
-
if (!Array.isArray(series.values)) return `${at}.values: expected an array of numbers`;
|
|
835
|
-
if (series.values.length !== node.categories.length) return `${at}.values: ${series.values.length} values for ${node.categories.length} categories`;
|
|
836
|
-
for (let item = 0; item < series.values.length; item += 1) {
|
|
837
|
-
const value = series.values[item];
|
|
838
|
-
if (isFormula(value)) ctx.formulas.push({
|
|
839
|
-
source: value["="],
|
|
840
|
-
path: `${at}.values[${item}]`,
|
|
841
|
-
kind: "number"
|
|
842
|
-
});
|
|
843
|
-
else if (!isNumber(value)) return `${at}.values[${item}]: expected a number`;
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
if (node.unit !== void 0 && typeof node.unit !== "string") return `${path}.unit: expected a string`;
|
|
847
|
-
if (node.caption !== void 0 && typeof node.caption !== "string") return `${path}.caption: expected a string`;
|
|
848
|
-
}
|
|
849
|
-
function checkProgress(node, path, ctx) {
|
|
850
|
-
if (node.label !== void 0 && typeof node.label !== "string") return `${path}.label: expected a string`;
|
|
851
|
-
if (node.percent === void 0 === (node.value === void 0)) return `${path}: give percent (0 to 100), or value with max`;
|
|
852
|
-
if (node.percent !== void 0 && !isFormula(node.percent)) {
|
|
853
|
-
if (typeof node.percent !== "number" || !Number.isFinite(node.percent) || node.percent < 0 || node.percent > 100) return `${path}.percent: expected a number from 0 to 100`;
|
|
854
|
-
}
|
|
855
|
-
if (node.value !== void 0 && node.max === void 0) return `${path}.max: expected a number with value`;
|
|
856
|
-
if (node.value === void 0 && node.max !== void 0) return `${path}.value: expected a number with max`;
|
|
857
|
-
return checkValue(node, "percent", path, ctx, "number") ?? checkValue(node, "value", path, ctx, "number") ?? checkValue(node, "max", path, ctx, "number") ?? checkValue(node, "valueLabel", path, ctx, "scalar") ?? checkValue(node, "tone", path, ctx, TONES);
|
|
858
|
-
}
|
|
859
|
-
function checkCallout(node, path, ctx) {
|
|
860
|
-
return checkValue(node, "text", path, ctx, "string", { required: true }) ?? checkValue(node, "tone", path, ctx, CALLOUT_TONES);
|
|
861
|
-
}
|
|
862
|
-
function checkSources(node, path) {
|
|
863
|
-
if (!Array.isArray(node.items) || node.items.length === 0) return `${path}.items: expected a non-empty array of { label, url }`;
|
|
864
|
-
for (let index = 0; index < node.items.length; index += 1) {
|
|
865
|
-
const item = node.items[index];
|
|
866
|
-
const at = `${path}.items[${index}]`;
|
|
867
|
-
if (!isRecord(item)) return `${at}: expected a { label, url } object`;
|
|
868
|
-
for (const key of Object.keys(item)) if (key !== "label" && key !== "url") return `${at}.${key}: unknown property ${quoted(key)}; allowed: label, url`;
|
|
869
|
-
if (typeof item.label !== "string" || item.label.trim() === "") return `${at}.label: expected a non-empty string`;
|
|
870
|
-
if (typeof item.url !== "string" || !/^https?:\/\/[^\s]+$/i.test(item.url)) return `${at}.url: expected an http(s) address`;
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
function checkInputId(node, path, ctx) {
|
|
874
|
-
if (!isName(node.id) || RESERVED.has(node.id)) return `${path}.id: an input id is letters, digits and _, and starts with a letter or _, so formulas can use it`;
|
|
875
|
-
if (ctx.ids.has(node.id)) return `${path}.id: duplicate id ${quoted(node.id)}`;
|
|
876
|
-
ctx.ids.add(node.id);
|
|
877
|
-
if (typeof node.label !== "string") return `${path}.label: expected a string`;
|
|
878
|
-
}
|
|
879
|
-
function checkNumberInput(node, path, ctx) {
|
|
880
|
-
const idError = checkInputId(node, path, ctx);
|
|
881
|
-
if (idError !== void 0) return idError;
|
|
882
|
-
for (const key of [
|
|
883
|
-
"min",
|
|
884
|
-
"max",
|
|
885
|
-
"step"
|
|
886
|
-
]) if (node[key] !== void 0 && !isNumber(node[key])) return `${path}.${key}: expected a number`;
|
|
887
|
-
if (node.type === "slider" && (node.min === void 0 || node.max === void 0)) return `${path}: a slider needs min and max`;
|
|
888
|
-
if (node.min !== void 0 && node.max !== void 0 && node.max <= node.min) return `${path}.max: expected more than min (${node.min})`;
|
|
889
|
-
if (node.step !== void 0 && node.step <= 0) return `${path}.step: expected more than 0`;
|
|
890
|
-
if (!isNumber(node.value)) return `${path}.value: expected a number`;
|
|
891
|
-
if (node.min !== void 0 && node.value < node.min || node.max !== void 0 && node.value > node.max) return `${path}.value: expected a number from ${node.min ?? "-∞"} to ${node.max ?? "∞"}`;
|
|
892
|
-
if (node.unit !== void 0 && typeof node.unit !== "string") return `${path}.unit: expected a string`;
|
|
893
|
-
ctx.inputs.set(node.id, node.value);
|
|
894
|
-
}
|
|
895
|
-
function checkChoice(node, path, ctx) {
|
|
896
|
-
const idError = checkInputId(node, path, ctx);
|
|
897
|
-
if (idError !== void 0) return idError;
|
|
898
|
-
if (!Array.isArray(node.options) || node.options.length === 0) return `${path}.options: expected a non-empty array of { label, value }`;
|
|
899
|
-
const labels = /* @__PURE__ */ new Set();
|
|
900
|
-
const values = /* @__PURE__ */ new Set();
|
|
901
|
-
for (let index = 0; index < node.options.length; index += 1) {
|
|
902
|
-
const option = node.options[index];
|
|
903
|
-
const at = `${path}.options[${index}]`;
|
|
904
|
-
if (!isRecord(option)) return `${at}: expected a { label, value } object`;
|
|
905
|
-
for (const key of Object.keys(option)) if (key !== "label" && key !== "value") return `${at}.${key}: unknown property ${quoted(key)}; allowed: label, value`;
|
|
906
|
-
if (typeof option.label !== "string" || option.label.trim() === "") return `${at}.label: expected a non-empty string`;
|
|
907
|
-
if (labels.has(option.label)) return `${at}.label: duplicate label ${quoted(option.label)}`;
|
|
908
|
-
labels.add(option.label);
|
|
909
|
-
const value = option.value ?? option.label;
|
|
910
|
-
if (!isScalar(value)) return `${at}.value: expected a string, a number or true/false`;
|
|
911
|
-
if (values.has(value)) return `${at}.value: duplicate value ${quoted(value)}`;
|
|
912
|
-
values.add(value);
|
|
913
|
-
}
|
|
914
|
-
const chosen = node.value ?? choiceValue(node.options[0]);
|
|
915
|
-
if (!values.has(chosen)) return `${path}.value: expected one of the options' values`;
|
|
916
|
-
ctx.inputs.set(node.id, chosen);
|
|
917
|
-
}
|
|
918
|
-
function checkToggle(node, path, ctx) {
|
|
919
|
-
const idError = checkInputId(node, path, ctx);
|
|
920
|
-
if (idError !== void 0) return idError;
|
|
921
|
-
if (node.value !== void 0 && typeof node.value !== "boolean") return `${path}.value: expected true or false`;
|
|
922
|
-
ctx.inputs.set(node.id, node.value === true);
|
|
923
|
-
}
|
|
924
|
-
function checkOptions(node, path, ctx) {
|
|
925
|
-
const idError = checkId(node, path, ctx);
|
|
926
|
-
if (idError !== void 0) return idError;
|
|
927
|
-
if (node.multi !== void 0 && typeof node.multi !== "boolean") return `${path}.multi: expected a boolean`;
|
|
928
|
-
if (!Array.isArray(node.options)) return `${path}.options: expected an array of 1 to ${MAX_OPTIONS} options`;
|
|
929
|
-
if (node.options.length < 1 || node.options.length > MAX_OPTIONS) return `${path}.options: expected 1 to ${MAX_OPTIONS} options; got ${node.options.length}`;
|
|
930
|
-
const labels = /* @__PURE__ */ new Set();
|
|
931
|
-
for (let index = 0; index < node.options.length; index += 1) {
|
|
932
|
-
const option = node.options[index];
|
|
933
|
-
const at = `${path}.options[${index}]`;
|
|
934
|
-
if (!isRecord(option)) return `${at}: expected an option object`;
|
|
935
|
-
for (const key of Object.keys(option)) if (key !== "label" && key !== "description") return `${at}.${key}: unknown property ${quoted(key)}; allowed: label, description`;
|
|
936
|
-
if (typeof option.label !== "string" || option.label.trim() === "") return `${at}.label: expected a non-empty string`;
|
|
937
|
-
if (labels.has(option.label)) return `${at}.label: duplicate label ${quoted(option.label)}`;
|
|
938
|
-
labels.add(option.label);
|
|
939
|
-
if (option.description !== void 0 && typeof option.description !== "string") return `${at}.description: expected a string`;
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
function checkConfirm(node, path, ctx) {
|
|
943
|
-
const idError = checkId(node, path, ctx);
|
|
944
|
-
if (idError !== void 0) return idError;
|
|
945
|
-
if (node.confirmLabel !== void 0 && typeof node.confirmLabel !== "string") return `${path}.confirmLabel: expected a string`;
|
|
946
|
-
if (node.cancelLabel !== void 0 && typeof node.cancelLabel !== "string") return `${path}.cancelLabel: expected a string`;
|
|
947
|
-
}
|
|
948
|
-
function checkForm(node, path, ctx) {
|
|
949
|
-
const idError = checkId(node, path, ctx);
|
|
950
|
-
if (idError !== void 0) return idError;
|
|
951
|
-
if (node.submitLabel !== void 0 && typeof node.submitLabel !== "string") return `${path}.submitLabel: expected a string`;
|
|
952
|
-
if (!Array.isArray(node.fields)) return `${path}.fields: expected an array of at most ${MAX_FIELDS} fields`;
|
|
953
|
-
if (node.fields.length > MAX_FIELDS) return `${path}.fields: ${node.fields.length} fields exceeds the limit of ${MAX_FIELDS}`;
|
|
954
|
-
const seen = /* @__PURE__ */ new Set();
|
|
955
|
-
for (let index = 0; index < node.fields.length; index += 1) {
|
|
956
|
-
const field = node.fields[index];
|
|
957
|
-
const at = `${path}.fields[${index}]`;
|
|
958
|
-
if (!isRecord(field)) return `${at}: expected a field object`;
|
|
959
|
-
for (const key of Object.keys(field)) if (key !== "id" && key !== "label" && key !== "placeholder") return `${at}.${key}: unknown property ${quoted(key)}; allowed: id, label, placeholder`;
|
|
960
|
-
if (typeof field.id !== "string" || field.id === "") return `${at}.id: expected a non-empty string`;
|
|
961
|
-
if (field.id === "__proto__") return `${at}.id: "__proto__" cannot be an id`;
|
|
962
|
-
if (seen.has(field.id)) return `${at}.id: duplicate field id ${quoted(field.id)}`;
|
|
963
|
-
seen.add(field.id);
|
|
964
|
-
if (typeof field.label !== "string") return `${at}.label: expected a string`;
|
|
965
|
-
if (field.placeholder !== void 0 && typeof field.placeholder !== "string") return `${at}.placeholder: expected a string`;
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
function checkSubmit(node, path, ctx) {
|
|
969
|
-
const idError = checkId(node, path, ctx);
|
|
970
|
-
if (idError !== void 0) return idError;
|
|
971
|
-
if (node.label !== void 0 && typeof node.label !== "string") return `${path}.label: expected a string`;
|
|
972
|
-
}
|
|
973
|
-
function checkId(node, path, ctx) {
|
|
974
|
-
if (typeof node.id !== "string" || node.id === "") return `${path}.id: expected a non-empty string`;
|
|
975
|
-
if (node.id === "__proto__") return `${path}.id: "__proto__" cannot be an id`;
|
|
976
|
-
if (ctx.ids.has(node.id)) return `${path}.id: duplicate id ${quoted(node.id)}`;
|
|
977
|
-
ctx.ids.add(node.id);
|
|
978
|
-
}
|
|
979
|
-
function checkFormulas(ctx) {
|
|
980
|
-
const known = /* @__PURE__ */ new Set([...ctx.inputs.keys(), ...ctx.lets.keys()]);
|
|
981
|
-
const lets = /* @__PURE__ */ new Map();
|
|
982
|
-
for (const [name, entry] of ctx.lets) {
|
|
983
|
-
if (ctx.inputs.has(name)) return `${entry.path}: ${quoted(name)} is already an input id`;
|
|
984
|
-
const compiled = compileFormula(entry.source);
|
|
985
|
-
if (compiled.error !== void 0) return `${entry.path}: ${compiled.error}`;
|
|
986
|
-
const unknown = compiled.names.find((item) => !known.has(item));
|
|
987
|
-
if (unknown !== void 0) return `${entry.path}: unknown name ${quoted(unknown)}; known: ${allowed([...known]) || "(none)"}`;
|
|
988
|
-
lets.set(name, compiled.tree);
|
|
989
|
-
}
|
|
990
|
-
const lookup = createScope(ctx.inputs, lets);
|
|
991
|
-
for (const [name, entry] of ctx.lets) {
|
|
992
|
-
const result = evaluate(lets.get(name), lookup);
|
|
993
|
-
if (result.error !== void 0) return `${entry.path}: ${result.error}`;
|
|
994
|
-
}
|
|
995
|
-
for (const formula of ctx.formulas) {
|
|
996
|
-
const compiled = compileFormula(formula.source);
|
|
997
|
-
if (compiled.error !== void 0) return `${formula.path}: ${compiled.error}`;
|
|
998
|
-
const unknown = compiled.names.find((item) => !known.has(item));
|
|
999
|
-
if (unknown !== void 0) return `${formula.path}: unknown name ${quoted(unknown)}; known: ${allowed([...known]) || "(none)"}`;
|
|
1000
|
-
const result = evaluate(compiled.tree, lookup);
|
|
1001
|
-
if (result.error !== void 0) return `${formula.path}: ${result.error}`;
|
|
1002
|
-
const problem = kindProblem(result.value, formula.kind);
|
|
1003
|
-
if (problem !== void 0) return `${formula.path}: the formula gives ${quoted(result.value)}; ${problem}`;
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
function kindProblem(value, kind) {
|
|
1007
|
-
if (kind === "number") return isNumber(value) ? void 0 : "expected a number";
|
|
1008
|
-
if (Array.isArray(kind)) return kind.includes(value) ? void 0 : `expected one of ${allowed(kind)}`;
|
|
1009
|
-
if (kind === "string") return isScalar(value) ? void 0 : "expected text";
|
|
1010
|
-
return isScalar(value) ? void 0 : "expected a string or a number";
|
|
1011
|
-
}
|
|
1012
|
-
function cellText(value) {
|
|
1013
|
-
if (typeof value === "string") return value;
|
|
1014
|
-
const text = JSON.stringify(value);
|
|
1015
|
-
return typeof text === "string" ? text : "";
|
|
1016
|
-
}
|
|
1017
|
-
const compiled = /* @__PURE__ */ new WeakMap();
|
|
1018
|
-
const treeOf = (formula) => {
|
|
1019
|
-
let entry = compiled.get(formula);
|
|
1020
|
-
if (entry === void 0) {
|
|
1021
|
-
entry = compileFormula(formula["="]);
|
|
1022
|
-
compiled.set(formula, entry);
|
|
1023
|
-
}
|
|
1024
|
-
return entry;
|
|
1025
|
-
};
|
|
1026
|
-
const choiceValue = (option) => option?.value ?? option?.label;
|
|
1027
|
-
function walk(node, visit) {
|
|
1028
|
-
if (!isRecord(node)) return;
|
|
1029
|
-
visit(node);
|
|
1030
|
-
if (LAYOUT.includes(node.type) && Array.isArray(node.children)) for (const child of node.children) walk(child, visit);
|
|
1031
|
-
}
|
|
1032
|
-
function viewInputs(view) {
|
|
1033
|
-
const found = [];
|
|
1034
|
-
walk(view, (node) => {
|
|
1035
|
-
if (INPUTS.has(node.type)) found.push(node);
|
|
1036
|
-
});
|
|
1037
|
-
return found;
|
|
1038
|
-
}
|
|
1039
|
-
function inputDefault(node) {
|
|
1040
|
-
if (node.type === "toggle") return node.value === true;
|
|
1041
|
-
if (node.type === "choice") return node.value ?? choiceValue(Array.isArray(node.options) ? node.options[0] : void 0);
|
|
1042
|
-
return node.value;
|
|
1043
|
-
}
|
|
1044
|
-
function viewScope(view, values = {}) {
|
|
1045
|
-
const inputs = /* @__PURE__ */ new Map();
|
|
1046
|
-
for (const node of viewInputs(view)) inputs.set(node.id, Object.hasOwn(values, node.id) ? values[node.id] : inputDefault(node));
|
|
1047
|
-
const lets = /* @__PURE__ */ new Map();
|
|
1048
|
-
for (const [name, source] of Object.entries(isRecord(view?.let) ? view.let : {})) {
|
|
1049
|
-
const entry = compileFormula(source);
|
|
1050
|
-
if (entry.tree !== void 0) lets.set(name, entry.tree);
|
|
1051
|
-
}
|
|
1052
|
-
return createScope(inputs, lets);
|
|
1053
|
-
}
|
|
1054
|
-
function resolve(value, lookup, fallback = void 0) {
|
|
1055
|
-
if (!isFormula(value)) return value;
|
|
1056
|
-
const entry = treeOf(value);
|
|
1057
|
-
if (entry.tree === void 0) return fallback;
|
|
1058
|
-
const result = evaluate(entry.tree, lookup);
|
|
1059
|
-
return result.error === void 0 ? result.value : fallback;
|
|
1060
|
-
}
|
|
1061
|
-
const asText = (value) => value === void 0 || value === null ? "" : show(value);
|
|
1062
|
-
function clip(text) {
|
|
1063
|
-
const flat = String(text).replace(/\s+/g, " ").trim();
|
|
1064
|
-
return flat.length <= SUMMARY_PART ? flat : flat.slice(0, SUMMARY_PART);
|
|
1065
|
-
}
|
|
1066
|
-
const choiceLabel = (node, value) => (Array.isArray(node.options) ? node.options : []).find((option) => choiceValue(option) === value)?.label ?? asText(value);
|
|
1067
|
-
function inputText(node, value) {
|
|
1068
|
-
if (node.type === "toggle") return value === true ? "on" : "off";
|
|
1069
|
-
if (node.type === "choice") return choiceLabel(node, value);
|
|
1070
|
-
return `${asText(value)}${node.unit ? ` ${node.unit}` : ""}`;
|
|
1071
|
-
}
|
|
1072
|
-
function progressPercent(node, lookup) {
|
|
1073
|
-
if (node.percent !== void 0) return Number(resolve(node.percent, lookup, 0));
|
|
1074
|
-
const max = Number(resolve(node.max, lookup, 0));
|
|
1075
|
-
return max > 0 ? Number(resolve(node.value, lookup, 0)) / max * 100 : 0;
|
|
1076
|
-
}
|
|
1077
|
-
function componentSummary(node, lookup) {
|
|
1078
|
-
const value = (item) => asText(resolve(item, lookup, ""));
|
|
1079
|
-
switch (node.type) {
|
|
1080
|
-
case "text": return `text: ${value(node.text)}`;
|
|
1081
|
-
case "metric": return `metric: ${node.label}=${value(node.value)}`;
|
|
1082
|
-
case "table": return `table: ${(Array.isArray(node.columns) ? node.columns : []).map((column) => column?.label ?? column?.key ?? "").filter(Boolean).join("/")}`;
|
|
1083
|
-
case "chart": {
|
|
1084
|
-
const series = Array.isArray(node.series) ? node.series[0] : void 0;
|
|
1085
|
-
const values = Array.isArray(series?.values) ? series.values : [];
|
|
1086
|
-
const unit = node.unit ? ` ${node.unit}` : "";
|
|
1087
|
-
return `chart: ${(Array.isArray(node.categories) ? node.categories : []).map((label, index) => `${label}=${value(values[index])}${unit}`).join(", ")}`;
|
|
1088
|
-
}
|
|
1089
|
-
case "progress": return `progress: ${node.label ? `${node.label} ` : ""}${Math.round(progressPercent(node, lookup))}%`;
|
|
1090
|
-
case "callout": return `callout: ${value(node.text)}`;
|
|
1091
|
-
case "sources": return `sources: ${(Array.isArray(node.items) ? node.items : []).map((item) => item?.label).filter(Boolean).join("/")}`;
|
|
1092
|
-
case "slider":
|
|
1093
|
-
case "number":
|
|
1094
|
-
case "choice":
|
|
1095
|
-
case "toggle": return `${node.type}: ${node.label}=${inputText(node, inputDefault(node))}`;
|
|
1096
|
-
case "options": return `options: ${(Array.isArray(node.options) ? node.options : []).map((option) => option?.label).filter(Boolean).join("/")}`;
|
|
1097
|
-
case "confirm": return "confirm";
|
|
1098
|
-
case "form": return `form: ${(Array.isArray(node.fields) ? node.fields : []).map((field) => field?.label ?? field?.id).filter(Boolean).join("/")}`;
|
|
1099
|
-
case "submit": return `submit: ${node.label ?? "Send"}`;
|
|
1100
|
-
default: return;
|
|
1101
|
-
}
|
|
1102
|
-
}
|
|
1103
|
-
function viewSummary(view) {
|
|
1104
|
-
const parts = [];
|
|
1105
|
-
const lookup = viewScope(view);
|
|
1106
|
-
const visit = (node) => {
|
|
1107
|
-
if (parts.length >= SUMMARY_COUNT || !isRecord(node)) return;
|
|
1108
|
-
const line = componentSummary(node, lookup);
|
|
1109
|
-
if (line !== void 0) parts.push(clip(line));
|
|
1110
|
-
if (parts.length >= SUMMARY_COUNT || !LAYOUT.includes(node.type) || !Array.isArray(node.children)) return;
|
|
1111
|
-
for (const child of node.children) visit(child);
|
|
1112
|
-
};
|
|
1113
|
-
visit(view);
|
|
1114
|
-
return parts.join("; ");
|
|
1115
|
-
}
|
|
1116
|
-
function inputsSummary(view, inputs) {
|
|
1117
|
-
if (!isRecord(inputs)) return "";
|
|
1118
|
-
return viewInputs(view).filter((node) => Object.hasOwn(inputs, node.id)).map((node) => `${node.label || node.id}=${inputText(node, inputs[node.id])}`).join("; ");
|
|
1119
|
-
}
|
|
1120
|
-
function interactiveNodes(node, found = []) {
|
|
1121
|
-
walk(node, (item) => {
|
|
1122
|
-
if (INTERACTIVE.has(item.type)) found.push(item);
|
|
1123
|
-
});
|
|
1124
|
-
return found;
|
|
1125
|
-
}
|
|
1126
|
-
function validateViewAnswer(view, action, values) {
|
|
1127
|
-
const nodes = interactiveNodes(view);
|
|
1128
|
-
if (typeof action !== "string" || action === "") return { error: `action: expected an interactive component id; allowed: ${allowed(nodes.map((node) => node.id))}` };
|
|
1129
|
-
const node = nodes.find((item) => item.id === action);
|
|
1130
|
-
if (node === void 0) return { error: `action: no interactive component with id ${quoted(action)}; allowed: ${allowed(nodes.map((item) => item.id))}` };
|
|
1131
|
-
if (!isRecord(values)) return { error: "values: expected an object" };
|
|
1132
|
-
const inputs = validateInputs(view, values.inputs);
|
|
1133
|
-
if (inputs.error !== void 0) return inputs;
|
|
1134
|
-
const extra = inputs.inputs === void 0 ? {} : { inputs: inputs.inputs };
|
|
1135
|
-
if (node.type === "submit") return { ...extra };
|
|
1136
|
-
const own = node.type === "options" ? validateOptions(node, values) : node.type === "confirm" ? validateConfirm(values) : validateForm(node, values);
|
|
1137
|
-
return own.error === void 0 ? {
|
|
1138
|
-
...own,
|
|
1139
|
-
...extra
|
|
1140
|
-
} : own;
|
|
1141
|
-
}
|
|
1142
|
-
function validateInputs(view, sent) {
|
|
1143
|
-
const nodes = viewInputs(view);
|
|
1144
|
-
if (nodes.length === 0) return sent === void 0 ? {} : { error: "values.inputs: this view has no inputs" };
|
|
1145
|
-
if (sent !== void 0 && !isRecord(sent)) return { error: "values.inputs: expected an object of input ids to values" };
|
|
1146
|
-
const given = sent ?? {};
|
|
1147
|
-
const ids = nodes.map((node) => node.id);
|
|
1148
|
-
const unknown = Object.keys(given).find((key) => !ids.includes(key));
|
|
1149
|
-
if (unknown !== void 0) return { error: `values.inputs.${unknown}: unknown input; allowed: ${allowed(ids)}` };
|
|
1150
|
-
const inputs = {};
|
|
1151
|
-
for (const node of nodes) {
|
|
1152
|
-
const value = Object.hasOwn(given, node.id) ? given[node.id] : inputDefault(node);
|
|
1153
|
-
const at = `values.inputs.${node.id}`;
|
|
1154
|
-
if (node.type === "toggle" && typeof value !== "boolean") return { error: `${at}: expected true or false` };
|
|
1155
|
-
if (node.type === "choice" && !(Array.isArray(node.options) ? node.options : []).some((option) => choiceValue(option) === value)) return { error: `${at}: expected one of the options' values` };
|
|
1156
|
-
if (node.type === "slider" || node.type === "number") {
|
|
1157
|
-
if (!isNumber(value)) return { error: `${at}: expected a number` };
|
|
1158
|
-
if (node.min !== void 0 && value < node.min || node.max !== void 0 && value > node.max) return { error: `${at}: expected a number from ${node.min ?? "-∞"} to ${node.max ?? "∞"}` };
|
|
1159
|
-
}
|
|
1160
|
-
inputs[node.id] = value;
|
|
1161
|
-
}
|
|
1162
|
-
return { inputs };
|
|
1163
|
-
}
|
|
1164
|
-
function validateOptions(node, values) {
|
|
1165
|
-
if (!Array.isArray(values.selected)) return { error: "values.selected: expected an array of option labels" };
|
|
1166
|
-
const labels = new Set((node.options ?? []).map((option) => option?.label));
|
|
1167
|
-
const picked = values.selected.filter((label) => typeof label === "string" && labels.has(label));
|
|
1168
|
-
const selected = node.multi === true ? [...new Set(picked)] : picked.slice(0, 1);
|
|
1169
|
-
if (selected.length === 0) return { error: "values.selected: select at least one option" };
|
|
1170
|
-
return { selected };
|
|
1171
|
-
}
|
|
1172
|
-
function validateConfirm(values) {
|
|
1173
|
-
if (typeof values.confirmed !== "boolean") return { error: "values.confirmed: expected a boolean" };
|
|
1174
|
-
return { confirmed: values.confirmed };
|
|
1175
|
-
}
|
|
1176
|
-
function validateForm(node, values) {
|
|
1177
|
-
if (!isRecord(values.fields)) return { error: "values.fields: expected an object of field ids to strings" };
|
|
1178
|
-
const declared = (node.fields ?? []).map((field) => field?.id).filter((id) => typeof id === "string");
|
|
1179
|
-
const allowedIds = new Set(declared);
|
|
1180
|
-
const fields = {};
|
|
1181
|
-
for (const [key, value] of Object.entries(values.fields)) {
|
|
1182
|
-
if (!allowedIds.has(key)) return { error: `values.fields.${key}: unknown field; allowed: ${allowed(declared)}` };
|
|
1183
|
-
if (typeof value !== "string") return { error: `values.fields.${key}: expected a string` };
|
|
1184
|
-
if (value.length > MAX_TEXT) return { error: `values.fields.${key}: ${value.length} characters exceeds the limit of ${MAX_TEXT}` };
|
|
1185
|
-
fields[key] = value;
|
|
1186
|
-
}
|
|
1187
|
-
return { fields };
|
|
1188
|
-
}
|
|
1189
|
-
//#endregion
|
|
1190
|
-
//#region src/answer-text.ts
|
|
1191
|
-
function controls(view) {
|
|
1192
|
-
return [
|
|
1193
|
-
"options",
|
|
1194
|
-
"confirm",
|
|
1195
|
-
"form",
|
|
1196
|
-
"submit"
|
|
1197
|
-
].includes(view.type) ? [view] : (view.children ?? []).flatMap(controls);
|
|
1198
|
-
}
|
|
1199
|
-
function answerText(card, action, values) {
|
|
1200
|
-
const node = controls(card.view).find((node) => node.id === action);
|
|
1201
|
-
const lines = [card.title || "Deep View"];
|
|
1202
|
-
if (Array.isArray(values.selected)) lines.push(values.selected.map(String).join(", "));
|
|
1203
|
-
if (typeof values.confirmed === "boolean") lines.push(values.confirmed ? node?.confirmLabel ?? "Confirm" : node?.cancelLabel ?? "Cancel");
|
|
1204
|
-
if (isRecord$1(values.fields)) for (const field of node?.fields ?? []) lines.push(`${field.label}: ${String(values.fields[field.id] ?? "")}`);
|
|
1205
|
-
const inputs = inputsSummary(card.view, values.inputs);
|
|
1206
|
-
if (inputs) lines.push(inputs);
|
|
1207
|
-
if (node?.type === "submit") lines.push(node.label ?? "Submit");
|
|
1208
|
-
return lines.join("\n");
|
|
1209
|
-
}
|
|
1210
|
-
//#endregion
|
|
1211
836
|
//#region src/index.ts
|
|
1212
837
|
const name = "deep-view";
|
|
1213
838
|
const inject = [
|
|
@@ -1218,6 +843,40 @@ const inject = [
|
|
|
1218
843
|
"connection",
|
|
1219
844
|
"webServer"
|
|
1220
845
|
];
|
|
846
|
+
const MAX_REDIRECTS = 256;
|
|
847
|
+
function resolveSessionId(redirects, sessionId) {
|
|
848
|
+
let current = sessionId;
|
|
849
|
+
const seen = /* @__PURE__ */ new Set();
|
|
850
|
+
while (!seen.has(current)) {
|
|
851
|
+
seen.add(current);
|
|
852
|
+
const next = redirects.get(current);
|
|
853
|
+
if (!next) break;
|
|
854
|
+
current = next;
|
|
855
|
+
}
|
|
856
|
+
return current;
|
|
857
|
+
}
|
|
858
|
+
function handoffPendingSession(pending, redirects, fromSessionId, toSessionId) {
|
|
859
|
+
if (!short(fromSessionId, 200) || !short(toSessionId, 200) || fromSessionId === toSessionId) return false;
|
|
860
|
+
const source = resolveSessionId(redirects, fromSessionId);
|
|
861
|
+
const target = resolveSessionId(redirects, toSessionId);
|
|
862
|
+
if (source === target) return false;
|
|
863
|
+
redirects.set(source, target);
|
|
864
|
+
if (fromSessionId !== source) redirects.set(fromSessionId, target);
|
|
865
|
+
while (redirects.size > MAX_REDIRECTS) {
|
|
866
|
+
const oldest = redirects.keys().next().value;
|
|
867
|
+
if (oldest === void 0) break;
|
|
868
|
+
redirects.delete(oldest);
|
|
869
|
+
}
|
|
870
|
+
const record = pending.get(source);
|
|
871
|
+
if (!record) return false;
|
|
872
|
+
pending.delete(source);
|
|
873
|
+
if (pending.has(target)) return false;
|
|
874
|
+
pending.set(target, {
|
|
875
|
+
...record,
|
|
876
|
+
sessionId: target
|
|
877
|
+
});
|
|
878
|
+
return true;
|
|
879
|
+
}
|
|
1221
880
|
const isLiveRoot = (host, agent) => agent !== void 0 && host.agents.roots().some((root) => root.id === agent.id);
|
|
1222
881
|
const ok = (value) => ({
|
|
1223
882
|
ok: true,
|
|
@@ -1234,9 +893,9 @@ const short = (value, max = 160) => typeof value === "string" && value.length >
|
|
|
1234
893
|
const checkedShown = (title, view) => checkShownView(title, view);
|
|
1235
894
|
const isShown = (value) => !("error" in value);
|
|
1236
895
|
function presentation(args, value) {
|
|
1237
|
-
if (!isRecord
|
|
1238
|
-
const shown = checkedShown(isRecord
|
|
1239
|
-
if (!isShown(shown)) return
|
|
896
|
+
if (!isRecord(value) || value.owner !== "dsh-deep-view" || !["shown", "pending"].includes(String(value.status))) return null;
|
|
897
|
+
const shown = checkedShown(isRecord(args) ? args.title : void 0, isRecord(args) ? args.view : void 0);
|
|
898
|
+
if (!isShown(shown)) return null;
|
|
1240
899
|
return {
|
|
1241
900
|
owner: VIEW_OWNER,
|
|
1242
901
|
...short(value.viewId, 80) ? { viewId: value.viewId } : {},
|
|
@@ -1244,7 +903,7 @@ function presentation(args, value) {
|
|
|
1244
903
|
awaitReply: value.awaitReply === true
|
|
1245
904
|
};
|
|
1246
905
|
}
|
|
1247
|
-
function createShowViewTool(host, pending, makeId = randomUUID) {
|
|
906
|
+
function createShowViewTool(host, pending, makeId = randomUUID, logger) {
|
|
1248
907
|
return {
|
|
1249
908
|
name: "show_view",
|
|
1250
909
|
description: [
|
|
@@ -1266,7 +925,7 @@ function createShowViewTool(host, pending, makeId = randomUUID) {
|
|
|
1266
925
|
"One ViewNode, nodes nest under stack|row(children,let?).",
|
|
1267
926
|
"Display: text,metric{label,value},table{columns:[{key,label}],rows:[[cell]]},chart{kind:bar|hbar|line|area|donut,categories,series:[{name?,values}]},progress{percent|value+max},callout{text},sources{items:[{label,url}]}.",
|
|
1268
927
|
"Inputs: slider,number,choice,toggle{id,label,value}. A {\"=\":\"expr\"} formula replaces a value or cell, reading input ids and let names.",
|
|
1269
|
-
"Answers(need await_reply true): options{id,options,multi?},confirm{id},form{id,fields},submit{id,label}. No html."
|
|
928
|
+
"Answers(need await_reply true): options{id,options:[{label,description?}],multi?},confirm{id},form{id,fields:[{id,label,placeholder?}]},submit{id,label}. No html."
|
|
1270
929
|
].join(" ")
|
|
1271
930
|
},
|
|
1272
931
|
await_reply: {
|
|
@@ -1304,14 +963,20 @@ function createShowViewTool(host, pending, makeId = randomUUID) {
|
|
|
1304
963
|
presentationMeta: presentation
|
|
1305
964
|
},
|
|
1306
965
|
async execute(args, exec) {
|
|
1307
|
-
const input = isRecord
|
|
966
|
+
const input = isRecord(args) ? args : {};
|
|
1308
967
|
const shown = checkedShown(input.title, input.view);
|
|
1309
|
-
if (!isShown(shown))
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
968
|
+
if (!isShown(shown)) {
|
|
969
|
+
logger?.debug("view.invalid", "Rejected invalid show_view arguments.", {
|
|
970
|
+
sessionId: exec.agent?.id ?? null,
|
|
971
|
+
reason: shown.error
|
|
972
|
+
});
|
|
973
|
+
return {
|
|
974
|
+
owner: VIEW_OWNER,
|
|
975
|
+
status: "invalid",
|
|
976
|
+
awaitReply: false,
|
|
977
|
+
summary: shown.error
|
|
978
|
+
};
|
|
979
|
+
}
|
|
1315
980
|
if (typeof input.await_reply !== "undefined" && typeof input.await_reply !== "boolean") return {
|
|
1316
981
|
owner: VIEW_OWNER,
|
|
1317
982
|
status: "invalid",
|
|
@@ -1327,12 +992,15 @@ function createShowViewTool(host, pending, makeId = randomUUID) {
|
|
|
1327
992
|
};
|
|
1328
993
|
const id = exec.agent?.id;
|
|
1329
994
|
const agent = id === void 0 ? void 0 : host.agents.get(id);
|
|
1330
|
-
if (awaitReply && !isLiveRoot(host, agent))
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
995
|
+
if (awaitReply && !isLiveRoot(host, agent)) {
|
|
996
|
+
logger?.debug("view.non-root-rejected", "Rejected interactive show_view outside a live root session.", { sessionId: id ?? null });
|
|
997
|
+
return {
|
|
998
|
+
owner: VIEW_OWNER,
|
|
999
|
+
status: "invalid",
|
|
1000
|
+
awaitReply: false,
|
|
1001
|
+
summary: "An interactive view only works in a live root session. Put the question or decision in your response instead."
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1336
1004
|
let viewId;
|
|
1337
1005
|
if (awaitReply && agent) {
|
|
1338
1006
|
viewId = makeId();
|
|
@@ -1343,6 +1011,11 @@ function createShowViewTool(host, pending, makeId = randomUUID) {
|
|
|
1343
1011
|
...shown
|
|
1344
1012
|
});
|
|
1345
1013
|
}
|
|
1014
|
+
logger?.info("view.prepared", "Prepared Deep View card.", {
|
|
1015
|
+
sessionId: agent?.id ?? id ?? null,
|
|
1016
|
+
viewId: viewId ?? null,
|
|
1017
|
+
awaitReply
|
|
1018
|
+
});
|
|
1346
1019
|
return {
|
|
1347
1020
|
owner: VIEW_OWNER,
|
|
1348
1021
|
status: awaitReply ? "pending" : "shown",
|
|
@@ -1354,30 +1027,48 @@ function createShowViewTool(host, pending, makeId = randomUUID) {
|
|
|
1354
1027
|
isConcurrencySafe: () => false
|
|
1355
1028
|
};
|
|
1356
1029
|
}
|
|
1357
|
-
function createRpc(host, pending) {
|
|
1030
|
+
function createRpc(host, pending, redirects = /* @__PURE__ */ new Map(), logger) {
|
|
1358
1031
|
const claims = /* @__PURE__ */ new Set();
|
|
1359
1032
|
return async (endpoint, payload, signal) => {
|
|
1360
|
-
if (!isRecord
|
|
1361
|
-
const sessionId = payload.sessionId;
|
|
1033
|
+
if (!isRecord(payload) || !short(payload.sessionId, 200)) return fail("bad-request", "A bounded sessionId is required");
|
|
1034
|
+
const sessionId = resolveSessionId(redirects, payload.sessionId);
|
|
1362
1035
|
const record = pending.get(sessionId);
|
|
1363
1036
|
if (endpoint === "pending") return ok(record?.callId === payload.callId || payload.callId === void 0 ? record ?? null : null);
|
|
1364
1037
|
if (endpoint === "dismiss") {
|
|
1365
|
-
if (record
|
|
1038
|
+
if (record && record.id === payload.viewId) {
|
|
1039
|
+
pending.delete(sessionId);
|
|
1040
|
+
logger?.debug("view.dismissed", "Dismissed pending Deep View card.", {
|
|
1041
|
+
sessionId,
|
|
1042
|
+
viewId: record.id
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1366
1045
|
return ok(true);
|
|
1367
1046
|
}
|
|
1368
1047
|
if (endpoint !== "reply") return fail("not-found", "Unknown Deep View endpoint");
|
|
1369
|
-
if (!record || !short(payload.viewId, 80) || payload.viewId !== record.id || !short(payload.action, 160) || !isRecord
|
|
1048
|
+
if (!record || !short(payload.viewId, 80) || payload.viewId !== record.id || !short(payload.action, 160) || !isRecord(payload.values)) {
|
|
1049
|
+
logger?.debug("reply.stale", "Rejected reply for stale or closed Deep View card.", { sessionId });
|
|
1050
|
+
return fail("stale", "This view is no longer open");
|
|
1051
|
+
}
|
|
1370
1052
|
if (claims.has(record.id)) return fail("busy", "This view is already being answered");
|
|
1371
1053
|
const checked = validateViewAnswer(record.view, payload.action, payload.values);
|
|
1372
|
-
if (checked.error !== void 0)
|
|
1054
|
+
if (checked.error !== void 0) {
|
|
1055
|
+
logger?.debug("reply.invalid", "Rejected invalid Deep View reply.", {
|
|
1056
|
+
sessionId,
|
|
1057
|
+
viewId: record.id,
|
|
1058
|
+
action: payload.action,
|
|
1059
|
+
reason: checked.error
|
|
1060
|
+
});
|
|
1061
|
+
return fail("invalid", checked.error);
|
|
1062
|
+
}
|
|
1373
1063
|
claims.add(record.id);
|
|
1374
1064
|
pending.delete(sessionId);
|
|
1375
1065
|
let appended = false;
|
|
1376
1066
|
try {
|
|
1377
1067
|
if (signal.aborted) return fail("cancelled", "The request was cancelled");
|
|
1378
|
-
const
|
|
1068
|
+
const live = host.agents.get(sessionId);
|
|
1069
|
+
const resolved = isLiveRoot(host, live) ? { agent: live } : await host.sessionController.resolveAgent(sessionId);
|
|
1379
1070
|
const agent = resolved.agent;
|
|
1380
|
-
if (!agent || host.agents.get(agent.id) !== agent || !host.agents.roots().some((root) => root.id === agent.id)) throw new Error(resolved.error?.message ?? "The session is not a live root session");
|
|
1071
|
+
if (!agent || host.agents.get(agent.id) !== agent || !host.agents.roots().some((root) => root.id === agent.id)) throw new Error(("error" in resolved ? resolved.error?.message : void 0) ?? "The session is not a live root session");
|
|
1381
1072
|
if (signal.aborted) return fail("cancelled", "The request was cancelled");
|
|
1382
1073
|
const deepView = {
|
|
1383
1074
|
owner: VIEW_OWNER,
|
|
@@ -1400,8 +1091,19 @@ function createRpc(host, pending) {
|
|
|
1400
1091
|
}));
|
|
1401
1092
|
appended = true;
|
|
1402
1093
|
await host.sessions.flush(agent.session);
|
|
1094
|
+
logger?.info("reply.submitted", "Submitted Deep View reply to live session.", {
|
|
1095
|
+
sessionId: agent.id,
|
|
1096
|
+
viewId: record.id,
|
|
1097
|
+
action: payload.action
|
|
1098
|
+
});
|
|
1403
1099
|
return ok({ answered: true });
|
|
1404
1100
|
} catch (error) {
|
|
1101
|
+
logger?.error("reply.failed", "Failed to deliver Deep View reply.", error, {
|
|
1102
|
+
sessionId,
|
|
1103
|
+
viewId: record.id,
|
|
1104
|
+
action: payload.action,
|
|
1105
|
+
appended
|
|
1106
|
+
});
|
|
1405
1107
|
return fail("internal", error instanceof Error ? error.message : String(error));
|
|
1406
1108
|
} finally {
|
|
1407
1109
|
if (!appended && !pending.has(sessionId)) pending.set(sessionId, record);
|
|
@@ -1411,26 +1113,41 @@ function createRpc(host, pending) {
|
|
|
1411
1113
|
}
|
|
1412
1114
|
async function apply(ctx) {
|
|
1413
1115
|
const host = ctx;
|
|
1116
|
+
const logger = installPluginLogger(ctx, PLUGIN_NAME, { forwardHostWarnError: false });
|
|
1414
1117
|
const pending = /* @__PURE__ */ new Map();
|
|
1118
|
+
const redirects = /* @__PURE__ */ new Map();
|
|
1119
|
+
ctx.provide(HOST_SERVICE, { handoffSession: (fromSessionId, toSessionId) => {
|
|
1120
|
+
const moved = handoffPendingSession(pending, redirects, fromSessionId, toSessionId);
|
|
1121
|
+
if (moved) logger.debug("session.handoff", "Moved pending Deep View card across session handoff.", {
|
|
1122
|
+
fromSessionId,
|
|
1123
|
+
toSessionId
|
|
1124
|
+
});
|
|
1125
|
+
return moved;
|
|
1126
|
+
} });
|
|
1415
1127
|
ctx.effect(() => {
|
|
1416
1128
|
if (host.tools.get("show_view")) {
|
|
1129
|
+
logger.warn("tool.already-registered", "show_view is already registered by another plugin; skipping registration.");
|
|
1417
1130
|
host.logger?.warn("deep-view: show_view is already registered by another plugin; this bundle stays disabled for its own tool");
|
|
1418
1131
|
return () => {};
|
|
1419
1132
|
}
|
|
1420
1133
|
try {
|
|
1421
|
-
return host.tools.register(createShowViewTool(host, pending));
|
|
1134
|
+
return host.tools.register(createShowViewTool(host, pending, randomUUID, logger));
|
|
1422
1135
|
} catch (error) {
|
|
1136
|
+
logger.warn("tool.register-failed", "show_view tool registration failed.", error);
|
|
1423
1137
|
host.logger?.warn("deep-view: show_view registration skipped: %s", error instanceof Error ? error.message : String(error));
|
|
1424
1138
|
return () => {};
|
|
1425
1139
|
}
|
|
1426
1140
|
}, "deep-view.tool");
|
|
1427
1141
|
ctx.effect(() => ctx.on("session/event", (session, event) => {
|
|
1428
|
-
if (event.type !== "user/message" || !isRecord
|
|
1429
|
-
pending.delete(session.id);
|
|
1142
|
+
if (event.type !== "user/message" || !isRecord(event.data) || !isRecord(event.data.source) || event.data.source.kind !== "user") return;
|
|
1143
|
+
pending.delete(resolveSessionId(redirects, session.id));
|
|
1430
1144
|
}), "deep-view.close-pending");
|
|
1431
|
-
ctx.effect(() => registerHostRpc(host, RPC_CHANNEL, createRpc(host, pending
|
|
1145
|
+
ctx.effect(() => registerHostRpc(host, RPC_CHANNEL, createRpc(host, pending, redirects, logger), {
|
|
1146
|
+
maxBodyBytes: 24576,
|
|
1147
|
+
logger
|
|
1148
|
+
}), "deep-view.rpc");
|
|
1432
1149
|
}
|
|
1433
1150
|
//#endregion
|
|
1434
|
-
export { apply, createRpc, createShowViewTool, inject, name };
|
|
1151
|
+
export { apply, createRpc, createShowViewTool, handoffPendingSession, inject, name, resolveSessionId };
|
|
1435
1152
|
|
|
1436
1153
|
//# sourceMappingURL=index.js.map
|