@delexec/ops 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/README.zh-CN.md +6 -0
- package/node_modules/@delexec/caller-controller/README.md +3 -0
- package/node_modules/@delexec/caller-controller/README.zh-CN.md +6 -0
- package/node_modules/@delexec/caller-controller/package.json +53 -0
- package/node_modules/@delexec/caller-controller/src/server.js +127 -0
- package/node_modules/@delexec/caller-controller-core/README.md +3 -0
- package/node_modules/@delexec/caller-controller-core/README.zh-CN.md +6 -0
- package/node_modules/@delexec/caller-controller-core/package.json +26 -0
- package/node_modules/@delexec/caller-controller-core/src/index.js +1612 -0
- package/node_modules/@delexec/caller-skill-adapter/package.json +12 -0
- package/node_modules/@delexec/caller-skill-adapter/src/server.js +1042 -0
- package/node_modules/@delexec/caller-skill-mcp-adapter/README.md +65 -0
- package/node_modules/@delexec/caller-skill-mcp-adapter/package.json +16 -0
- package/node_modules/@delexec/caller-skill-mcp-adapter/src/server.js +527 -0
- package/node_modules/@delexec/responder-controller/README.md +3 -0
- package/node_modules/@delexec/responder-controller/README.zh-CN.md +6 -0
- package/node_modules/@delexec/responder-controller/package.json +53 -0
- package/node_modules/@delexec/responder-controller/src/server.js +254 -0
- package/node_modules/@delexec/responder-runtime-core/README.md +3 -0
- package/node_modules/@delexec/responder-runtime-core/README.zh-CN.md +6 -0
- package/node_modules/@delexec/responder-runtime-core/package.json +26 -0
- package/node_modules/@delexec/responder-runtime-core/src/executors.js +326 -0
- package/node_modules/@delexec/responder-runtime-core/src/index.js +1202 -0
- package/node_modules/@delexec/runtime-utils/README.md +3 -0
- package/node_modules/@delexec/runtime-utils/README.zh-CN.md +6 -0
- package/node_modules/@delexec/runtime-utils/package.json +23 -0
- package/node_modules/@delexec/runtime-utils/src/index.js +338 -0
- package/node_modules/@delexec/sqlite-store/README.md +3 -0
- package/node_modules/@delexec/sqlite-store/README.zh-CN.md +6 -0
- package/node_modules/@delexec/sqlite-store/package.json +26 -0
- package/node_modules/@delexec/sqlite-store/src/index.js +68 -0
- package/node_modules/@delexec/transport-email/README.md +3 -0
- package/node_modules/@delexec/transport-email/README.zh-CN.md +6 -0
- package/node_modules/@delexec/transport-email/package.json +23 -0
- package/node_modules/@delexec/transport-email/src/index.js +185 -0
- package/node_modules/@delexec/transport-emailengine/README.md +3 -0
- package/node_modules/@delexec/transport-emailengine/README.zh-CN.md +6 -0
- package/node_modules/@delexec/transport-emailengine/package.json +26 -0
- package/node_modules/@delexec/transport-emailengine/src/index.js +210 -0
- package/node_modules/@delexec/transport-gmail/README.md +3 -0
- package/node_modules/@delexec/transport-gmail/README.zh-CN.md +6 -0
- package/node_modules/@delexec/transport-gmail/package.json +26 -0
- package/node_modules/@delexec/transport-gmail/src/index.js +295 -0
- package/node_modules/@delexec/transport-relay-http/README.md +3 -0
- package/node_modules/@delexec/transport-relay-http/README.zh-CN.md +6 -0
- package/node_modules/@delexec/transport-relay-http/package.json +23 -0
- package/node_modules/@delexec/transport-relay-http/src/index.js +124 -0
- package/package.json +64 -0
- package/src/cli.js +1571 -0
- package/src/config.js +1180 -0
- package/src/example-hotline-worker.js +65 -0
- package/src/example-hotline.js +196 -0
- package/src/logging.js +56 -0
- package/src/supervisor.js +3070 -0
|
@@ -0,0 +1,3070 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
import { buildStructuredError } from "@delexec/contracts";
|
|
10
|
+
import {
|
|
11
|
+
buildHotlineOnboardingBody,
|
|
12
|
+
ensureHotlineRegistrationDraft,
|
|
13
|
+
buildTransportEnvUpdates,
|
|
14
|
+
buildTransportSecretUpdates,
|
|
15
|
+
ensureResponderIdentity,
|
|
16
|
+
ensureOpsState,
|
|
17
|
+
hasEncryptedSecretStore,
|
|
18
|
+
listLegacySecretKeys,
|
|
19
|
+
loadHotlineRegistrationDraft,
|
|
20
|
+
normalizeTransportConfig,
|
|
21
|
+
OPS_SECRET_KEYS,
|
|
22
|
+
readTransportSecretsFromEnv,
|
|
23
|
+
readResolvedOpsSecrets,
|
|
24
|
+
redactTransportConfig,
|
|
25
|
+
removeHotline,
|
|
26
|
+
saveOpsState,
|
|
27
|
+
scrubLegacySecrets,
|
|
28
|
+
setHotlineEnabled,
|
|
29
|
+
unlockOpsSecrets,
|
|
30
|
+
upsertHotline,
|
|
31
|
+
writeOpsSecrets
|
|
32
|
+
} from "./config.js";
|
|
33
|
+
import {
|
|
34
|
+
buildExampleRequestBody,
|
|
35
|
+
buildExampleHotlineDefinition,
|
|
36
|
+
isExampleHotlineDefinitionStale,
|
|
37
|
+
LOCAL_EXAMPLE_DISPLAY_NAME,
|
|
38
|
+
LOCAL_EXAMPLE_HOTLINE_ID
|
|
39
|
+
} from "./example-hotline.js";
|
|
40
|
+
import {
|
|
41
|
+
appendServiceLog,
|
|
42
|
+
appendSupervisorEvent,
|
|
43
|
+
getServiceLogFile,
|
|
44
|
+
getSupervisorEventsFile,
|
|
45
|
+
readServiceLogTail,
|
|
46
|
+
readSupervisorEventTail
|
|
47
|
+
} from "./logging.js";
|
|
48
|
+
import {
|
|
49
|
+
ensureOpsDirectories,
|
|
50
|
+
getOpsHomeDir,
|
|
51
|
+
initializeSecretStore,
|
|
52
|
+
rotateSecretStorePassphrase,
|
|
53
|
+
writeJsonFile
|
|
54
|
+
} from "@delexec/runtime-utils";
|
|
55
|
+
|
|
56
|
+
const require = createRequire(import.meta.url);
|
|
57
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
58
|
+
const __dirname = path.dirname(__filename);
|
|
59
|
+
const PLATFORM_BOOTSTRAP_DEMO_HOTLINE_IDS = new Set([
|
|
60
|
+
"starlight.creative.studio.v1",
|
|
61
|
+
"atlas.knowledge.qa.v1",
|
|
62
|
+
"pixel.product.renderer.v1"
|
|
63
|
+
]);
|
|
64
|
+
function getOpsSessionStateFile() {
|
|
65
|
+
return path.join(getOpsHomeDir(), "run", "session.json");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function nowIso() {
|
|
69
|
+
return new Date().toISOString();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function persistActiveSession(session) {
|
|
73
|
+
ensureOpsDirectories();
|
|
74
|
+
const sessionStateFile = getOpsSessionStateFile();
|
|
75
|
+
if (!session?.token) {
|
|
76
|
+
if (fs.existsSync(sessionStateFile)) {
|
|
77
|
+
fs.rmSync(sessionStateFile, { force: true });
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
writeJsonFile(sessionStateFile, {
|
|
82
|
+
token: session.token,
|
|
83
|
+
expires_at: session.expires_at
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function clearActiveSession() {
|
|
88
|
+
persistActiveSession(null);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function sendJson(res, statusCode, data) {
|
|
92
|
+
res.writeHead(statusCode, {
|
|
93
|
+
"content-type": "application/json; charset=utf-8",
|
|
94
|
+
"access-control-allow-origin": "*",
|
|
95
|
+
"access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
|
|
96
|
+
"access-control-allow-headers": "Content-Type, Authorization, X-Ops-Session"
|
|
97
|
+
});
|
|
98
|
+
res.end(JSON.stringify(data));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function sendError(res, statusCode, code, message, { retryable, ...extra } = {}) {
|
|
102
|
+
sendJson(res, statusCode, buildStructuredError(code, message, { retryable, ...extra }));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseJsonBody(req) {
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
const chunks = [];
|
|
108
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
109
|
+
req.on("end", () => {
|
|
110
|
+
if (chunks.length === 0) {
|
|
111
|
+
resolve({});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
116
|
+
} catch {
|
|
117
|
+
reject(new Error("invalid_json"));
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
req.on("error", reject);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function requestJson(baseUrl, pathname, { method = "GET", headers = {}, body } = {}) {
|
|
125
|
+
const response = await fetch(new URL(pathname, baseUrl), {
|
|
126
|
+
method,
|
|
127
|
+
headers: {
|
|
128
|
+
...headers,
|
|
129
|
+
...(body === undefined ? {} : { "content-type": "application/json; charset=utf-8" })
|
|
130
|
+
},
|
|
131
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
132
|
+
});
|
|
133
|
+
const text = await response.text();
|
|
134
|
+
return {
|
|
135
|
+
status: response.status,
|
|
136
|
+
body: text ? JSON.parse(text) : null
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function processBaseUrl(port) {
|
|
141
|
+
return `http://127.0.0.1:${port}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function appendPath(baseUrl, pathname) {
|
|
145
|
+
return new URL(pathname, `${baseUrl}/`).toString();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function parseJsonArrayEnv(value) {
|
|
149
|
+
const normalized = normalizedString(value);
|
|
150
|
+
if (!normalized) {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const parsed = JSON.parse(normalized);
|
|
155
|
+
return Array.isArray(parsed) ? parsed.map((item) => String(item)) : [normalized];
|
|
156
|
+
} catch {
|
|
157
|
+
return normalized.split(/\s+/).filter(Boolean);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function normalizedString(value) {
|
|
162
|
+
if (value === undefined || value === null) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
const trimmed = String(value).trim();
|
|
166
|
+
return trimmed ? trimmed : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const OPS_SESSION_HEADER = "x-ops-session";
|
|
170
|
+
const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
|
|
171
|
+
|
|
172
|
+
function createSessionToken() {
|
|
173
|
+
return crypto.randomUUID().replace(/-/g, "");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function buildTransportSecretLookup(secrets) {
|
|
177
|
+
return {
|
|
178
|
+
[OPS_SECRET_KEYS.transport_emailengine_access_token]: secrets.transport.emailengine.access_token,
|
|
179
|
+
[OPS_SECRET_KEYS.transport_gmail_client_secret]: secrets.transport.gmail.client_secret,
|
|
180
|
+
[OPS_SECRET_KEYS.transport_gmail_refresh_token]: secrets.transport.gmail.refresh_token
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function buildLegacyTransportSecretEnv(secretUpdates) {
|
|
185
|
+
return {
|
|
186
|
+
TRANSPORT_EMAILENGINE_ACCESS_TOKEN: secretUpdates[OPS_SECRET_KEYS.transport_emailengine_access_token] || undefined,
|
|
187
|
+
TRANSPORT_GMAIL_CLIENT_SECRET: secretUpdates[OPS_SECRET_KEYS.transport_gmail_client_secret] || undefined,
|
|
188
|
+
TRANSPORT_GMAIL_REFRESH_TOKEN: secretUpdates[OPS_SECRET_KEYS.transport_gmail_refresh_token] || undefined
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function mergeEnvWithResolvedSecrets(env, secrets) {
|
|
193
|
+
return {
|
|
194
|
+
...env,
|
|
195
|
+
CALLER_PLATFORM_API_KEY: secrets.caller_api_key || env.CALLER_PLATFORM_API_KEY || env.PLATFORM_API_KEY || "",
|
|
196
|
+
PLATFORM_API_KEY: secrets.caller_api_key || env.PLATFORM_API_KEY || env.CALLER_PLATFORM_API_KEY || "",
|
|
197
|
+
RESPONDER_PLATFORM_API_KEY: secrets.responder_platform_api_key || env.RESPONDER_PLATFORM_API_KEY || "",
|
|
198
|
+
PLATFORM_ADMIN_API_KEY: secrets.platform_admin_api_key || env.PLATFORM_ADMIN_API_KEY || "",
|
|
199
|
+
...buildTransportSecretLookup(secrets)
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function pruneExpiredSessions(runtime) {
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
for (const [token, session] of runtime.auth.sessions.entries()) {
|
|
206
|
+
if (session.expiresAt <= now) {
|
|
207
|
+
runtime.auth.sessions.delete(token);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (runtime.auth.sessions.size === 0) {
|
|
211
|
+
runtime.auth.unlockedSecrets = null;
|
|
212
|
+
runtime.auth.passphrase = null;
|
|
213
|
+
runtime.auth.unlockedAt = null;
|
|
214
|
+
clearActiveSession();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function createAuthenticatedSession(runtime, passphrase, secrets) {
|
|
219
|
+
pruneExpiredSessions(runtime);
|
|
220
|
+
const token = createSessionToken();
|
|
221
|
+
const expiresAt = Date.now() + SESSION_TTL_MS;
|
|
222
|
+
runtime.auth.passphrase = passphrase;
|
|
223
|
+
runtime.auth.unlockedSecrets = secrets;
|
|
224
|
+
runtime.auth.unlockedAt = nowIso();
|
|
225
|
+
runtime.auth.sessions.set(token, {
|
|
226
|
+
token,
|
|
227
|
+
createdAt: nowIso(),
|
|
228
|
+
expiresAt
|
|
229
|
+
});
|
|
230
|
+
const session = {
|
|
231
|
+
token,
|
|
232
|
+
expires_at: new Date(expiresAt).toISOString()
|
|
233
|
+
};
|
|
234
|
+
persistActiveSession(session);
|
|
235
|
+
return session;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function readSessionToken(req) {
|
|
239
|
+
const headerValue = req.headers[OPS_SESSION_HEADER];
|
|
240
|
+
if (Array.isArray(headerValue)) {
|
|
241
|
+
return headerValue[0] || null;
|
|
242
|
+
}
|
|
243
|
+
return normalizedString(headerValue);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function getCurrentSession(runtime, req) {
|
|
247
|
+
pruneExpiredSessions(runtime);
|
|
248
|
+
const token = readSessionToken(req);
|
|
249
|
+
if (!token) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
const session = runtime.auth.sessions.get(token);
|
|
253
|
+
if (!session) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
session.expiresAt = Date.now() + SESSION_TTL_MS;
|
|
257
|
+
const activeSession = {
|
|
258
|
+
token,
|
|
259
|
+
expires_at: new Date(session.expiresAt).toISOString()
|
|
260
|
+
};
|
|
261
|
+
persistActiveSession(activeSession);
|
|
262
|
+
return activeSession;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function isLocalResponderConfigRoute(method, pathname) {
|
|
266
|
+
if (method === "POST" && (pathname === "/responder/hotlines" || pathname === "/responder/hotlines/example")) {
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
if (method === "DELETE" && /^\/responder\/hotlines\/[^/]+$/.test(pathname)) {
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
if (method === "POST" && /^\/responder\/hotlines\/[^/]+\/(enable|disable)$/.test(pathname)) {
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function isProtectedRoute(method, pathname) {
|
|
279
|
+
if (
|
|
280
|
+
pathname === "/healthz" ||
|
|
281
|
+
pathname === "/status" ||
|
|
282
|
+
pathname === "/setup" ||
|
|
283
|
+
pathname === "/mcp-adapter/spec" ||
|
|
284
|
+
pathname.startsWith("/auth/session")
|
|
285
|
+
) {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
if (isLocalResponderConfigRoute(method, pathname)) {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
if (method === "GET" && pathname === "/") {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function buildResponderRuntimeStatus(state, runtime, hotlineId = null) {
|
|
298
|
+
const responderProcess = runtime.processes.get("responder") || null;
|
|
299
|
+
const configuredHotlineIds = (state.config.responder?.hotlines || []).map((item) => item.hotline_id).filter(Boolean);
|
|
300
|
+
return {
|
|
301
|
+
responder_running: Boolean(responderProcess && !responderProcess.exited),
|
|
302
|
+
responder_healthy: Boolean(responderProcess?.health?.status === 200),
|
|
303
|
+
configured_hotline_ids: configuredHotlineIds,
|
|
304
|
+
hotline_configured: hotlineId ? configuredHotlineIds.includes(hotlineId) : null
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function platformFeaturesEnabled(state) {
|
|
309
|
+
return state.config?.platform?.enabled === true;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function serializeHotlineForUi(state, runtime, hotline) {
|
|
313
|
+
const draftFile = hotline?.metadata?.registration?.draft_file || null;
|
|
314
|
+
const localIntegrationFile = hotline?.metadata?.local?.integration_file || null;
|
|
315
|
+
const localHookFile = hotline?.metadata?.local?.hook_file || null;
|
|
316
|
+
const runtimeStatus = buildResponderRuntimeStatus(state, runtime, hotline?.hotline_id || null);
|
|
317
|
+
return {
|
|
318
|
+
...hotline,
|
|
319
|
+
draft_ready: Boolean(draftFile),
|
|
320
|
+
draft_file: draftFile,
|
|
321
|
+
local_integration_file: localIntegrationFile,
|
|
322
|
+
local_hook_file: localHookFile,
|
|
323
|
+
runtime_loaded: Boolean(runtimeStatus.responder_running && hotline?.enabled !== false && runtimeStatus.hotline_configured),
|
|
324
|
+
local_status: hotline?.enabled === false ? "disabled" : draftFile ? "draft_ready" : "configured"
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function getAuthState(runtime, state) {
|
|
329
|
+
pruneExpiredSessions(runtime);
|
|
330
|
+
const configured = hasEncryptedSecretStore();
|
|
331
|
+
const legacySecretKeys = listLegacySecretKeys(state);
|
|
332
|
+
const activeSession = runtime.auth.sessions.values().next().value || null;
|
|
333
|
+
return {
|
|
334
|
+
configured,
|
|
335
|
+
secret_file: state.secretsFile,
|
|
336
|
+
legacy_secret_keys: legacySecretKeys,
|
|
337
|
+
legacy_secret_source_present: legacySecretKeys.length > 0,
|
|
338
|
+
locked: configured && runtime.auth.sessions.size === 0,
|
|
339
|
+
authenticated: configured ? runtime.auth.sessions.size > 0 : true,
|
|
340
|
+
setup_required: !configured,
|
|
341
|
+
expires_at: activeSession ? new Date(activeSession.expiresAt).toISOString() : null
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function getRecoverableSession(runtime) {
|
|
346
|
+
pruneExpiredSessions(runtime);
|
|
347
|
+
const activeSession = runtime.auth.sessions.values().next().value || null;
|
|
348
|
+
if (!activeSession) {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
token: activeSession.token,
|
|
353
|
+
expires_at: new Date(activeSession.expiresAt).toISOString()
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function requireAuthenticatedSession(req, res, runtime, state) {
|
|
358
|
+
if (!hasEncryptedSecretStore()) {
|
|
359
|
+
return { ok: true, session: null };
|
|
360
|
+
}
|
|
361
|
+
const session = getCurrentSession(runtime, req);
|
|
362
|
+
if (!session) {
|
|
363
|
+
sendError(res, 401, "AUTH_SESSION_REQUIRED", "local supervisor session is locked or missing", {
|
|
364
|
+
retryable: false,
|
|
365
|
+
auth: getAuthState(runtime, state)
|
|
366
|
+
});
|
|
367
|
+
return { ok: false, session: null };
|
|
368
|
+
}
|
|
369
|
+
return { ok: true, session };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function normalizeTransportPayload(body = {}) {
|
|
373
|
+
return normalizeTransportConfig({ runtime: { transport: body } }, {});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function validateTransportConfig(transport) {
|
|
377
|
+
if (!["local", "relay_http", "email"].includes(transport.type)) {
|
|
378
|
+
return { status: 400, body: buildStructuredError("CONTRACT_INVALID_TRANSPORT_TYPE", "unsupported transport type") };
|
|
379
|
+
}
|
|
380
|
+
if (transport.type === "relay_http" && !normalizedString(transport.relay_http?.base_url)) {
|
|
381
|
+
return { status: 400, body: buildStructuredError("CONTRACT_INVALID_TRANSPORT_BODY", "relay_http.base_url is required") };
|
|
382
|
+
}
|
|
383
|
+
if (transport.type === "email") {
|
|
384
|
+
if (!["emailengine", "gmail"].includes(transport.email.provider)) {
|
|
385
|
+
return { status: 400, body: buildStructuredError("CONTRACT_INVALID_TRANSPORT_BODY", "unsupported email provider") };
|
|
386
|
+
}
|
|
387
|
+
if (!normalizedString(transport.email.sender)) {
|
|
388
|
+
return { status: 400, body: buildStructuredError("CONTRACT_INVALID_TRANSPORT_BODY", "email.sender is required") };
|
|
389
|
+
}
|
|
390
|
+
if (!normalizedString(transport.email.receiver)) {
|
|
391
|
+
return { status: 400, body: buildStructuredError("CONTRACT_INVALID_TRANSPORT_BODY", "email.receiver is required") };
|
|
392
|
+
}
|
|
393
|
+
if (transport.email.provider === "emailengine") {
|
|
394
|
+
if (!normalizedString(transport.email.emailengine?.base_url) || !normalizedString(transport.email.emailengine?.account)) {
|
|
395
|
+
return {
|
|
396
|
+
status: 400,
|
|
397
|
+
body: buildStructuredError("CONTRACT_INVALID_TRANSPORT_BODY", "email.emailengine.base_url and account are required")
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (transport.email.provider === "gmail" && (!normalizedString(transport.email.gmail?.client_id) || !normalizedString(transport.email.gmail?.user))) {
|
|
402
|
+
return {
|
|
403
|
+
status: 400,
|
|
404
|
+
body: buildStructuredError("CONTRACT_INVALID_TRANSPORT_BODY", "email.gmail.client_id and user are required")
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function getRuntimeTransport(state) {
|
|
412
|
+
return normalizeTransportConfig(state.config, state.env);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function getResolvedSecrets(state, runtime) {
|
|
416
|
+
return readResolvedOpsSecrets(state, runtime.auth.unlockedSecrets);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function getTransportResponse(state, runtime) {
|
|
420
|
+
return redactTransportConfig(state.config.runtime?.transport || {}, mergeEnvWithResolvedSecrets(state.env, getResolvedSecrets(state, runtime)));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function buildPlatformHeaders(state, runtime) {
|
|
424
|
+
const secrets = getResolvedSecrets(state, runtime);
|
|
425
|
+
return secrets.caller_api_key ? { "X-Platform-Api-Key": secrets.caller_api_key } : {};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function findConfiguredExampleHotline(state) {
|
|
429
|
+
return (state.config.responder?.hotlines || []).find((item) => item.hotline_id === LOCAL_EXAMPLE_HOTLINE_ID) || null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function buildExampleVisibilityError(example) {
|
|
433
|
+
if (!example) {
|
|
434
|
+
return {
|
|
435
|
+
status: 404,
|
|
436
|
+
body: buildStructuredError("EXAMPLE_HOTLINE_NOT_CONFIGURED", "official example hotline is not configured locally", {
|
|
437
|
+
stage: "add_example_hotline"
|
|
438
|
+
})
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
if (example.submitted_for_review !== true) {
|
|
442
|
+
return {
|
|
443
|
+
status: 409,
|
|
444
|
+
body: buildStructuredError("EXAMPLE_REVIEW_NOT_SUBMITTED", "official example hotline must be submitted for review first", {
|
|
445
|
+
stage: "submit_review"
|
|
446
|
+
})
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
return {
|
|
450
|
+
status: 409,
|
|
451
|
+
body: buildStructuredError("EXAMPLE_NOT_VISIBLE_IN_CATALOG", "official example hotline is not yet visible in catalog", {
|
|
452
|
+
stage: "approve_and_catalog",
|
|
453
|
+
review_status: example.review_status || "pending"
|
|
454
|
+
})
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function ensurePreferenceState(state) {
|
|
459
|
+
state.config.preferences ||= {
|
|
460
|
+
task_types: {},
|
|
461
|
+
caller_policy: {
|
|
462
|
+
mode: "manual",
|
|
463
|
+
responderWhitelist: [],
|
|
464
|
+
hotlineWhitelist: [],
|
|
465
|
+
blocklist: []
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
state.config.preferences.task_types ||= {};
|
|
469
|
+
return state.config.preferences.task_types;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function ensureCallerPolicyState(state) {
|
|
473
|
+
state.config.preferences ||= {
|
|
474
|
+
task_types: {},
|
|
475
|
+
caller_policy: {
|
|
476
|
+
mode: "manual",
|
|
477
|
+
responderWhitelist: [],
|
|
478
|
+
hotlineWhitelist: [],
|
|
479
|
+
blocklist: []
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
state.config.preferences.caller_policy ||= {
|
|
483
|
+
mode: "manual",
|
|
484
|
+
responderWhitelist: [],
|
|
485
|
+
hotlineWhitelist: [],
|
|
486
|
+
blocklist: []
|
|
487
|
+
};
|
|
488
|
+
state.config.preferences.caller_policy.mode ||= "manual";
|
|
489
|
+
state.config.preferences.caller_policy.responderWhitelist ||= [];
|
|
490
|
+
state.config.preferences.caller_policy.hotlineWhitelist ||= [];
|
|
491
|
+
state.config.preferences.caller_policy.blocklist ||= [];
|
|
492
|
+
return state.config.preferences.caller_policy;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function normalizeTaskTypeKey(taskType) {
|
|
496
|
+
return normalizedString(taskType)?.toLowerCase() || null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function getTaskTypePreference(state, taskType) {
|
|
500
|
+
const key = normalizeTaskTypeKey(taskType);
|
|
501
|
+
if (!key) {
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
return ensurePreferenceState(state)[key] || null;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function setTaskTypePreference(state, taskType, preference) {
|
|
508
|
+
const key = normalizeTaskTypeKey(taskType);
|
|
509
|
+
if (!key) {
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
const preferences = ensurePreferenceState(state);
|
|
513
|
+
if (!preference || !preference.hotline_id) {
|
|
514
|
+
delete preferences[key];
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
preferences[key] = {
|
|
518
|
+
task_type: key,
|
|
519
|
+
hotline_id: preference.hotline_id,
|
|
520
|
+
responder_id: preference.responder_id || null,
|
|
521
|
+
updated_at: nowIso()
|
|
522
|
+
};
|
|
523
|
+
return preferences[key];
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function summarizeCandidate(item, { selected = false, taskType = null, preferred = false } = {}) {
|
|
527
|
+
const taskTypeMatched = taskType ? (item.task_types || []).includes(taskType) : false;
|
|
528
|
+
const reasons = [];
|
|
529
|
+
if (selected) {
|
|
530
|
+
reasons.push("agent_selected");
|
|
531
|
+
}
|
|
532
|
+
if (preferred) {
|
|
533
|
+
reasons.push("task_type_preference");
|
|
534
|
+
}
|
|
535
|
+
if (taskTypeMatched) {
|
|
536
|
+
reasons.push("task_type_match");
|
|
537
|
+
}
|
|
538
|
+
if (item.availability_status === "healthy") {
|
|
539
|
+
reasons.push("healthy");
|
|
540
|
+
}
|
|
541
|
+
if ((item.capabilities || []).length > 0) {
|
|
542
|
+
reasons.push("capability_signal");
|
|
543
|
+
}
|
|
544
|
+
return {
|
|
545
|
+
hotline_id: item.hotline_id,
|
|
546
|
+
responder_id: item.responder_id,
|
|
547
|
+
display_name: item.display_name || item.hotline_id,
|
|
548
|
+
responder_display_name: item.responder_display_name || item.responder_id,
|
|
549
|
+
task_types: item.task_types || [],
|
|
550
|
+
capabilities: item.capabilities || [],
|
|
551
|
+
tags: item.tags || [],
|
|
552
|
+
availability_status: item.availability_status || "unknown",
|
|
553
|
+
signer_public_key_pem: item.responder_public_key_pem || null,
|
|
554
|
+
template_summary: item.template_ref
|
|
555
|
+
? {
|
|
556
|
+
template_ref: item.template_ref,
|
|
557
|
+
input_properties: Object.keys(item.input_schema?.properties || {}),
|
|
558
|
+
output_properties: Object.keys(item.output_schema?.properties || {})
|
|
559
|
+
}
|
|
560
|
+
: null,
|
|
561
|
+
difference_note: preferred
|
|
562
|
+
? "Matches your remembered task-type preference."
|
|
563
|
+
: taskTypeMatched
|
|
564
|
+
? "Matches the current task type."
|
|
565
|
+
: "Available as a fallback responder route.",
|
|
566
|
+
match_reasons: reasons
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function scoreCandidate(item, { taskType = null, responderId = null, hotlineId = null, preferred = null } = {}) {
|
|
571
|
+
let score = 0;
|
|
572
|
+
if (hotlineId && item.hotline_id === hotlineId) {
|
|
573
|
+
score += 120;
|
|
574
|
+
}
|
|
575
|
+
if (responderId && item.responder_id === responderId) {
|
|
576
|
+
score += 80;
|
|
577
|
+
}
|
|
578
|
+
if (preferred && item.hotline_id === preferred.hotline_id) {
|
|
579
|
+
score += 60;
|
|
580
|
+
if (!preferred.responder_id || preferred.responder_id === item.responder_id) {
|
|
581
|
+
score += 20;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (taskType && (item.task_types || []).includes(taskType)) {
|
|
585
|
+
score += 40;
|
|
586
|
+
}
|
|
587
|
+
if (item.availability_status === "healthy") {
|
|
588
|
+
score += 15;
|
|
589
|
+
}
|
|
590
|
+
if (item.review_status === "approved") {
|
|
591
|
+
score += 10;
|
|
592
|
+
}
|
|
593
|
+
score += Math.min((item.capabilities || []).length, 5);
|
|
594
|
+
return score;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function fetchCatalogCandidates(state, runtime, filters = {}) {
|
|
598
|
+
if (!platformFeaturesEnabled(state)) {
|
|
599
|
+
return listLocalCatalogHotlines(state, runtime, filters);
|
|
600
|
+
}
|
|
601
|
+
const params = new URLSearchParams();
|
|
602
|
+
if (filters.hotline_id) {
|
|
603
|
+
params.set("hotline_id", filters.hotline_id);
|
|
604
|
+
}
|
|
605
|
+
if (filters.responder_id) {
|
|
606
|
+
params.set("responder_id", filters.responder_id);
|
|
607
|
+
}
|
|
608
|
+
if (filters.task_type) {
|
|
609
|
+
params.set("task_type", filters.task_type);
|
|
610
|
+
}
|
|
611
|
+
if (filters.capability) {
|
|
612
|
+
params.set("capability", filters.capability);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const response = await requestJson(
|
|
616
|
+
processBaseUrl(state.config.runtime.ports.caller),
|
|
617
|
+
`/controller/hotlines${params.toString() ? `?${params.toString()}` : ""}`,
|
|
618
|
+
{
|
|
619
|
+
headers: buildPlatformHeaders(state, runtime)
|
|
620
|
+
}
|
|
621
|
+
);
|
|
622
|
+
return response.body?.items || [];
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function buildLocalCatalogHotline(state, runtime, hotline) {
|
|
626
|
+
const responderIdentity = ensureResponderIdentity(state);
|
|
627
|
+
const currentOfficialExample =
|
|
628
|
+
hotline.hotline_id === LOCAL_EXAMPLE_HOTLINE_ID ? buildExampleHotlineDefinition(hotline) : null;
|
|
629
|
+
const { draft } = currentOfficialExample ? { draft: currentOfficialExample } : loadHotlineRegistrationDraft(state, hotline);
|
|
630
|
+
const runtimeStatus = buildResponderRuntimeStatus(state, runtime, hotline.hotline_id);
|
|
631
|
+
const source = draft || {};
|
|
632
|
+
return {
|
|
633
|
+
responder_id: state.config.responder.responder_id || responderIdentity.responder_id,
|
|
634
|
+
hotline_id: hotline.hotline_id,
|
|
635
|
+
display_name: source.display_name || hotline.display_name || hotline.hotline_id,
|
|
636
|
+
description: source.description || null,
|
|
637
|
+
summary: source.summary || null,
|
|
638
|
+
status: hotline.enabled === false ? "disabled" : "enabled",
|
|
639
|
+
review_status: hotline.review_status || "local_only",
|
|
640
|
+
submission_version: null,
|
|
641
|
+
submitted_at: null,
|
|
642
|
+
reviewed_at: null,
|
|
643
|
+
reviewed_by: null,
|
|
644
|
+
review_reason: null,
|
|
645
|
+
availability_status:
|
|
646
|
+
runtimeStatus.responder_running && hotline.enabled !== false && runtimeStatus.hotline_configured ? "healthy" : "offline",
|
|
647
|
+
last_heartbeat_at: null,
|
|
648
|
+
template_ref: source.template_ref || `docs/templates/hotlines/${hotline.hotline_id}/`,
|
|
649
|
+
task_types: source.task_types || hotline.task_types || [],
|
|
650
|
+
capabilities: source.capabilities || hotline.capabilities || [],
|
|
651
|
+
tags: source.tags || hotline.tags || [],
|
|
652
|
+
recommended_for: Array.isArray(source.recommended_for) ? source.recommended_for : [],
|
|
653
|
+
not_recommended_for: Array.isArray(source.not_recommended_for) ? source.not_recommended_for : [],
|
|
654
|
+
limitations: Array.isArray(source.limitations) ? source.limitations : [],
|
|
655
|
+
input_summary: source.input_summary || null,
|
|
656
|
+
output_summary: source.output_summary || null,
|
|
657
|
+
input_schema: source.input_schema || null,
|
|
658
|
+
output_schema: source.output_schema || null,
|
|
659
|
+
input_attachments: source.input_attachments || null,
|
|
660
|
+
output_attachments: source.output_attachments || null,
|
|
661
|
+
input_examples: Array.isArray(source.input_examples) ? source.input_examples : null,
|
|
662
|
+
output_examples: Array.isArray(source.output_examples) ? source.output_examples : null,
|
|
663
|
+
responder_public_key_pem: responderIdentity.public_key_pem,
|
|
664
|
+
responder_public_keys_pem: responderIdentity.public_key_pem ? [responderIdentity.public_key_pem] : [],
|
|
665
|
+
catalog_visibility: "local",
|
|
666
|
+
source: "local"
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function listLocalCatalogHotlines(state, runtime, filters = {}) {
|
|
671
|
+
return (state.config.responder?.hotlines || [])
|
|
672
|
+
.filter((item) => item.enabled !== false)
|
|
673
|
+
.map((item) => buildLocalCatalogHotline(state, runtime, item))
|
|
674
|
+
.filter((item) => {
|
|
675
|
+
if (filters.hotline_id && item.hotline_id !== filters.hotline_id) {
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
if (filters.responder_id && item.responder_id !== filters.responder_id) {
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
if (filters.task_type && !(item.task_types || []).includes(filters.task_type)) {
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
684
|
+
if (filters.capability && !(item.capabilities || []).includes(filters.capability)) {
|
|
685
|
+
return false;
|
|
686
|
+
}
|
|
687
|
+
return true;
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function isCallableCatalogItem(item) {
|
|
692
|
+
if (!item) {
|
|
693
|
+
return false;
|
|
694
|
+
}
|
|
695
|
+
if (item.hotline_id === LOCAL_EXAMPLE_HOTLINE_ID) {
|
|
696
|
+
return true;
|
|
697
|
+
}
|
|
698
|
+
if (isPlatformBootstrapDemoCatalogItem(item)) {
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
if (item.source === "local" || item.review_status === "local_only" || (item.tags || []).includes("local")) {
|
|
702
|
+
return true;
|
|
703
|
+
}
|
|
704
|
+
return item.availability_status !== "offline";
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function isPlatformBootstrapDemoCatalogItem(item) {
|
|
708
|
+
if (!item || item.source === "local" || !PLATFORM_BOOTSTRAP_DEMO_HOTLINE_IDS.has(item.hotline_id)) {
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
return (
|
|
712
|
+
item.review_reason === "bootstrap" ||
|
|
713
|
+
item.reviewed_by === "system" ||
|
|
714
|
+
String(item.template_ref || "").startsWith(`docs/templates/hotlines/${item.hotline_id}/`)
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function mergeCatalogItems(platformItems = [], localItems = []) {
|
|
719
|
+
const seen = new Set();
|
|
720
|
+
const merged = [];
|
|
721
|
+
for (const item of [...localItems, ...platformItems]) {
|
|
722
|
+
const key = `${item.responder_id || ""}:${item.hotline_id || ""}`;
|
|
723
|
+
if (seen.has(key) || !isCallableCatalogItem(item)) {
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
seen.add(key);
|
|
727
|
+
merged.push(item);
|
|
728
|
+
}
|
|
729
|
+
return merged;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
async function testRelayTransport(baseUrl) {
|
|
734
|
+
try {
|
|
735
|
+
const response = await fetch(new URL("/healthz", baseUrl));
|
|
736
|
+
return {
|
|
737
|
+
ok: response.ok,
|
|
738
|
+
kind: "relay_http",
|
|
739
|
+
status: response.status,
|
|
740
|
+
detail: response.ok ? "relay_health_ok" : "relay_health_failed"
|
|
741
|
+
};
|
|
742
|
+
} catch (error) {
|
|
743
|
+
return {
|
|
744
|
+
ok: false,
|
|
745
|
+
kind: "relay_http",
|
|
746
|
+
error: buildStructuredError("TRANSPORT_CONNECTION_FAILED", error instanceof Error ? error.message : "unknown_error")
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function testEmailEngineTransport(transport, secrets) {
|
|
752
|
+
if (!secrets.emailengine.access_token) {
|
|
753
|
+
return {
|
|
754
|
+
ok: false,
|
|
755
|
+
kind: "emailengine",
|
|
756
|
+
error: buildStructuredError("AUTH_CREDENTIALS_MISSING", "EmailEngine access token is not configured")
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
try {
|
|
761
|
+
const response = await fetch(
|
|
762
|
+
new URL(`/v1/account/${encodeURIComponent(transport.email.emailengine.account)}`, transport.email.emailengine.base_url),
|
|
763
|
+
{
|
|
764
|
+
headers: {
|
|
765
|
+
Authorization: `Bearer ${secrets.emailengine.access_token}`
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
);
|
|
769
|
+
if (!response.ok) {
|
|
770
|
+
return {
|
|
771
|
+
ok: false,
|
|
772
|
+
kind: "emailengine",
|
|
773
|
+
status: response.status,
|
|
774
|
+
error: buildStructuredError("AUTH_INVALID_CREDENTIALS", `EmailEngine returned ${response.status}`)
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
return {
|
|
778
|
+
ok: true,
|
|
779
|
+
kind: "emailengine",
|
|
780
|
+
status: response.status,
|
|
781
|
+
detail: "emailengine_auth_ok"
|
|
782
|
+
};
|
|
783
|
+
} catch (error) {
|
|
784
|
+
return {
|
|
785
|
+
ok: false,
|
|
786
|
+
kind: "emailengine",
|
|
787
|
+
error: buildStructuredError("TRANSPORT_CONNECTION_FAILED", error instanceof Error ? error.message : "unknown_error")
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async function getGmailAccessToken(transport, secrets) {
|
|
793
|
+
if (!secrets.gmail.client_secret || !secrets.gmail.refresh_token) {
|
|
794
|
+
return {
|
|
795
|
+
ok: false,
|
|
796
|
+
error: buildStructuredError("AUTH_CREDENTIALS_MISSING", "Gmail client secret or refresh token is not configured")
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
try {
|
|
801
|
+
const response = await fetch("https://oauth2.googleapis.com/token", {
|
|
802
|
+
method: "POST",
|
|
803
|
+
headers: {
|
|
804
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
805
|
+
},
|
|
806
|
+
body: new URLSearchParams({
|
|
807
|
+
client_id: transport.email.gmail.client_id,
|
|
808
|
+
client_secret: secrets.gmail.client_secret,
|
|
809
|
+
refresh_token: secrets.gmail.refresh_token,
|
|
810
|
+
grant_type: "refresh_token"
|
|
811
|
+
})
|
|
812
|
+
});
|
|
813
|
+
const body = await response.json().catch(() => null);
|
|
814
|
+
if (!response.ok || !body?.access_token) {
|
|
815
|
+
return {
|
|
816
|
+
ok: false,
|
|
817
|
+
status: response.status,
|
|
818
|
+
error: buildStructuredError("AUTH_INVALID_CREDENTIALS", body?.error_description || body?.error || "gmail_token_refresh_failed")
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
return {
|
|
822
|
+
ok: true,
|
|
823
|
+
accessToken: body.access_token
|
|
824
|
+
};
|
|
825
|
+
} catch (error) {
|
|
826
|
+
return {
|
|
827
|
+
ok: false,
|
|
828
|
+
error: buildStructuredError("TRANSPORT_CONNECTION_FAILED", error instanceof Error ? error.message : "unknown_error")
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
async function testGmailTransport(transport, secrets) {
|
|
834
|
+
const token = await getGmailAccessToken(transport, secrets);
|
|
835
|
+
if (!token.ok) {
|
|
836
|
+
return {
|
|
837
|
+
ok: false,
|
|
838
|
+
kind: "gmail",
|
|
839
|
+
...(token.status ? { status: token.status } : {}),
|
|
840
|
+
error: token.error
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
try {
|
|
845
|
+
const response = await fetch(`https://gmail.googleapis.com/gmail/v1/users/${encodeURIComponent(transport.email.gmail.user)}/profile`, {
|
|
846
|
+
headers: {
|
|
847
|
+
Authorization: `Bearer ${token.accessToken}`
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
if (!response.ok) {
|
|
851
|
+
const body = await response.json().catch(() => null);
|
|
852
|
+
return {
|
|
853
|
+
ok: false,
|
|
854
|
+
kind: "gmail",
|
|
855
|
+
status: response.status,
|
|
856
|
+
error: buildStructuredError("AUTH_INVALID_CREDENTIALS", body?.error?.message || `gmail_profile_failed_${response.status}`)
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
return {
|
|
860
|
+
ok: true,
|
|
861
|
+
kind: "gmail",
|
|
862
|
+
status: response.status,
|
|
863
|
+
detail: "gmail_auth_ok"
|
|
864
|
+
};
|
|
865
|
+
} catch (error) {
|
|
866
|
+
return {
|
|
867
|
+
ok: false,
|
|
868
|
+
kind: "gmail",
|
|
869
|
+
error: buildStructuredError("TRANSPORT_CONNECTION_FAILED", error instanceof Error ? error.message : "unknown_error")
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
async function testTransportConnection(state, runtime) {
|
|
875
|
+
const transport = getRuntimeTransport(state);
|
|
876
|
+
const secrets = getResolvedSecrets(state, runtime).transport;
|
|
877
|
+
if (transport.type === "local") {
|
|
878
|
+
return {
|
|
879
|
+
ok: true,
|
|
880
|
+
kind: "local",
|
|
881
|
+
detail: "local_transport_uses_managed_relay"
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
if (transport.type === "relay_http") {
|
|
885
|
+
return testRelayTransport(transport.relay_http.base_url);
|
|
886
|
+
}
|
|
887
|
+
if (transport.email.provider === "emailengine") {
|
|
888
|
+
return testEmailEngineTransport(transport, secrets);
|
|
889
|
+
}
|
|
890
|
+
return testGmailTransport(transport, secrets);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function logSeverity(message) {
|
|
894
|
+
if (!message) {
|
|
895
|
+
return null;
|
|
896
|
+
}
|
|
897
|
+
if (/(error|exception|fatal|failed|failure)/i.test(message)) {
|
|
898
|
+
return "error";
|
|
899
|
+
}
|
|
900
|
+
if (/(warn|warning|retry|timeout|denied|reject)/i.test(message)) {
|
|
901
|
+
return "warning";
|
|
902
|
+
}
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
export function createOpsSupervisorServer() {
|
|
907
|
+
const state = ensureOpsState();
|
|
908
|
+
appendSupervisorEvent({
|
|
909
|
+
type: "supervisor_created",
|
|
910
|
+
platform_base_url: state.config.platform.base_url
|
|
911
|
+
});
|
|
912
|
+
const runtime = {
|
|
913
|
+
processes: new Map(),
|
|
914
|
+
starting: new Map(),
|
|
915
|
+
relayQueues: new Map(),
|
|
916
|
+
auth: {
|
|
917
|
+
sessions: new Map(),
|
|
918
|
+
unlockedSecrets: null,
|
|
919
|
+
passphrase: null,
|
|
920
|
+
unlockedAt: null
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
function refreshStateFromDisk() {
|
|
925
|
+
Object.assign(state, ensureOpsState());
|
|
926
|
+
return state;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function getRuntimeStatus(name) {
|
|
930
|
+
const processInfo = runtime.processes.get(name);
|
|
931
|
+
if (!processInfo) {
|
|
932
|
+
return {
|
|
933
|
+
name,
|
|
934
|
+
running: false,
|
|
935
|
+
launch_mode: null,
|
|
936
|
+
pid: null,
|
|
937
|
+
started_at: null,
|
|
938
|
+
exited_at: null,
|
|
939
|
+
exit_code: null,
|
|
940
|
+
last_error: null
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
return {
|
|
944
|
+
name,
|
|
945
|
+
running: !processInfo.exited,
|
|
946
|
+
launch_mode: processInfo.launchMode || null,
|
|
947
|
+
pid: processInfo.child?.pid || processInfo.pid || null,
|
|
948
|
+
started_at: processInfo.startedAt,
|
|
949
|
+
exited_at: processInfo.exitedAt,
|
|
950
|
+
exit_code: processInfo.exitCode,
|
|
951
|
+
last_error: processInfo.lastError
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function usesManagedRelay() {
|
|
956
|
+
const runtimeTransport = getRuntimeTransport(state);
|
|
957
|
+
const managedRelayBaseUrl = processBaseUrl(state.config.runtime.ports.relay);
|
|
958
|
+
return (
|
|
959
|
+
runtimeTransport.type === "local" ||
|
|
960
|
+
(runtimeTransport.type === "relay_http" && normalizedString(runtimeTransport.relay_http.base_url) === managedRelayBaseUrl)
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function resolveRelayPackageEntry() {
|
|
965
|
+
const candidatePackageJsons = [
|
|
966
|
+
path.resolve(__dirname, "../node_modules/@delexec/transport-relay/package.json"),
|
|
967
|
+
path.resolve(__dirname, "../../../../platform/apps/transport-relay/package.json")
|
|
968
|
+
];
|
|
969
|
+
|
|
970
|
+
for (const packageJsonPath of candidatePackageJsons) {
|
|
971
|
+
if (!fs.existsSync(packageJsonPath)) {
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
const manifest = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
|
975
|
+
const packageRoot = path.dirname(packageJsonPath);
|
|
976
|
+
if (typeof manifest.bin === "string") {
|
|
977
|
+
return path.resolve(packageRoot, manifest.bin);
|
|
978
|
+
}
|
|
979
|
+
if (manifest.bin && typeof manifest.bin === "object") {
|
|
980
|
+
const relayBin = manifest.bin["delexec-relay"] || Object.values(manifest.bin)[0];
|
|
981
|
+
if (relayBin) {
|
|
982
|
+
return path.resolve(packageRoot, relayBin);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
if (typeof manifest.main === "string") {
|
|
986
|
+
return path.resolve(packageRoot, manifest.main);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Fall back to PATH lookup for delexec-relay / croc-relay binary.
|
|
991
|
+
// @delexec/transport-relay is a platform package and is no longer bundled
|
|
992
|
+
// with @delexec/ops. Operators who install it separately (globally or via
|
|
993
|
+
// the platform compose stack) will have the binary available in PATH.
|
|
994
|
+
for (const binName of ["delexec-relay", "croc-relay"]) {
|
|
995
|
+
try {
|
|
996
|
+
const resolved = execFileSync("which", [binName], { encoding: "utf8" }).trim();
|
|
997
|
+
if (resolved) {
|
|
998
|
+
return resolved;
|
|
999
|
+
}
|
|
1000
|
+
} catch (_) {
|
|
1001
|
+
// not in PATH, try next
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
return null;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function relayLaunchSpec() {
|
|
1009
|
+
const configuredBin = normalizedString(process.env.OPS_RELAY_BIN);
|
|
1010
|
+
if (configuredBin) {
|
|
1011
|
+
return {
|
|
1012
|
+
command: configuredBin,
|
|
1013
|
+
args: parseJsonArrayEnv(process.env.OPS_RELAY_ARGS),
|
|
1014
|
+
mode: "configured_command"
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const packageEntry = resolveRelayPackageEntry();
|
|
1019
|
+
if (packageEntry) {
|
|
1020
|
+
return {
|
|
1021
|
+
command: process.execPath,
|
|
1022
|
+
args: [packageEntry],
|
|
1023
|
+
mode: "package_entry"
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
throw new Error("relay_launch_command_not_found");
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function shouldUseEmbeddedRelay() {
|
|
1031
|
+
if (!usesManagedRelay()) {
|
|
1032
|
+
return false;
|
|
1033
|
+
}
|
|
1034
|
+
if (normalizedString(process.env.OPS_RELAY_BIN)) {
|
|
1035
|
+
return false;
|
|
1036
|
+
}
|
|
1037
|
+
return !resolveRelayPackageEntry();
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function relayQueueFor(receiver) {
|
|
1041
|
+
const key = String(receiver || "").trim();
|
|
1042
|
+
if (!runtime.relayQueues.has(key)) {
|
|
1043
|
+
runtime.relayQueues.set(key, []);
|
|
1044
|
+
}
|
|
1045
|
+
return runtime.relayQueues.get(key);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
async function startEmbeddedRelay() {
|
|
1049
|
+
const current = runtime.processes.get("relay");
|
|
1050
|
+
if (current && !current.exited) {
|
|
1051
|
+
return current;
|
|
1052
|
+
}
|
|
1053
|
+
runtime.relayQueues.clear();
|
|
1054
|
+
|
|
1055
|
+
const server = http.createServer(async (req, res) => {
|
|
1056
|
+
const method = req.method || "GET";
|
|
1057
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
1058
|
+
const pathname = url.pathname;
|
|
1059
|
+
|
|
1060
|
+
if (method === "GET" && pathname === "/healthz") {
|
|
1061
|
+
sendJson(res, 200, { ok: true, service: "embedded-local-relay" });
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
if (method === "POST" && pathname === "/v1/messages/send") {
|
|
1066
|
+
const body = await parseJsonBody(req);
|
|
1067
|
+
if (!body?.receiver || !body?.envelope) {
|
|
1068
|
+
sendError(res, 400, "receiver_and_envelope_required", "receiver and envelope are required");
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
relayQueueFor(body.receiver).push(body.envelope);
|
|
1072
|
+
sendJson(res, 201, {
|
|
1073
|
+
ok: true,
|
|
1074
|
+
queued: true,
|
|
1075
|
+
receiver: body.receiver,
|
|
1076
|
+
message_id: body.envelope.message_id || null
|
|
1077
|
+
});
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
if (method === "POST" && pathname === "/v1/messages/poll") {
|
|
1082
|
+
const body = await parseJsonBody(req);
|
|
1083
|
+
if (!body?.receiver) {
|
|
1084
|
+
sendError(res, 400, "receiver_required", "receiver is required");
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
sendJson(res, 200, {
|
|
1088
|
+
items: relayQueueFor(body.receiver).slice(0, Number(body.limit || 10))
|
|
1089
|
+
});
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
if (method === "POST" && pathname === "/v1/messages/ack") {
|
|
1094
|
+
const body = await parseJsonBody(req);
|
|
1095
|
+
if (!body?.receiver || !body?.message_id) {
|
|
1096
|
+
sendError(res, 400, "receiver_and_message_id_required", "receiver and message_id are required");
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
const queue = relayQueueFor(body.receiver);
|
|
1100
|
+
const index = queue.findIndex((item) => item?.message_id === body.message_id);
|
|
1101
|
+
if (index >= 0) {
|
|
1102
|
+
queue.splice(index, 1);
|
|
1103
|
+
}
|
|
1104
|
+
sendJson(res, 200, { acked: index >= 0 });
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
if (method === "GET" && pathname === "/v1/messages/peek") {
|
|
1109
|
+
const receiver = normalizedString(url.searchParams.get("receiver"));
|
|
1110
|
+
if (!receiver) {
|
|
1111
|
+
sendError(res, 400, "receiver_required", "receiver is required");
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
const threadId = normalizedString(url.searchParams.get("thread_id"));
|
|
1115
|
+
const items = relayQueueFor(receiver);
|
|
1116
|
+
sendJson(res, 200, {
|
|
1117
|
+
items: threadId ? items.filter((item) => item?.thread_id === threadId) : [...items]
|
|
1118
|
+
});
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
const healthMatch = pathname.match(/^\/v1\/receivers\/([^/]+)\/health$/);
|
|
1123
|
+
if (method === "GET" && healthMatch) {
|
|
1124
|
+
const receiver = decodeURIComponent(healthMatch[1]);
|
|
1125
|
+
sendJson(res, 200, {
|
|
1126
|
+
ok: true,
|
|
1127
|
+
receiver,
|
|
1128
|
+
queue_depth: relayQueueFor(receiver).length
|
|
1129
|
+
});
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
sendError(res, 404, "not_found", "no matching embedded relay route", { path: pathname });
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
await new Promise((resolve, reject) => {
|
|
1137
|
+
server.once("error", reject);
|
|
1138
|
+
server.listen(state.config.runtime.ports.relay, "127.0.0.1", resolve);
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
const processInfo = {
|
|
1142
|
+
name: "relay",
|
|
1143
|
+
child: null,
|
|
1144
|
+
pid: process.pid,
|
|
1145
|
+
logs: [],
|
|
1146
|
+
startedAt: nowIso(),
|
|
1147
|
+
launchMode: "embedded_local",
|
|
1148
|
+
exited: false,
|
|
1149
|
+
exitedAt: null,
|
|
1150
|
+
exitCode: null,
|
|
1151
|
+
lastError: null,
|
|
1152
|
+
close: async () => {
|
|
1153
|
+
if (processInfo.exited) {
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
await new Promise((resolve) => server.close(resolve));
|
|
1157
|
+
processInfo.exited = true;
|
|
1158
|
+
processInfo.exitedAt = nowIso();
|
|
1159
|
+
processInfo.exitCode = 0;
|
|
1160
|
+
}
|
|
1161
|
+
};
|
|
1162
|
+
|
|
1163
|
+
server.on("error", (error) => {
|
|
1164
|
+
processInfo.lastError = error instanceof Error ? error.message : "embedded_relay_error";
|
|
1165
|
+
appendSupervisorEvent({
|
|
1166
|
+
type: "service_error",
|
|
1167
|
+
service: "relay",
|
|
1168
|
+
message: processInfo.lastError
|
|
1169
|
+
});
|
|
1170
|
+
});
|
|
1171
|
+
server.on("close", () => {
|
|
1172
|
+
if (!processInfo.exited) {
|
|
1173
|
+
processInfo.exited = true;
|
|
1174
|
+
processInfo.exitedAt = nowIso();
|
|
1175
|
+
processInfo.exitCode = 0;
|
|
1176
|
+
}
|
|
1177
|
+
appendSupervisorEvent({
|
|
1178
|
+
type: "service_exit",
|
|
1179
|
+
service: "relay",
|
|
1180
|
+
exit_code: processInfo.exitCode
|
|
1181
|
+
});
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
runtime.processes.set("relay", processInfo);
|
|
1185
|
+
appendSupervisorEvent({
|
|
1186
|
+
type: "service_started",
|
|
1187
|
+
service: "relay",
|
|
1188
|
+
pid: process.pid,
|
|
1189
|
+
launch_mode: "embedded_local"
|
|
1190
|
+
});
|
|
1191
|
+
return processInfo;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
async function stopProcessInfo(processInfo) {
|
|
1195
|
+
if (!processInfo || processInfo.exited) {
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
if (typeof processInfo.close === "function") {
|
|
1199
|
+
await processInfo.close();
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
if (processInfo.child) {
|
|
1203
|
+
processInfo.child.kill();
|
|
1204
|
+
const deadline = Date.now() + 3000;
|
|
1205
|
+
while (!processInfo.exited && Date.now() < deadline) {
|
|
1206
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function serviceEnv(name) {
|
|
1212
|
+
const ports = state.config.runtime.ports;
|
|
1213
|
+
const runtimeTransport = getRuntimeTransport(state);
|
|
1214
|
+
const resolvedSecrets = getResolvedSecrets(state, runtime);
|
|
1215
|
+
const envWithSecrets = mergeEnvWithResolvedSecrets(state.env, resolvedSecrets);
|
|
1216
|
+
const relayBaseUrl =
|
|
1217
|
+
runtimeTransport.type === "relay_http"
|
|
1218
|
+
? runtimeTransport.relay_http.base_url
|
|
1219
|
+
: processBaseUrl(ports.relay);
|
|
1220
|
+
const transportEnv = buildTransportEnvUpdates(
|
|
1221
|
+
runtimeTransport.type === "local"
|
|
1222
|
+
? {
|
|
1223
|
+
...runtimeTransport,
|
|
1224
|
+
relay_http: { base_url: relayBaseUrl }
|
|
1225
|
+
}
|
|
1226
|
+
: runtimeTransport,
|
|
1227
|
+
envWithSecrets
|
|
1228
|
+
);
|
|
1229
|
+
const base = {
|
|
1230
|
+
...process.env,
|
|
1231
|
+
DELEXEC_HOME: process.env.DELEXEC_HOME || path.dirname(state.envFile),
|
|
1232
|
+
PLATFORM_API_BASE_URL: state.config.platform.base_url,
|
|
1233
|
+
CALLER_PLATFORM_API_KEY: resolvedSecrets.caller_api_key || "",
|
|
1234
|
+
PLATFORM_API_KEY: resolvedSecrets.caller_api_key || "",
|
|
1235
|
+
CALLER_CONTACT_EMAIL: state.config.caller.contact_email || "",
|
|
1236
|
+
CALLER_REGISTRATION_MODE: state.config.caller.registration_mode || "",
|
|
1237
|
+
RESPONDER_ID: state.config.responder.responder_id || "",
|
|
1238
|
+
RESPONDER_SIGNING_PUBLIC_KEY_PEM: state.env.RESPONDER_SIGNING_PUBLIC_KEY_PEM || "",
|
|
1239
|
+
RESPONDER_SIGNING_PRIVATE_KEY_PEM: state.env.RESPONDER_SIGNING_PRIVATE_KEY_PEM || "",
|
|
1240
|
+
HOTLINE_IDS: (state.config.responder.hotlines || []).map((item) => item.hotline_id).join(","),
|
|
1241
|
+
RESPONDER_PLATFORM_API_KEY: resolvedSecrets.responder_platform_api_key || "",
|
|
1242
|
+
TRANSPORT_BASE_URL: relayBaseUrl,
|
|
1243
|
+
TRANSPORT_TYPE: runtimeTransport.type,
|
|
1244
|
+
TRANSPORT_PROVIDER: transportEnv.TRANSPORT_PROVIDER || "",
|
|
1245
|
+
TRANSPORT_EMAIL_PROVIDER: transportEnv.TRANSPORT_EMAIL_PROVIDER || "",
|
|
1246
|
+
TRANSPORT_EMAIL_MODE: transportEnv.TRANSPORT_EMAIL_MODE || "",
|
|
1247
|
+
TRANSPORT_EMAIL_SENDER: transportEnv.TRANSPORT_EMAIL_SENDER || "",
|
|
1248
|
+
TRANSPORT_EMAIL_RECEIVER: transportEnv.TRANSPORT_EMAIL_RECEIVER || "",
|
|
1249
|
+
TRANSPORT_EMAIL_POLL_INTERVAL_MS: transportEnv.TRANSPORT_EMAIL_POLL_INTERVAL_MS || "",
|
|
1250
|
+
TRANSPORT_EMAILENGINE_BASE_URL: state.env.TRANSPORT_EMAILENGINE_BASE_URL || "",
|
|
1251
|
+
TRANSPORT_EMAILENGINE_ACCOUNT: state.env.TRANSPORT_EMAILENGINE_ACCOUNT || "",
|
|
1252
|
+
TRANSPORT_EMAILENGINE_ACCESS_TOKEN: resolvedSecrets.transport.emailengine.access_token || "",
|
|
1253
|
+
TRANSPORT_GMAIL_CLIENT_ID: state.env.TRANSPORT_GMAIL_CLIENT_ID || "",
|
|
1254
|
+
TRANSPORT_GMAIL_USER: state.env.TRANSPORT_GMAIL_USER || "",
|
|
1255
|
+
TRANSPORT_GMAIL_CLIENT_SECRET: resolvedSecrets.transport.gmail.client_secret || "",
|
|
1256
|
+
TRANSPORT_GMAIL_REFRESH_TOKEN: resolvedSecrets.transport.gmail.refresh_token || ""
|
|
1257
|
+
};
|
|
1258
|
+
|
|
1259
|
+
if (name === "relay") {
|
|
1260
|
+
return {
|
|
1261
|
+
...base,
|
|
1262
|
+
PORT: String(ports.relay),
|
|
1263
|
+
SERVICE_NAME: "transport-relay"
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
if (name === "caller") {
|
|
1267
|
+
return {
|
|
1268
|
+
...base,
|
|
1269
|
+
PORT: String(ports.caller),
|
|
1270
|
+
SERVICE_NAME: "caller-controller",
|
|
1271
|
+
PLATFORM_ENABLED: String(platformFeaturesEnabled(state)),
|
|
1272
|
+
TRANSPORT_RECEIVER: "caller-controller"
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
if (name === "skill-adapter") {
|
|
1276
|
+
return {
|
|
1277
|
+
...base,
|
|
1278
|
+
PORT: String(ports.skill_adapter || 8091),
|
|
1279
|
+
SERVICE_NAME: "caller-skill-adapter",
|
|
1280
|
+
PLATFORM_ENABLED: String(platformFeaturesEnabled(state)),
|
|
1281
|
+
CALLER_CONTROLLER_BASE_URL: processBaseUrl(ports.caller)
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
if (name === "mcp-adapter") {
|
|
1285
|
+
return {
|
|
1286
|
+
...base,
|
|
1287
|
+
PORT: String(ports.mcp_adapter || 8092),
|
|
1288
|
+
SERVICE_NAME: "caller-skill-mcp-adapter",
|
|
1289
|
+
MCP_ADAPTER_TRANSPORT: "http",
|
|
1290
|
+
CALLER_SKILL_BASE_URL: processBaseUrl(ports.skill_adapter || 8091)
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
return {
|
|
1294
|
+
...base,
|
|
1295
|
+
PORT: String(ports.responder),
|
|
1296
|
+
SERVICE_NAME: "responder-controller",
|
|
1297
|
+
TRANSPORT_RECEIVER: state.config.responder.responder_id || "responder-controller"
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function resolveWorkspaceServiceEntry(sourceRelativePath, packageName) {
|
|
1302
|
+
const sourceEntry = path.resolve(__dirname, sourceRelativePath);
|
|
1303
|
+
if (fs.existsSync(sourceEntry)) {
|
|
1304
|
+
return sourceEntry;
|
|
1305
|
+
}
|
|
1306
|
+
return require.resolve(packageName);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
function serviceEntry(name) {
|
|
1310
|
+
if (name === "caller") {
|
|
1311
|
+
return resolveWorkspaceServiceEntry("../../caller-controller/src/server.js", "@delexec/caller-controller");
|
|
1312
|
+
}
|
|
1313
|
+
if (name === "skill-adapter") {
|
|
1314
|
+
return resolveWorkspaceServiceEntry("../../caller-skill-adapter/src/server.js", "@delexec/caller-skill-adapter");
|
|
1315
|
+
}
|
|
1316
|
+
if (name === "mcp-adapter") {
|
|
1317
|
+
return resolveWorkspaceServiceEntry("../../caller-skill-mcp-adapter/src/server.js", "@delexec/caller-skill-mcp-adapter");
|
|
1318
|
+
}
|
|
1319
|
+
return resolveWorkspaceServiceEntry("../../responder-controller/src/server.js", "@delexec/responder-controller");
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function buildMcpAdapterSpec() {
|
|
1323
|
+
const callerSkillBaseUrl = processBaseUrl(state.config.runtime.ports.skill_adapter || 8091);
|
|
1324
|
+
const mcpEntry = resolveWorkspaceServiceEntry("../../caller-skill-mcp-adapter/src/server.js", "@delexec/caller-skill-mcp-adapter");
|
|
1325
|
+
const httpBaseUrl = processBaseUrl(state.config.runtime.ports.mcp_adapter || 8092);
|
|
1326
|
+
return {
|
|
1327
|
+
mode: "multi_transport",
|
|
1328
|
+
available: true,
|
|
1329
|
+
recommended_for: ["codex", "cursor", "claude-code"],
|
|
1330
|
+
preferred_transport: "streamable_http",
|
|
1331
|
+
stdio: {
|
|
1332
|
+
mode: "stdio",
|
|
1333
|
+
command: process.execPath,
|
|
1334
|
+
args: [mcpEntry],
|
|
1335
|
+
env: {
|
|
1336
|
+
CALLER_SKILL_BASE_URL: callerSkillBaseUrl
|
|
1337
|
+
}
|
|
1338
|
+
},
|
|
1339
|
+
streamable_http: {
|
|
1340
|
+
mode: "streamable_http",
|
|
1341
|
+
url: appendPath(httpBaseUrl, "/mcp"),
|
|
1342
|
+
health_url: appendPath(httpBaseUrl, "/healthz")
|
|
1343
|
+
},
|
|
1344
|
+
entry_file: mcpEntry,
|
|
1345
|
+
caller_skill_base_url: callerSkillBaseUrl,
|
|
1346
|
+
base_url: httpBaseUrl
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function serviceLaunchSpec(name) {
|
|
1351
|
+
if (name === "relay") {
|
|
1352
|
+
return relayLaunchSpec();
|
|
1353
|
+
}
|
|
1354
|
+
return {
|
|
1355
|
+
command: process.execPath,
|
|
1356
|
+
args: [serviceEntry(name)],
|
|
1357
|
+
mode: "node_entry"
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
function captureLog(processInfo, stream, chunk) {
|
|
1362
|
+
const ts = new Date().toTimeString().slice(0, 8); // HH:mm:ss
|
|
1363
|
+
const lines = chunk.toString("utf8").split(/\r?\n/);
|
|
1364
|
+
for (const raw of lines) {
|
|
1365
|
+
const line = raw.trimEnd();
|
|
1366
|
+
if (!line) continue;
|
|
1367
|
+
const stamped = `${ts} ${line}`;
|
|
1368
|
+
processInfo.logs.push(stamped);
|
|
1369
|
+
if (processInfo.logs.length > 200) processInfo.logs.shift();
|
|
1370
|
+
appendServiceLog(processInfo.name, `${stamped}\n`);
|
|
1371
|
+
const mirrored = `[${processInfo.name}] ${stamped}\n`;
|
|
1372
|
+
if (stream === "stderr") {
|
|
1373
|
+
process.stderr.write(mirrored);
|
|
1374
|
+
} else {
|
|
1375
|
+
process.stdout.write(mirrored);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
async function ensureService(name) {
|
|
1381
|
+
const current = runtime.processes.get(name);
|
|
1382
|
+
if (current && !current.exited) {
|
|
1383
|
+
return current;
|
|
1384
|
+
}
|
|
1385
|
+
const inflight = runtime.starting.get(name);
|
|
1386
|
+
if (inflight) {
|
|
1387
|
+
return inflight;
|
|
1388
|
+
}
|
|
1389
|
+
const startPromise = (async () => {
|
|
1390
|
+
if (name === "relay" && shouldUseEmbeddedRelay()) {
|
|
1391
|
+
return startEmbeddedRelay();
|
|
1392
|
+
}
|
|
1393
|
+
const ports = state.config.runtime.ports;
|
|
1394
|
+
const portMap = {
|
|
1395
|
+
caller: ports.caller,
|
|
1396
|
+
responder: ports.responder,
|
|
1397
|
+
relay: ports.relay,
|
|
1398
|
+
"skill-adapter": ports.skill_adapter || 8091,
|
|
1399
|
+
"mcp-adapter": ports.mcp_adapter || 8092
|
|
1400
|
+
};
|
|
1401
|
+
// Kill any orphaned process holding the port before starting
|
|
1402
|
+
const targetPort = portMap[name];
|
|
1403
|
+
if (targetPort) {
|
|
1404
|
+
await new Promise((resolve) => {
|
|
1405
|
+
const killer = spawn(process.execPath, [
|
|
1406
|
+
"-e",
|
|
1407
|
+
`const { execSync } = require("child_process");
|
|
1408
|
+
try {
|
|
1409
|
+
const out = execSync("lsof -ti:${targetPort} 2>/dev/null", { encoding: "utf8" }).trim();
|
|
1410
|
+
if (out) { out.split("\\n").forEach(pid => { try { process.kill(Number(pid), "SIGKILL"); } catch(_) {} }); }
|
|
1411
|
+
} catch(_) {}
|
|
1412
|
+
`
|
|
1413
|
+
]);
|
|
1414
|
+
killer.on("exit", resolve);
|
|
1415
|
+
setTimeout(resolve, 2000);
|
|
1416
|
+
});
|
|
1417
|
+
// Brief pause to let OS release the port
|
|
1418
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
1419
|
+
}
|
|
1420
|
+
const launch = serviceLaunchSpec(name);
|
|
1421
|
+
const child = spawn(launch.command, launch.args, {
|
|
1422
|
+
env: serviceEnv(name),
|
|
1423
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1424
|
+
});
|
|
1425
|
+
const processInfo = {
|
|
1426
|
+
name,
|
|
1427
|
+
child,
|
|
1428
|
+
logs: [],
|
|
1429
|
+
startedAt: nowIso(),
|
|
1430
|
+
launchMode: launch.mode,
|
|
1431
|
+
exited: false,
|
|
1432
|
+
exitedAt: null,
|
|
1433
|
+
exitCode: null,
|
|
1434
|
+
lastError: null
|
|
1435
|
+
};
|
|
1436
|
+
child.stdout.on("data", (chunk) => captureLog(processInfo, "stdout", chunk.toString("utf8")));
|
|
1437
|
+
child.stderr.on("data", (chunk) => captureLog(processInfo, "stderr", chunk.toString("utf8")));
|
|
1438
|
+
child.on("error", (error) => {
|
|
1439
|
+
processInfo.lastError = error instanceof Error ? error.message : "unknown_error";
|
|
1440
|
+
appendSupervisorEvent({
|
|
1441
|
+
type: "service_error",
|
|
1442
|
+
service: name,
|
|
1443
|
+
message: processInfo.lastError
|
|
1444
|
+
});
|
|
1445
|
+
});
|
|
1446
|
+
child.on("exit", (code) => {
|
|
1447
|
+
processInfo.exited = true;
|
|
1448
|
+
processInfo.exitedAt = nowIso();
|
|
1449
|
+
processInfo.exitCode = code;
|
|
1450
|
+
appendSupervisorEvent({
|
|
1451
|
+
type: "service_exit",
|
|
1452
|
+
service: name,
|
|
1453
|
+
exit_code: code
|
|
1454
|
+
});
|
|
1455
|
+
});
|
|
1456
|
+
runtime.processes.set(name, processInfo);
|
|
1457
|
+
appendSupervisorEvent({
|
|
1458
|
+
type: "service_started",
|
|
1459
|
+
service: name,
|
|
1460
|
+
pid: child.pid
|
|
1461
|
+
});
|
|
1462
|
+
return processInfo;
|
|
1463
|
+
})();
|
|
1464
|
+
runtime.starting.set(name, startPromise);
|
|
1465
|
+
try {
|
|
1466
|
+
return await startPromise;
|
|
1467
|
+
} finally {
|
|
1468
|
+
if (runtime.starting.get(name) === startPromise) {
|
|
1469
|
+
runtime.starting.delete(name);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
async function waitForRelay(maxWaitMs = 8000) {
|
|
1475
|
+
const relayUrl = `http://127.0.0.1:${state.config.runtime.ports?.relay || 8090}`;
|
|
1476
|
+
const start = Date.now();
|
|
1477
|
+
while (Date.now() - start < maxWaitMs) {
|
|
1478
|
+
try {
|
|
1479
|
+
const res = await fetch(`${relayUrl}/healthz`);
|
|
1480
|
+
if (res.ok) return;
|
|
1481
|
+
} catch {
|
|
1482
|
+
// not ready yet
|
|
1483
|
+
}
|
|
1484
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
async function waitForServiceHealth(name, maxWaitMs = 8000) {
|
|
1489
|
+
const start = Date.now();
|
|
1490
|
+
while (Date.now() - start < maxWaitMs) {
|
|
1491
|
+
const health = await fetchHealth(name);
|
|
1492
|
+
if (health?.status === 200) {
|
|
1493
|
+
return health;
|
|
1494
|
+
}
|
|
1495
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1496
|
+
}
|
|
1497
|
+
return null;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
async function ensureBaseServices() {
|
|
1501
|
+
if (usesManagedRelay()) {
|
|
1502
|
+
await ensureService("relay");
|
|
1503
|
+
if (shouldUseEmbeddedRelay()) {
|
|
1504
|
+
await waitForServiceHealth("relay");
|
|
1505
|
+
} else {
|
|
1506
|
+
await waitForRelay();
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
await ensureService("caller");
|
|
1510
|
+
await waitForServiceHealth("caller");
|
|
1511
|
+
await ensureService("skill-adapter");
|
|
1512
|
+
await waitForServiceHealth("skill-adapter");
|
|
1513
|
+
await ensureService("mcp-adapter");
|
|
1514
|
+
await waitForServiceHealth("mcp-adapter");
|
|
1515
|
+
if (state.config.responder.enabled) {
|
|
1516
|
+
await ensureService("responder");
|
|
1517
|
+
await waitForServiceHealth("responder");
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
async function prepareCallConfirmation(body = {}) {
|
|
1522
|
+
await ensureBaseServices();
|
|
1523
|
+
const taskType = normalizedString(body.task_type);
|
|
1524
|
+
const hotlineId = normalizedString(body.hotline_id);
|
|
1525
|
+
const responderId = normalizedString(body.responder_id);
|
|
1526
|
+
const capability = normalizedString(body.capability);
|
|
1527
|
+
const preference = getTaskTypePreference(state, taskType);
|
|
1528
|
+
|
|
1529
|
+
const items = await fetchCatalogCandidates(state, runtime, {
|
|
1530
|
+
task_type: taskType,
|
|
1531
|
+
hotline_id: hotlineId,
|
|
1532
|
+
responder_id: responderId,
|
|
1533
|
+
capability
|
|
1534
|
+
});
|
|
1535
|
+
|
|
1536
|
+
const candidates = items
|
|
1537
|
+
.map((item) => ({
|
|
1538
|
+
raw: item,
|
|
1539
|
+
score: scoreCandidate(item, {
|
|
1540
|
+
taskType,
|
|
1541
|
+
hotlineId,
|
|
1542
|
+
responderId,
|
|
1543
|
+
preferred: preference
|
|
1544
|
+
})
|
|
1545
|
+
}))
|
|
1546
|
+
.sort((left, right) => right.score - left.score)
|
|
1547
|
+
.slice(0, 5);
|
|
1548
|
+
|
|
1549
|
+
if (candidates.length === 0) {
|
|
1550
|
+
return {
|
|
1551
|
+
status: 404,
|
|
1552
|
+
body: buildStructuredError("HOTLINE_CANDIDATES_NOT_FOUND", "no visible hotline candidates matched the current request", {
|
|
1553
|
+
task_type: taskType,
|
|
1554
|
+
responder_id: responderId,
|
|
1555
|
+
hotline_id: hotlineId
|
|
1556
|
+
})
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
const selected = candidates[0].raw;
|
|
1561
|
+
const selectedSummary = summarizeCandidate(selected, {
|
|
1562
|
+
selected: true,
|
|
1563
|
+
taskType,
|
|
1564
|
+
preferred: Boolean(preference && selected.hotline_id === preference.hotline_id)
|
|
1565
|
+
});
|
|
1566
|
+
|
|
1567
|
+
return {
|
|
1568
|
+
status: 200,
|
|
1569
|
+
body: {
|
|
1570
|
+
task_type: taskType,
|
|
1571
|
+
always_ask: true,
|
|
1572
|
+
remembered_preference: preference,
|
|
1573
|
+
selection_reason: selectedSummary.match_reasons.join(" · "),
|
|
1574
|
+
selected_hotline: selectedSummary,
|
|
1575
|
+
candidate_hotlines: candidates.map(({ raw }) =>
|
|
1576
|
+
summarizeCandidate(raw, {
|
|
1577
|
+
selected: raw.hotline_id === selected.hotline_id && raw.responder_id === selected.responder_id,
|
|
1578
|
+
taskType,
|
|
1579
|
+
preferred: Boolean(preference && raw.hotline_id === preference.hotline_id)
|
|
1580
|
+
})
|
|
1581
|
+
)
|
|
1582
|
+
}
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
async function confirmPreparedCall(body = {}) {
|
|
1587
|
+
await ensureBaseServices();
|
|
1588
|
+
const chosenHotlineId = normalizedString(body.hotline_id);
|
|
1589
|
+
const chosenResponderId = normalizedString(body.responder_id);
|
|
1590
|
+
const taskType = normalizedString(body.task_type);
|
|
1591
|
+
if (!chosenHotlineId || !chosenResponderId || !taskType) {
|
|
1592
|
+
return {
|
|
1593
|
+
status: 400,
|
|
1594
|
+
body: buildStructuredError(
|
|
1595
|
+
"CONTRACT_INVALID_CONFIRM_BODY",
|
|
1596
|
+
"hotline_id, responder_id, and task_type are required for call confirmation"
|
|
1597
|
+
)
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
const candidates = await fetchCatalogCandidates(state, runtime, {
|
|
1602
|
+
hotline_id: chosenHotlineId,
|
|
1603
|
+
responder_id: chosenResponderId,
|
|
1604
|
+
task_type: taskType
|
|
1605
|
+
});
|
|
1606
|
+
const selected = candidates.find((item) => item.hotline_id === chosenHotlineId && item.responder_id === chosenResponderId);
|
|
1607
|
+
if (!selected) {
|
|
1608
|
+
return {
|
|
1609
|
+
status: 409,
|
|
1610
|
+
body: buildStructuredError("HOTLINE_NO_LONGER_VISIBLE", "chosen hotline is no longer visible for this caller", {
|
|
1611
|
+
hotline_id: chosenHotlineId,
|
|
1612
|
+
responder_id: chosenResponderId
|
|
1613
|
+
})
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
if (body.remember_for_task_type === true) {
|
|
1618
|
+
setTaskTypePreference(state, taskType, {
|
|
1619
|
+
hotline_id: chosenHotlineId,
|
|
1620
|
+
responder_id: chosenResponderId
|
|
1621
|
+
});
|
|
1622
|
+
state.env = saveOpsState(state);
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
return requestJson(processBaseUrl(state.config.runtime.ports.caller), "/controller/remote-requests", {
|
|
1626
|
+
method: "POST",
|
|
1627
|
+
headers: buildPlatformHeaders(state, runtime),
|
|
1628
|
+
body: {
|
|
1629
|
+
responder_id: chosenResponderId,
|
|
1630
|
+
hotline_id: chosenHotlineId,
|
|
1631
|
+
task_type: taskType,
|
|
1632
|
+
input: body.input || { text: normalizedString(body.text) || "" },
|
|
1633
|
+
payload: body.payload || { text: normalizedString(body.text) || "" },
|
|
1634
|
+
output_schema: body.output_schema || {
|
|
1635
|
+
type: "object",
|
|
1636
|
+
properties: {
|
|
1637
|
+
summary: { type: "string" }
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
async function reloadResponderIfRunning() {
|
|
1645
|
+
if (!state.config.responder.enabled) {
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
const processInfo = runtime.processes.get("responder");
|
|
1649
|
+
if (processInfo && !processInfo.exited) {
|
|
1650
|
+
await stopProcessInfo(processInfo);
|
|
1651
|
+
}
|
|
1652
|
+
await ensureService("responder");
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
async function fetchHealth(name) {
|
|
1656
|
+
const portKey = name === "skill-adapter" ? "skill_adapter" : name === "mcp-adapter" ? "mcp_adapter" : name;
|
|
1657
|
+
const port = state.config.runtime.ports[portKey];
|
|
1658
|
+
if (name === "relay" && !usesManagedRelay()) {
|
|
1659
|
+
const runtimeTransport = getRuntimeTransport(state);
|
|
1660
|
+
if (runtimeTransport.type !== "relay_http") {
|
|
1661
|
+
return null;
|
|
1662
|
+
}
|
|
1663
|
+
try {
|
|
1664
|
+
return await requestJson(runtimeTransport.relay_http.base_url, "/healthz");
|
|
1665
|
+
} catch (error) {
|
|
1666
|
+
return { status: 503, body: { ok: false, error: error instanceof Error ? error.message : "unknown_error" } };
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
try {
|
|
1670
|
+
return await requestJson(processBaseUrl(port), "/healthz");
|
|
1671
|
+
} catch (error) {
|
|
1672
|
+
return { status: 503, body: { ok: false, error: error instanceof Error ? error.message : "unknown_error" } };
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
async function fetchRecentRequestsSummary() {
|
|
1677
|
+
try {
|
|
1678
|
+
const response = await requestJson(processBaseUrl(state.config.runtime.ports.caller), "/controller/requests");
|
|
1679
|
+
const items = response.body?.items || [];
|
|
1680
|
+
const byStatus = items.reduce((summary, item) => {
|
|
1681
|
+
const key = item.status || "UNKNOWN";
|
|
1682
|
+
summary[key] = (summary[key] || 0) + 1;
|
|
1683
|
+
return summary;
|
|
1684
|
+
}, {});
|
|
1685
|
+
return {
|
|
1686
|
+
total: items.length,
|
|
1687
|
+
by_status: byStatus,
|
|
1688
|
+
latest: items.slice(0, 5).map((item) => ({
|
|
1689
|
+
request_id: item.request_id,
|
|
1690
|
+
status: item.status,
|
|
1691
|
+
updated_at: item.updated_at || item.created_at || null
|
|
1692
|
+
}))
|
|
1693
|
+
};
|
|
1694
|
+
} catch {
|
|
1695
|
+
return {
|
|
1696
|
+
total: 0,
|
|
1697
|
+
by_status: {},
|
|
1698
|
+
latest: []
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
async function buildStatus() {
|
|
1704
|
+
await syncResponderReviewStatusesFromPlatform();
|
|
1705
|
+
const hotlines = state.config.responder.hotlines || [];
|
|
1706
|
+
const secrets = getResolvedSecrets(state, runtime);
|
|
1707
|
+
const runtimeTransport = getRuntimeTransport(state);
|
|
1708
|
+
const pendingReviewCount = platformFeaturesEnabled(state)
|
|
1709
|
+
? hotlines.filter((item) => item.submitted_for_review !== true).length
|
|
1710
|
+
: 0;
|
|
1711
|
+
const reviewStatusCounts = hotlines.reduce((counts, item) => {
|
|
1712
|
+
const key = item.review_status || "local_only";
|
|
1713
|
+
counts[key] = (counts[key] || 0) + 1;
|
|
1714
|
+
return counts;
|
|
1715
|
+
}, {});
|
|
1716
|
+
state.config.caller.api_key_configured = Boolean(secrets.caller_api_key);
|
|
1717
|
+
state.config.caller.registration_mode ||= state.config.caller.api_key_configured ? "platform" : null;
|
|
1718
|
+
state.config.platform_console ||= {};
|
|
1719
|
+
state.config.platform_console.admin_api_key_configured = Boolean(secrets.platform_admin_api_key);
|
|
1720
|
+
return {
|
|
1721
|
+
ok: true,
|
|
1722
|
+
config: state.config,
|
|
1723
|
+
auth: getAuthState(runtime, state),
|
|
1724
|
+
debug: {
|
|
1725
|
+
logs_dir: path.join(path.dirname(state.envFile), "logs"),
|
|
1726
|
+
event_log: getSupervisorEventsFile(),
|
|
1727
|
+
service_logs: {
|
|
1728
|
+
relay: getServiceLogFile("relay"),
|
|
1729
|
+
caller: getServiceLogFile("caller"),
|
|
1730
|
+
skill_adapter: getServiceLogFile("skill-adapter"),
|
|
1731
|
+
mcp_adapter: getServiceLogFile("mcp-adapter"),
|
|
1732
|
+
responder: getServiceLogFile("responder")
|
|
1733
|
+
}
|
|
1734
|
+
},
|
|
1735
|
+
responder: {
|
|
1736
|
+
enabled: state.config.responder.enabled,
|
|
1737
|
+
responder_id: state.config.responder.responder_id,
|
|
1738
|
+
display_name: state.config.responder.display_name,
|
|
1739
|
+
hotline_count: hotlines.length,
|
|
1740
|
+
pending_review_count: pendingReviewCount,
|
|
1741
|
+
review_summary: reviewStatusCounts,
|
|
1742
|
+
platform_enabled: platformFeaturesEnabled(state)
|
|
1743
|
+
},
|
|
1744
|
+
caller: {
|
|
1745
|
+
enabled: state.config.caller.enabled !== false,
|
|
1746
|
+
registered: state.config.caller.registration_mode === "local_only" || state.config.caller.api_key_configured === true,
|
|
1747
|
+
registration_mode: state.config.caller.registration_mode || null,
|
|
1748
|
+
contact_email: state.config.caller.contact_email || null,
|
|
1749
|
+
api_key_configured: state.config.caller.api_key_configured === true,
|
|
1750
|
+
platform_enabled: platformFeaturesEnabled(state)
|
|
1751
|
+
},
|
|
1752
|
+
requests: await fetchRecentRequestsSummary(),
|
|
1753
|
+
runtime: {
|
|
1754
|
+
supervisor: {
|
|
1755
|
+
port: state.config.runtime.ports.supervisor
|
|
1756
|
+
},
|
|
1757
|
+
relay: {
|
|
1758
|
+
...getRuntimeStatus("relay"),
|
|
1759
|
+
managed: usesManagedRelay(),
|
|
1760
|
+
transport_type: runtimeTransport.type,
|
|
1761
|
+
base_url: runtimeTransport.type === "relay_http" ? runtimeTransport.relay_http.base_url : processBaseUrl(state.config.runtime.ports.relay),
|
|
1762
|
+
health: await fetchHealth("relay")
|
|
1763
|
+
},
|
|
1764
|
+
caller: {
|
|
1765
|
+
...getRuntimeStatus("caller"),
|
|
1766
|
+
health: await fetchHealth("caller")
|
|
1767
|
+
},
|
|
1768
|
+
skill_adapter: {
|
|
1769
|
+
...getRuntimeStatus("skill-adapter"),
|
|
1770
|
+
health: await fetchHealth("skill-adapter")
|
|
1771
|
+
},
|
|
1772
|
+
mcp_adapter: {
|
|
1773
|
+
...getRuntimeStatus("mcp-adapter"),
|
|
1774
|
+
health: await fetchHealth("mcp-adapter"),
|
|
1775
|
+
spec: buildMcpAdapterSpec()
|
|
1776
|
+
},
|
|
1777
|
+
responder: {
|
|
1778
|
+
...getRuntimeStatus("responder"),
|
|
1779
|
+
health: state.config.responder.enabled ? await fetchHealth("responder") : null
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
async function buildExampleDiagnostics() {
|
|
1786
|
+
const status = await buildStatus();
|
|
1787
|
+
const uiPort = Number(process.env.DELEXEC_OPS_UI_PORT || 4173);
|
|
1788
|
+
const serviceCheck = (label, service) => {
|
|
1789
|
+
const running = service?.running === true;
|
|
1790
|
+
const healthy = service?.health?.status === 200 || service?.health?.body?.ok === true;
|
|
1791
|
+
return {
|
|
1792
|
+
name: label,
|
|
1793
|
+
status: healthy ? "ok" : running ? "warn" : "fail",
|
|
1794
|
+
detail: healthy
|
|
1795
|
+
? `${label} health check is passing.`
|
|
1796
|
+
: running
|
|
1797
|
+
? `${label} process is running but health has not reported ok yet.`
|
|
1798
|
+
: `${label} process is not running.`
|
|
1799
|
+
};
|
|
1800
|
+
};
|
|
1801
|
+
|
|
1802
|
+
const callerRegistered = status.caller?.registered === true;
|
|
1803
|
+
const responderEnabled = status.responder?.enabled === true;
|
|
1804
|
+
const localExample = (state.config.responder.hotlines || []).find((item) => item.hotline_id === LOCAL_EXAMPLE_HOTLINE_ID);
|
|
1805
|
+
|
|
1806
|
+
return {
|
|
1807
|
+
generated_at: nowIso(),
|
|
1808
|
+
checks: [
|
|
1809
|
+
{
|
|
1810
|
+
name: "caller_registration",
|
|
1811
|
+
status: callerRegistered ? "ok" : "fail",
|
|
1812
|
+
detail: callerRegistered
|
|
1813
|
+
? `Caller is registered in ${status.caller.registration_mode || "configured"} mode.`
|
|
1814
|
+
: "Caller is not registered yet; run bootstrap or auth register before sending calls."
|
|
1815
|
+
},
|
|
1816
|
+
{
|
|
1817
|
+
name: "local_example_hotline",
|
|
1818
|
+
status: localExample?.enabled === false ? "fail" : localExample ? "ok" : "fail",
|
|
1819
|
+
detail: localExample
|
|
1820
|
+
? `${LOCAL_EXAMPLE_DISPLAY_NAME} is configured locally.`
|
|
1821
|
+
: `${LOCAL_EXAMPLE_DISPLAY_NAME} is not configured locally; add the official example first.`
|
|
1822
|
+
},
|
|
1823
|
+
{
|
|
1824
|
+
name: "responder_enabled",
|
|
1825
|
+
status: responderEnabled ? "ok" : "fail",
|
|
1826
|
+
detail: responderEnabled ? "Local responder is enabled." : "Local responder is disabled."
|
|
1827
|
+
},
|
|
1828
|
+
serviceCheck("relay", status.runtime?.relay),
|
|
1829
|
+
serviceCheck("caller", status.runtime?.caller),
|
|
1830
|
+
serviceCheck("responder", status.runtime?.responder),
|
|
1831
|
+
serviceCheck("skill_adapter", status.runtime?.skill_adapter),
|
|
1832
|
+
serviceCheck("mcp_adapter", status.runtime?.mcp_adapter)
|
|
1833
|
+
],
|
|
1834
|
+
links: [
|
|
1835
|
+
{ label: "Ops Console", url: processBaseUrl(uiPort) },
|
|
1836
|
+
{ label: "Runtime", url: appendPath(processBaseUrl(uiPort), "/general/runtime") },
|
|
1837
|
+
{ label: "Calls", url: appendPath(processBaseUrl(uiPort), "/caller/calls") },
|
|
1838
|
+
{ label: "Catalog", url: appendPath(processBaseUrl(uiPort), "/caller/catalog") },
|
|
1839
|
+
{ label: "Supervisor Health", url: appendPath(processBaseUrl(state.config.runtime.ports.supervisor), "/healthz") }
|
|
1840
|
+
],
|
|
1841
|
+
next_steps: [
|
|
1842
|
+
"Run delexec-ops status to inspect the local runtime.",
|
|
1843
|
+
"Open Calls after this request finishes to inspect the result package.",
|
|
1844
|
+
"Run delexec-ops debug-snapshot if any check is warn or fail."
|
|
1845
|
+
]
|
|
1846
|
+
};
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
function buildRuntimeAlerts(service, { maxItems = 20 } = {}) {
|
|
1850
|
+
const events = readSupervisorEventTail({ maxLines: 200 })
|
|
1851
|
+
.filter((event) => {
|
|
1852
|
+
if (service === "supervisor") {
|
|
1853
|
+
return true;
|
|
1854
|
+
}
|
|
1855
|
+
return event.service === service;
|
|
1856
|
+
})
|
|
1857
|
+
.flatMap((event) => {
|
|
1858
|
+
if (event.type === "service_error") {
|
|
1859
|
+
return [
|
|
1860
|
+
{
|
|
1861
|
+
at: event.at,
|
|
1862
|
+
service: event.service,
|
|
1863
|
+
severity: "error",
|
|
1864
|
+
source: "event",
|
|
1865
|
+
message: event.message || "service_error"
|
|
1866
|
+
}
|
|
1867
|
+
];
|
|
1868
|
+
}
|
|
1869
|
+
if (event.type === "service_exit" && event.exit_code !== 0 && event.exit_code !== null) {
|
|
1870
|
+
return [
|
|
1871
|
+
{
|
|
1872
|
+
at: event.at,
|
|
1873
|
+
service: event.service,
|
|
1874
|
+
severity: "error",
|
|
1875
|
+
source: "event",
|
|
1876
|
+
message: `service exited with code ${event.exit_code}`
|
|
1877
|
+
}
|
|
1878
|
+
];
|
|
1879
|
+
}
|
|
1880
|
+
return [];
|
|
1881
|
+
});
|
|
1882
|
+
|
|
1883
|
+
const logAlerts = (service === "supervisor" ? [] : readServiceLogTail(service, { maxLines: 200 }))
|
|
1884
|
+
.flatMap((line) => {
|
|
1885
|
+
const severity = logSeverity(line);
|
|
1886
|
+
if (!severity) {
|
|
1887
|
+
return [];
|
|
1888
|
+
}
|
|
1889
|
+
return [
|
|
1890
|
+
{
|
|
1891
|
+
at: null,
|
|
1892
|
+
service,
|
|
1893
|
+
severity,
|
|
1894
|
+
source: "log",
|
|
1895
|
+
message: line.trim()
|
|
1896
|
+
}
|
|
1897
|
+
];
|
|
1898
|
+
});
|
|
1899
|
+
|
|
1900
|
+
return [...events, ...logAlerts].slice(-maxItems).reverse();
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
async function registerCaller(contactEmail, { localOnly = false, forcePlatform = false } = {}) {
|
|
1904
|
+
if (!forcePlatform && (localOnly || !platformFeaturesEnabled(state))) {
|
|
1905
|
+
const nextEmail = normalizedString(contactEmail) || state.config.caller.contact_email || null;
|
|
1906
|
+
state.config.caller.contact_email = nextEmail;
|
|
1907
|
+
state.config.caller.registration_mode = "local_only";
|
|
1908
|
+
state.config.caller.api_key = null;
|
|
1909
|
+
state.config.caller.api_key_configured = false;
|
|
1910
|
+
state.env = saveOpsState(state);
|
|
1911
|
+
return {
|
|
1912
|
+
status: 201,
|
|
1913
|
+
body: {
|
|
1914
|
+
ok: true,
|
|
1915
|
+
registered: true,
|
|
1916
|
+
mode: "local_only",
|
|
1917
|
+
contact_email: nextEmail
|
|
1918
|
+
}
|
|
1919
|
+
};
|
|
1920
|
+
}
|
|
1921
|
+
const response = await requestJson(state.config.platform.base_url, "/v1/users/register", {
|
|
1922
|
+
method: "POST",
|
|
1923
|
+
body: {
|
|
1924
|
+
contact_email: contactEmail
|
|
1925
|
+
}
|
|
1926
|
+
});
|
|
1927
|
+
if (response.status !== 201) {
|
|
1928
|
+
return response;
|
|
1929
|
+
}
|
|
1930
|
+
state.config.caller.contact_email = response.body.contact_email || contactEmail;
|
|
1931
|
+
state.config.caller.registration_mode = "platform";
|
|
1932
|
+
state.config.caller.api_key_configured = true;
|
|
1933
|
+
state.config.platform.enabled = true;
|
|
1934
|
+
if (hasEncryptedSecretStore()) {
|
|
1935
|
+
writeOpsSecrets(runtime.auth.passphrase, {
|
|
1936
|
+
[OPS_SECRET_KEYS.caller_api_key]: response.body.api_key
|
|
1937
|
+
});
|
|
1938
|
+
runtime.auth.unlockedSecrets = unlockOpsSecrets(runtime.auth.passphrase);
|
|
1939
|
+
scrubLegacySecrets(state);
|
|
1940
|
+
} else {
|
|
1941
|
+
state.env = saveOpsState({
|
|
1942
|
+
...state,
|
|
1943
|
+
env: {
|
|
1944
|
+
...state.env,
|
|
1945
|
+
CALLER_PLATFORM_API_KEY: response.body.api_key,
|
|
1946
|
+
PLATFORM_API_KEY: response.body.api_key
|
|
1947
|
+
}
|
|
1948
|
+
});
|
|
1949
|
+
}
|
|
1950
|
+
state.env = saveOpsState(state);
|
|
1951
|
+
return response;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
function buildResponderRegisterHeaders() {
|
|
1955
|
+
const secrets = getResolvedSecrets(state, runtime);
|
|
1956
|
+
const apiKey = secrets.caller_api_key || secrets.responder_platform_api_key;
|
|
1957
|
+
if (!apiKey) {
|
|
1958
|
+
throw new Error("caller_platform_api_key_required");
|
|
1959
|
+
}
|
|
1960
|
+
return { Authorization: `Bearer ${apiKey}` };
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
function buildPlatformReadHeaders() {
|
|
1964
|
+
const secrets = getResolvedSecrets(state, runtime);
|
|
1965
|
+
const apiKey = secrets.platform_admin_api_key || secrets.caller_api_key || secrets.responder_platform_api_key;
|
|
1966
|
+
return apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
async function syncResponderReviewStatusesFromPlatform() {
|
|
1970
|
+
if (!platformFeaturesEnabled(state)) {
|
|
1971
|
+
return false;
|
|
1972
|
+
}
|
|
1973
|
+
const hotlines = state.config.responder.hotlines || [];
|
|
1974
|
+
const submitted = hotlines.filter((item) => item.submitted_for_review === true);
|
|
1975
|
+
if (submitted.length === 0) {
|
|
1976
|
+
return false;
|
|
1977
|
+
}
|
|
1978
|
+
const headers = buildPlatformReadHeaders();
|
|
1979
|
+
let changed = false;
|
|
1980
|
+
for (const item of submitted) {
|
|
1981
|
+
let response;
|
|
1982
|
+
try {
|
|
1983
|
+
response = await requestJson(
|
|
1984
|
+
state.config.platform.base_url,
|
|
1985
|
+
`/v1/catalog/hotlines/${encodeURIComponent(item.hotline_id)}`,
|
|
1986
|
+
{ headers }
|
|
1987
|
+
);
|
|
1988
|
+
} catch {
|
|
1989
|
+
continue;
|
|
1990
|
+
}
|
|
1991
|
+
if (response.status !== 200 || !response.body) {
|
|
1992
|
+
continue;
|
|
1993
|
+
}
|
|
1994
|
+
const nextReviewStatus = response.body.review_status || item.review_status || "pending";
|
|
1995
|
+
if (item.review_status !== nextReviewStatus) {
|
|
1996
|
+
item.review_status = nextReviewStatus;
|
|
1997
|
+
changed = true;
|
|
1998
|
+
}
|
|
1999
|
+
if (item.submitted_for_review !== true) {
|
|
2000
|
+
item.submitted_for_review = true;
|
|
2001
|
+
changed = true;
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
if (changed) {
|
|
2005
|
+
state.env = saveOpsState(state);
|
|
2006
|
+
}
|
|
2007
|
+
return changed;
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
async function verifyRegisteredHotline({ hotlineId, expectedTemplateRef }) {
|
|
2011
|
+
let detail;
|
|
2012
|
+
let bundle;
|
|
2013
|
+
try {
|
|
2014
|
+
detail = await requestJson(
|
|
2015
|
+
state.config.platform.base_url,
|
|
2016
|
+
`/v1/catalog/hotlines/${encodeURIComponent(hotlineId)}`,
|
|
2017
|
+
{
|
|
2018
|
+
headers: buildResponderRegisterHeaders()
|
|
2019
|
+
}
|
|
2020
|
+
);
|
|
2021
|
+
} catch (error) {
|
|
2022
|
+
return {
|
|
2023
|
+
ok: false,
|
|
2024
|
+
catalog_visible: false,
|
|
2025
|
+
template_ref_matches: false,
|
|
2026
|
+
template_bundle_available: false,
|
|
2027
|
+
catalog_status: null,
|
|
2028
|
+
template_bundle_status: null,
|
|
2029
|
+
error: error instanceof Error ? error.message : "catalog_verification_failed"
|
|
2030
|
+
};
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
const actualTemplateRef = detail.body?.template_ref || null;
|
|
2034
|
+
const templateRefMatches = Boolean(detail.status === 200 && actualTemplateRef && actualTemplateRef === expectedTemplateRef);
|
|
2035
|
+
if (detail.status === 200 && actualTemplateRef) {
|
|
2036
|
+
try {
|
|
2037
|
+
bundle = await requestJson(
|
|
2038
|
+
state.config.platform.base_url,
|
|
2039
|
+
`/v1/catalog/hotlines/${encodeURIComponent(hotlineId)}/template-bundle?template_ref=${encodeURIComponent(actualTemplateRef)}`,
|
|
2040
|
+
{
|
|
2041
|
+
headers: buildResponderRegisterHeaders()
|
|
2042
|
+
}
|
|
2043
|
+
);
|
|
2044
|
+
} catch (error) {
|
|
2045
|
+
bundle = {
|
|
2046
|
+
status: null,
|
|
2047
|
+
body: {
|
|
2048
|
+
ok: false,
|
|
2049
|
+
error: error instanceof Error ? error.message : "template_bundle_verification_failed"
|
|
2050
|
+
}
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
const templateBundleAvailable = Boolean(bundle?.status === 200);
|
|
2056
|
+
return {
|
|
2057
|
+
ok: Boolean(detail.status === 200 && templateRefMatches && templateBundleAvailable),
|
|
2058
|
+
catalog_visible: detail.status === 200,
|
|
2059
|
+
template_ref_matches: templateRefMatches,
|
|
2060
|
+
template_bundle_available: templateBundleAvailable,
|
|
2061
|
+
catalog_status: detail.status,
|
|
2062
|
+
template_bundle_status: bundle?.status ?? null,
|
|
2063
|
+
template_ref: actualTemplateRef || expectedTemplateRef || null
|
|
2064
|
+
};
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
async function submitPendingResponderReviews({ hotlineId = null } = {}) {
|
|
2068
|
+
if (!platformFeaturesEnabled(state)) {
|
|
2069
|
+
return {
|
|
2070
|
+
status: 409,
|
|
2071
|
+
body: buildStructuredError(
|
|
2072
|
+
"PLATFORM_FEATURES_DISABLED",
|
|
2073
|
+
"platform publishing is disabled; enable platform features before submitting hotline reviews"
|
|
2074
|
+
)
|
|
2075
|
+
};
|
|
2076
|
+
}
|
|
2077
|
+
const responderIdentity = ensureResponderIdentity(state);
|
|
2078
|
+
const pending = (state.config.responder.hotlines || []).filter(
|
|
2079
|
+
(item) => item.submitted_for_review !== true && (!hotlineId || item.hotline_id === hotlineId)
|
|
2080
|
+
);
|
|
2081
|
+
const results = [];
|
|
2082
|
+
for (const item of pending) {
|
|
2083
|
+
let onboarding;
|
|
2084
|
+
try {
|
|
2085
|
+
onboarding = buildHotlineOnboardingBody(state, item, responderIdentity);
|
|
2086
|
+
} catch (error) {
|
|
2087
|
+
return {
|
|
2088
|
+
status: 400,
|
|
2089
|
+
body: buildStructuredError(
|
|
2090
|
+
error?.code || "HOTLINE_DRAFT_INVALID",
|
|
2091
|
+
error instanceof Error ? error.message : "hotline registration draft is invalid",
|
|
2092
|
+
{ fields: Array.isArray(error?.fields) ? error.fields : [] }
|
|
2093
|
+
)
|
|
2094
|
+
};
|
|
2095
|
+
}
|
|
2096
|
+
const response = await requestJson(state.config.platform.base_url, "/v2/hotlines", {
|
|
2097
|
+
method: "POST",
|
|
2098
|
+
headers: buildResponderRegisterHeaders(),
|
|
2099
|
+
body: onboarding.body
|
|
2100
|
+
});
|
|
2101
|
+
if (response.status !== 201) {
|
|
2102
|
+
return response;
|
|
2103
|
+
}
|
|
2104
|
+
if (hasEncryptedSecretStore()) {
|
|
2105
|
+
writeOpsSecrets(runtime.auth.passphrase, {
|
|
2106
|
+
[OPS_SECRET_KEYS.responder_platform_api_key]: response.body.responder_api_key || response.body.api_key
|
|
2107
|
+
});
|
|
2108
|
+
runtime.auth.unlockedSecrets = unlockOpsSecrets(runtime.auth.passphrase);
|
|
2109
|
+
scrubLegacySecrets(state);
|
|
2110
|
+
} else {
|
|
2111
|
+
state.env = saveOpsState({
|
|
2112
|
+
...state,
|
|
2113
|
+
env: {
|
|
2114
|
+
...state.env,
|
|
2115
|
+
RESPONDER_PLATFORM_API_KEY: response.body.responder_api_key || response.body.api_key
|
|
2116
|
+
}
|
|
2117
|
+
});
|
|
2118
|
+
}
|
|
2119
|
+
item.submitted_for_review = true;
|
|
2120
|
+
item.review_status = response.body.hotline_review_status || response.body.review_status || "pending";
|
|
2121
|
+
const verification = await verifyRegisteredHotline({
|
|
2122
|
+
hotlineId: item.hotline_id,
|
|
2123
|
+
expectedTemplateRef: onboarding.body.template_ref
|
|
2124
|
+
});
|
|
2125
|
+
results.push({
|
|
2126
|
+
...response.body,
|
|
2127
|
+
draft_file: onboarding.draft_file,
|
|
2128
|
+
used_draft: onboarding.used_draft,
|
|
2129
|
+
verification
|
|
2130
|
+
});
|
|
2131
|
+
}
|
|
2132
|
+
saveOpsState(state);
|
|
2133
|
+
return { status: 201, body: { ok: true, responder_id: responderIdentity.responder_id, submitted: results.length, results } };
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
async function addOfficialExampleHotline() {
|
|
2137
|
+
const existing = findConfiguredExampleHotline(state);
|
|
2138
|
+
const definition = buildExampleHotlineDefinition(existing);
|
|
2139
|
+
if (isExampleHotlineDefinitionStale(existing)) {
|
|
2140
|
+
definition.submitted_for_review = false;
|
|
2141
|
+
definition.review_status = "local_only";
|
|
2142
|
+
}
|
|
2143
|
+
const registrationDraft = ensureHotlineRegistrationDraft(state, definition);
|
|
2144
|
+
upsertHotline(state, definition);
|
|
2145
|
+
state.env = saveOpsState(state);
|
|
2146
|
+
await reloadResponderIfRunning();
|
|
2147
|
+
appendSupervisorEvent({
|
|
2148
|
+
type: "hotline_upserted",
|
|
2149
|
+
hotline_id: definition.hotline_id,
|
|
2150
|
+
adapter_type: definition.adapter_type,
|
|
2151
|
+
example: true
|
|
2152
|
+
});
|
|
2153
|
+
return {
|
|
2154
|
+
...definition,
|
|
2155
|
+
local_integration_file: registrationDraft.integration_file,
|
|
2156
|
+
local_hook_file: registrationDraft.hook_file,
|
|
2157
|
+
registration_draft_file: registrationDraft.draft_file,
|
|
2158
|
+
registration_draft: registrationDraft.draft
|
|
2159
|
+
};
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
async function ensureOfficialExampleHotlineCurrent() {
|
|
2163
|
+
const existing = findConfiguredExampleHotline(state);
|
|
2164
|
+
if (!existing) {
|
|
2165
|
+
return null;
|
|
2166
|
+
}
|
|
2167
|
+
const current = buildExampleHotlineDefinition(existing);
|
|
2168
|
+
const stale = isExampleHotlineDefinitionStale(existing);
|
|
2169
|
+
if (!stale) {
|
|
2170
|
+
return existing;
|
|
2171
|
+
}
|
|
2172
|
+
current.submitted_for_review = false;
|
|
2173
|
+
current.review_status = "local_only";
|
|
2174
|
+
const registrationDraft = ensureHotlineRegistrationDraft(state, current);
|
|
2175
|
+
upsertHotline(state, current);
|
|
2176
|
+
state.env = saveOpsState(state);
|
|
2177
|
+
await reloadResponderIfRunning();
|
|
2178
|
+
appendSupervisorEvent({
|
|
2179
|
+
type: "hotline_upserted",
|
|
2180
|
+
hotline_id: current.hotline_id,
|
|
2181
|
+
adapter_type: current.adapter_type,
|
|
2182
|
+
example: true,
|
|
2183
|
+
upgraded: true,
|
|
2184
|
+
registration_draft_file: registrationDraft.draft_file
|
|
2185
|
+
});
|
|
2186
|
+
return current;
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
async function dispatchExampleRequest(body = {}) {
|
|
2190
|
+
await ensureBaseServices();
|
|
2191
|
+
const callerRegistered =
|
|
2192
|
+
state.config.caller.registration_mode === "local_only" || Boolean(getResolvedSecrets(state, runtime).caller_api_key);
|
|
2193
|
+
if (!callerRegistered) {
|
|
2194
|
+
return {
|
|
2195
|
+
status: 409,
|
|
2196
|
+
body: buildStructuredError("CALLER_NOT_REGISTERED", "caller must be registered before running the local example", {
|
|
2197
|
+
stage: "register_caller"
|
|
2198
|
+
})
|
|
2199
|
+
};
|
|
2200
|
+
}
|
|
2201
|
+
if (state.config.responder.enabled !== true) {
|
|
2202
|
+
return {
|
|
2203
|
+
status: 409,
|
|
2204
|
+
body: buildStructuredError("RESPONDER_NOT_ENABLED", "responder must be enabled before running the local example", {
|
|
2205
|
+
stage: "enable_responder"
|
|
2206
|
+
})
|
|
2207
|
+
};
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
const example = await ensureOfficialExampleHotlineCurrent();
|
|
2211
|
+
if (!example) {
|
|
2212
|
+
return buildExampleVisibilityError(example);
|
|
2213
|
+
}
|
|
2214
|
+
const responderIdentity = ensureResponderIdentity(state);
|
|
2215
|
+
let signerPublicKeyPem = responderIdentity.public_key_pem;
|
|
2216
|
+
|
|
2217
|
+
const diagnostics = await buildExampleDiagnostics();
|
|
2218
|
+
const requestBody = buildExampleRequestBody({
|
|
2219
|
+
text: body.text,
|
|
2220
|
+
responderId: state.config.responder.responder_id,
|
|
2221
|
+
hotlineId: LOCAL_EXAMPLE_HOTLINE_ID,
|
|
2222
|
+
signerPublicKeyPem,
|
|
2223
|
+
diagnostics
|
|
2224
|
+
});
|
|
2225
|
+
let response;
|
|
2226
|
+
const created = await requestJson(processBaseUrl(state.config.runtime.ports.caller), "/controller/requests", {
|
|
2227
|
+
method: "POST",
|
|
2228
|
+
body: requestBody
|
|
2229
|
+
});
|
|
2230
|
+
if (created.status !== 201 || !created.body?.request_id) {
|
|
2231
|
+
response = created;
|
|
2232
|
+
} else {
|
|
2233
|
+
await requestJson(
|
|
2234
|
+
processBaseUrl(state.config.runtime.ports.caller),
|
|
2235
|
+
`/controller/requests/${encodeURIComponent(created.body.request_id)}/contract-draft`,
|
|
2236
|
+
{
|
|
2237
|
+
method: "POST",
|
|
2238
|
+
body: {}
|
|
2239
|
+
}
|
|
2240
|
+
);
|
|
2241
|
+
const dispatched = await requestJson(
|
|
2242
|
+
processBaseUrl(state.config.runtime.ports.caller),
|
|
2243
|
+
`/controller/requests/${encodeURIComponent(created.body.request_id)}/dispatch`,
|
|
2244
|
+
{
|
|
2245
|
+
method: "POST",
|
|
2246
|
+
body: {
|
|
2247
|
+
thread_id: LOCAL_EXAMPLE_HOTLINE_ID,
|
|
2248
|
+
payload: requestBody.payload,
|
|
2249
|
+
task_input: requestBody.input
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
);
|
|
2253
|
+
response = {
|
|
2254
|
+
status: dispatched.status === 202 ? 201 : dispatched.status,
|
|
2255
|
+
body: {
|
|
2256
|
+
request_id: created.body.request_id,
|
|
2257
|
+
request: dispatched.body?.request || created.body,
|
|
2258
|
+
accepted: dispatched.body?.accepted === true,
|
|
2259
|
+
delivery_meta: null,
|
|
2260
|
+
task_token: null
|
|
2261
|
+
}
|
|
2262
|
+
};
|
|
2263
|
+
}
|
|
2264
|
+
const draft = loadHotlineRegistrationDraft(state, example);
|
|
2265
|
+
return {
|
|
2266
|
+
status: response.status,
|
|
2267
|
+
body: {
|
|
2268
|
+
...(response.body || {}),
|
|
2269
|
+
hotline_id: LOCAL_EXAMPLE_HOTLINE_ID,
|
|
2270
|
+
draft_file: draft.draft_file,
|
|
2271
|
+
draft_ready: Boolean(draft.draft)
|
|
2272
|
+
}
|
|
2273
|
+
};
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
const server = http.createServer(async (req, res) => {
|
|
2277
|
+
const method = req.method || "GET";
|
|
2278
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
2279
|
+
const pathname = url.pathname;
|
|
2280
|
+
|
|
2281
|
+
try {
|
|
2282
|
+
if (method === "OPTIONS") {
|
|
2283
|
+
sendJson(res, 204, {});
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
if (isProtectedRoute(method, pathname)) {
|
|
2288
|
+
const session = requireAuthenticatedSession(req, res, runtime, state);
|
|
2289
|
+
if (!session.ok) {
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
if (method === "GET" && pathname === "/healthz") {
|
|
2295
|
+
sendJson(res, 200, { ok: true, service: "ops-supervisor" });
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
if (method === "GET" && pathname === "/auth/session") {
|
|
2299
|
+
const recoverableSession = getRecoverableSession(runtime);
|
|
2300
|
+
if (recoverableSession) {
|
|
2301
|
+
persistActiveSession(recoverableSession);
|
|
2302
|
+
}
|
|
2303
|
+
sendJson(res, 200, {
|
|
2304
|
+
ok: true,
|
|
2305
|
+
session: getAuthState(runtime, state),
|
|
2306
|
+
recoverable_session: recoverableSession
|
|
2307
|
+
});
|
|
2308
|
+
return;
|
|
2309
|
+
}
|
|
2310
|
+
if (method === "POST" && pathname === "/auth/session/setup") {
|
|
2311
|
+
const body = await parseJsonBody(req);
|
|
2312
|
+
const passphrase = normalizedString(body.passphrase);
|
|
2313
|
+
if (!passphrase || passphrase.length < 8) {
|
|
2314
|
+
sendError(res, 400, "AUTH_INVALID_PASSPHRASE", "passphrase must be at least 8 characters");
|
|
2315
|
+
return;
|
|
2316
|
+
}
|
|
2317
|
+
if (hasEncryptedSecretStore()) {
|
|
2318
|
+
sendError(res, 409, "AUTH_SECRET_STORE_EXISTS", "encrypted secret store already exists");
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2321
|
+
const legacySecrets = Object.fromEntries(
|
|
2322
|
+
Object.entries(readResolvedOpsSecrets(state))
|
|
2323
|
+
.flatMap(([key, value]) => {
|
|
2324
|
+
if (key === "transport") {
|
|
2325
|
+
return [
|
|
2326
|
+
[OPS_SECRET_KEYS.transport_emailengine_access_token, value.emailengine.access_token],
|
|
2327
|
+
[OPS_SECRET_KEYS.transport_gmail_client_secret, value.gmail.client_secret],
|
|
2328
|
+
[OPS_SECRET_KEYS.transport_gmail_refresh_token, value.gmail.refresh_token]
|
|
2329
|
+
];
|
|
2330
|
+
}
|
|
2331
|
+
return [[key, value]];
|
|
2332
|
+
})
|
|
2333
|
+
.filter(([, value]) => normalizedString(value))
|
|
2334
|
+
);
|
|
2335
|
+
initializeSecretStore(state.secretsFile, passphrase, legacySecrets);
|
|
2336
|
+
runtime.auth.unlockedSecrets = unlockOpsSecrets(passphrase);
|
|
2337
|
+
runtime.auth.passphrase = passphrase;
|
|
2338
|
+
runtime.auth.unlockedAt = nowIso();
|
|
2339
|
+
state.config.caller.api_key_configured = Boolean(runtime.auth.unlockedSecrets[OPS_SECRET_KEYS.caller_api_key]);
|
|
2340
|
+
scrubLegacySecrets(state);
|
|
2341
|
+
state.env = saveOpsState(state);
|
|
2342
|
+
const session = createAuthenticatedSession(runtime, passphrase, runtime.auth.unlockedSecrets);
|
|
2343
|
+
appendSupervisorEvent({ type: "auth_session_setup" });
|
|
2344
|
+
sendJson(res, 201, {
|
|
2345
|
+
ok: true,
|
|
2346
|
+
token: session.token,
|
|
2347
|
+
expires_at: session.expires_at,
|
|
2348
|
+
session: getAuthState(runtime, state)
|
|
2349
|
+
});
|
|
2350
|
+
return;
|
|
2351
|
+
}
|
|
2352
|
+
if (method === "POST" && pathname === "/auth/session/login") {
|
|
2353
|
+
const body = await parseJsonBody(req);
|
|
2354
|
+
const passphrase = normalizedString(body.passphrase);
|
|
2355
|
+
if (!passphrase) {
|
|
2356
|
+
sendError(res, 400, "AUTH_INVALID_PASSPHRASE", "passphrase is required");
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
if (!hasEncryptedSecretStore()) {
|
|
2360
|
+
sendError(res, 409, "AUTH_SECRET_STORE_MISSING", "encrypted secret store is not initialized yet");
|
|
2361
|
+
return;
|
|
2362
|
+
}
|
|
2363
|
+
try {
|
|
2364
|
+
const secrets = unlockOpsSecrets(passphrase);
|
|
2365
|
+
const session = createAuthenticatedSession(runtime, passphrase, secrets);
|
|
2366
|
+
appendSupervisorEvent({ type: "auth_session_login" });
|
|
2367
|
+
for (const svc of ["caller", "skill-adapter"]) {
|
|
2368
|
+
const existing = runtime.processes.get(svc);
|
|
2369
|
+
if (existing && !existing.exited) {
|
|
2370
|
+
existing.child.kill();
|
|
2371
|
+
const deadline = Date.now() + 3000;
|
|
2372
|
+
while (!existing.exited && Date.now() < deadline) {
|
|
2373
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
await ensureService(svc);
|
|
2377
|
+
}
|
|
2378
|
+
appendSupervisorEvent({ type: "services_restarted_after_login", services: ["caller", "skill-adapter"] });
|
|
2379
|
+
sendJson(res, 200, {
|
|
2380
|
+
ok: true,
|
|
2381
|
+
token: session.token,
|
|
2382
|
+
expires_at: session.expires_at,
|
|
2383
|
+
session: getAuthState(runtime, state)
|
|
2384
|
+
});
|
|
2385
|
+
} catch (error) {
|
|
2386
|
+
sendError(res, 401, "AUTH_INVALID_PASSPHRASE", error instanceof Error ? error.message : "secret_unlock_failed");
|
|
2387
|
+
}
|
|
2388
|
+
return;
|
|
2389
|
+
}
|
|
2390
|
+
if (method === "POST" && pathname === "/auth/session/logout") {
|
|
2391
|
+
const token = readSessionToken(req);
|
|
2392
|
+
if (token) {
|
|
2393
|
+
runtime.auth.sessions.delete(token);
|
|
2394
|
+
} else {
|
|
2395
|
+
runtime.auth.sessions.clear();
|
|
2396
|
+
}
|
|
2397
|
+
pruneExpiredSessions(runtime);
|
|
2398
|
+
if (runtime.auth.sessions.size === 0) {
|
|
2399
|
+
clearActiveSession();
|
|
2400
|
+
}
|
|
2401
|
+
appendSupervisorEvent({ type: "auth_session_logout" });
|
|
2402
|
+
sendJson(res, 200, {
|
|
2403
|
+
ok: true,
|
|
2404
|
+
session: getAuthState(runtime, state)
|
|
2405
|
+
});
|
|
2406
|
+
return;
|
|
2407
|
+
}
|
|
2408
|
+
if (method === "POST" && pathname === "/auth/session/change-passphrase") {
|
|
2409
|
+
if (!hasEncryptedSecretStore()) {
|
|
2410
|
+
sendError(res, 409, "AUTH_SECRET_STORE_MISSING", "encrypted secret store is not initialized yet");
|
|
2411
|
+
return;
|
|
2412
|
+
}
|
|
2413
|
+
const body = await parseJsonBody(req);
|
|
2414
|
+
const nextPassphrase = normalizedString(body.next_passphrase);
|
|
2415
|
+
if (!nextPassphrase || nextPassphrase.length < 8) {
|
|
2416
|
+
sendError(res, 400, "AUTH_INVALID_PASSPHRASE", "next_passphrase must be at least 8 characters");
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2419
|
+
const currentPassphrase = runtime.auth.passphrase || normalizedString(body.current_passphrase);
|
|
2420
|
+
if (!currentPassphrase) {
|
|
2421
|
+
sendError(res, 400, "AUTH_INVALID_PASSPHRASE", "current passphrase is required");
|
|
2422
|
+
return;
|
|
2423
|
+
}
|
|
2424
|
+
try {
|
|
2425
|
+
rotateSecretStorePassphrase(state.secretsFile, currentPassphrase, nextPassphrase);
|
|
2426
|
+
const secrets = unlockOpsSecrets(nextPassphrase);
|
|
2427
|
+
runtime.auth.passphrase = nextPassphrase;
|
|
2428
|
+
runtime.auth.unlockedSecrets = secrets;
|
|
2429
|
+
runtime.auth.unlockedAt = nowIso();
|
|
2430
|
+
appendSupervisorEvent({ type: "auth_passphrase_rotated" });
|
|
2431
|
+
sendJson(res, 200, {
|
|
2432
|
+
ok: true,
|
|
2433
|
+
session: getAuthState(runtime, state)
|
|
2434
|
+
});
|
|
2435
|
+
} catch (error) {
|
|
2436
|
+
sendError(res, 401, "AUTH_INVALID_PASSPHRASE", error instanceof Error ? error.message : "passphrase_rotation_failed");
|
|
2437
|
+
}
|
|
2438
|
+
return;
|
|
2439
|
+
}
|
|
2440
|
+
if (method === "GET" && pathname === "/status") {
|
|
2441
|
+
sendJson(res, 200, await buildStatus());
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
if (method === "GET" && pathname === "/platform/settings") {
|
|
2445
|
+
sendJson(res, 200, {
|
|
2446
|
+
enabled: platformFeaturesEnabled(state),
|
|
2447
|
+
base_url: state.config.platform?.base_url || null
|
|
2448
|
+
});
|
|
2449
|
+
return;
|
|
2450
|
+
}
|
|
2451
|
+
if (method === "PUT" && pathname === "/platform/settings") {
|
|
2452
|
+
const body = await parseJsonBody(req);
|
|
2453
|
+
state.config.platform ||= {};
|
|
2454
|
+
if (typeof body.enabled === "boolean") {
|
|
2455
|
+
state.config.platform.enabled = body.enabled;
|
|
2456
|
+
}
|
|
2457
|
+
if (normalizedString(body.base_url)) {
|
|
2458
|
+
state.config.platform.base_url = normalizedString(body.base_url);
|
|
2459
|
+
state.config.platform_console ||= {};
|
|
2460
|
+
state.config.platform_console.base_url = state.config.platform.base_url;
|
|
2461
|
+
}
|
|
2462
|
+
state.env = saveOpsState(state);
|
|
2463
|
+
for (const svc of ["caller", "skill-adapter"]) {
|
|
2464
|
+
const existing = runtime.processes.get(svc);
|
|
2465
|
+
if (existing && !existing.exited) {
|
|
2466
|
+
existing.child.kill();
|
|
2467
|
+
const deadline = Date.now() + 3000;
|
|
2468
|
+
while (!existing.exited && Date.now() < deadline) {
|
|
2469
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
await ensureService(svc);
|
|
2473
|
+
}
|
|
2474
|
+
appendSupervisorEvent({
|
|
2475
|
+
type: "platform_settings_updated",
|
|
2476
|
+
enabled: platformFeaturesEnabled(state),
|
|
2477
|
+
base_url: state.config.platform.base_url
|
|
2478
|
+
});
|
|
2479
|
+
sendJson(res, 200, {
|
|
2480
|
+
ok: true,
|
|
2481
|
+
enabled: platformFeaturesEnabled(state),
|
|
2482
|
+
base_url: state.config.platform.base_url
|
|
2483
|
+
});
|
|
2484
|
+
return;
|
|
2485
|
+
}
|
|
2486
|
+
if (method === "GET" && pathname === "/runtime/transport") {
|
|
2487
|
+
sendJson(res, 200, getTransportResponse(state, runtime));
|
|
2488
|
+
return;
|
|
2489
|
+
}
|
|
2490
|
+
if (method === "PUT" && pathname === "/runtime/transport") {
|
|
2491
|
+
const body = await parseJsonBody(req);
|
|
2492
|
+
const nextTransport = normalizeTransportPayload(body);
|
|
2493
|
+
const validation = validateTransportConfig(nextTransport);
|
|
2494
|
+
if (validation) {
|
|
2495
|
+
sendJson(res, validation.status, validation.body);
|
|
2496
|
+
return;
|
|
2497
|
+
}
|
|
2498
|
+
state.config.runtime ||= {};
|
|
2499
|
+
state.config.runtime.transport = nextTransport;
|
|
2500
|
+
const secretUpdates = buildTransportSecretUpdates(body);
|
|
2501
|
+
if (hasEncryptedSecretStore()) {
|
|
2502
|
+
if (Object.keys(secretUpdates).length > 0) {
|
|
2503
|
+
writeOpsSecrets(runtime.auth.passphrase, secretUpdates);
|
|
2504
|
+
runtime.auth.unlockedSecrets = unlockOpsSecrets(runtime.auth.passphrase);
|
|
2505
|
+
}
|
|
2506
|
+
scrubLegacySecrets(state);
|
|
2507
|
+
} else if (Object.keys(secretUpdates).length > 0) {
|
|
2508
|
+
state.env = {
|
|
2509
|
+
...state.env,
|
|
2510
|
+
...buildLegacyTransportSecretEnv(secretUpdates)
|
|
2511
|
+
};
|
|
2512
|
+
}
|
|
2513
|
+
// Clear after scrubLegacySecrets (which re-reads disk) so saveOpsState picks up config.type
|
|
2514
|
+
if (state.env) state.env = { ...state.env, TRANSPORT_TYPE: null };
|
|
2515
|
+
state.env = saveOpsState(state);
|
|
2516
|
+
appendSupervisorEvent({
|
|
2517
|
+
type: "transport_updated",
|
|
2518
|
+
transport_type: nextTransport.type,
|
|
2519
|
+
provider: nextTransport.type === "email" ? nextTransport.email.provider : null
|
|
2520
|
+
});
|
|
2521
|
+
sendJson(res, 200, getTransportResponse(state, runtime));
|
|
2522
|
+
return;
|
|
2523
|
+
}
|
|
2524
|
+
if (method === "POST" && pathname === "/runtime/transport/test") {
|
|
2525
|
+
const validation = validateTransportConfig(getRuntimeTransport(state));
|
|
2526
|
+
if (validation) {
|
|
2527
|
+
sendJson(res, validation.status, validation.body);
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
const result = await testTransportConnection(state, runtime);
|
|
2531
|
+
sendJson(res, result.ok ? 200 : result.status || 502, result);
|
|
2532
|
+
return;
|
|
2533
|
+
}
|
|
2534
|
+
if (method === "POST" && pathname === "/setup") {
|
|
2535
|
+
refreshStateFromDisk();
|
|
2536
|
+
ensureResponderIdentity(state);
|
|
2537
|
+
state.env = saveOpsState(state);
|
|
2538
|
+
for (const svc of ["caller", "skill-adapter", "mcp-adapter", "responder"]) {
|
|
2539
|
+
const existing = runtime.processes.get(svc);
|
|
2540
|
+
if (existing && !existing.exited) {
|
|
2541
|
+
await stopProcessInfo(existing);
|
|
2542
|
+
await ensureService(svc);
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
appendSupervisorEvent({ type: "setup_completed" });
|
|
2546
|
+
sendJson(res, 200, { ok: true, config: state.config });
|
|
2547
|
+
return;
|
|
2548
|
+
}
|
|
2549
|
+
if (method === "POST" && pathname === "/auth/register-caller") {
|
|
2550
|
+
const body = await parseJsonBody(req);
|
|
2551
|
+
const registered = await registerCaller(body.contact_email, {
|
|
2552
|
+
localOnly: normalizedString(body.mode) === "local_only",
|
|
2553
|
+
forcePlatform: normalizedString(body.mode) === "platform"
|
|
2554
|
+
});
|
|
2555
|
+
appendSupervisorEvent({
|
|
2556
|
+
type: "caller_registered",
|
|
2557
|
+
ok: registered.status === 201,
|
|
2558
|
+
contact_email: body.contact_email || null
|
|
2559
|
+
});
|
|
2560
|
+
if (registered.status === 201) {
|
|
2561
|
+
for (const svc of ["caller", "skill-adapter"]) {
|
|
2562
|
+
const existing = runtime.processes.get(svc);
|
|
2563
|
+
if (existing && !existing.exited) {
|
|
2564
|
+
await stopProcessInfo(existing);
|
|
2565
|
+
}
|
|
2566
|
+
await ensureService(svc);
|
|
2567
|
+
}
|
|
2568
|
+
appendSupervisorEvent({ type: "services_restarted_after_registration", services: ["caller", "skill-adapter"] });
|
|
2569
|
+
}
|
|
2570
|
+
sendJson(res, registered.status, registered.body);
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2573
|
+
if (method === "GET" && pathname === "/catalog/hotlines") {
|
|
2574
|
+
const localItems = listLocalCatalogHotlines(state, runtime, {
|
|
2575
|
+
hotline_id: url.searchParams.get("hotline_id") || undefined,
|
|
2576
|
+
responder_id: url.searchParams.get("responder_id") || undefined,
|
|
2577
|
+
task_type: url.searchParams.get("task_type") || undefined,
|
|
2578
|
+
capability: url.searchParams.get("capability") || undefined
|
|
2579
|
+
});
|
|
2580
|
+
if (!platformFeaturesEnabled(state)) {
|
|
2581
|
+
sendJson(res, 200, { items: localItems.filter(isCallableCatalogItem) });
|
|
2582
|
+
return;
|
|
2583
|
+
}
|
|
2584
|
+
const response = await requestJson(
|
|
2585
|
+
processBaseUrl(state.config.runtime.ports.caller),
|
|
2586
|
+
`/controller/hotlines${url.search}`
|
|
2587
|
+
, {
|
|
2588
|
+
headers: buildPlatformHeaders(state, runtime)
|
|
2589
|
+
});
|
|
2590
|
+
if (response.status >= 200 && response.status < 300) {
|
|
2591
|
+
sendJson(res, response.status, {
|
|
2592
|
+
...(response.body || {}),
|
|
2593
|
+
items: mergeCatalogItems(response.body?.items || [], localItems)
|
|
2594
|
+
});
|
|
2595
|
+
return;
|
|
2596
|
+
}
|
|
2597
|
+
const callableLocalItems = localItems.filter(isCallableCatalogItem);
|
|
2598
|
+
if (callableLocalItems.length > 0) {
|
|
2599
|
+
sendJson(res, 200, { items: callableLocalItems });
|
|
2600
|
+
return;
|
|
2601
|
+
}
|
|
2602
|
+
sendJson(res, response.status, response.body);
|
|
2603
|
+
return;
|
|
2604
|
+
}
|
|
2605
|
+
const catalogDetailMatch = pathname.match(/^\/catalog\/hotlines\/([^/]+)$/);
|
|
2606
|
+
if (method === "GET" && catalogDetailMatch) {
|
|
2607
|
+
const hotlineId = decodeURIComponent(catalogDetailMatch[1]);
|
|
2608
|
+
const localItem = listLocalCatalogHotlines(state, runtime, { hotline_id: hotlineId })[0] || null;
|
|
2609
|
+
if (localItem && isCallableCatalogItem(localItem)) {
|
|
2610
|
+
sendJson(res, 200, localItem);
|
|
2611
|
+
return;
|
|
2612
|
+
}
|
|
2613
|
+
if (!platformFeaturesEnabled(state)) {
|
|
2614
|
+
sendError(res, 404, "HOTLINE_NOT_FOUND", "hotline is not configured locally");
|
|
2615
|
+
return;
|
|
2616
|
+
}
|
|
2617
|
+
const response = await requestJson(
|
|
2618
|
+
state.config.platform.base_url,
|
|
2619
|
+
`/v1/catalog/hotlines/${encodeURIComponent(hotlineId)}`,
|
|
2620
|
+
{
|
|
2621
|
+
headers: buildPlatformReadHeaders()
|
|
2622
|
+
}
|
|
2623
|
+
);
|
|
2624
|
+
if (response.status >= 200 && response.status < 300 && isPlatformBootstrapDemoCatalogItem(response.body)) {
|
|
2625
|
+
sendError(res, 404, "HOTLINE_NOT_FOUND", "hotline is not available in the local caller catalog");
|
|
2626
|
+
return;
|
|
2627
|
+
}
|
|
2628
|
+
sendJson(res, response.status, response.body);
|
|
2629
|
+
return;
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
// ------------------------------------------------------------------
|
|
2633
|
+
// /caller/approvals proxy → Skill Adapter
|
|
2634
|
+
// Allows the Ops Console to read and action pending approval records
|
|
2635
|
+
// ------------------------------------------------------------------
|
|
2636
|
+
if (pathname.startsWith("/caller/approvals")) {
|
|
2637
|
+
const skillAdapterBase = processBaseUrl(state.config.runtime.ports.skill_adapter);
|
|
2638
|
+
const upstreamPath = `/skills/remote-hotline/approvals${pathname.slice("/caller/approvals".length)}${url.search}`;
|
|
2639
|
+
const body = ["POST", "PUT", "PATCH"].includes(method) ? await parseJsonBody(req) : undefined;
|
|
2640
|
+
const response = await requestJson(skillAdapterBase, upstreamPath, {
|
|
2641
|
+
method,
|
|
2642
|
+
body
|
|
2643
|
+
});
|
|
2644
|
+
sendJson(res, response.status, response.body);
|
|
2645
|
+
return;
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
if (pathname === "/caller/global-policy") {
|
|
2649
|
+
if (method === "GET") {
|
|
2650
|
+
sendJson(res, 200, ensureCallerPolicyState(state));
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
if (["PUT", "PATCH"].includes(method)) {
|
|
2654
|
+
const body = await parseJsonBody(req);
|
|
2655
|
+
const current = ensureCallerPolicyState(state);
|
|
2656
|
+
const next = {
|
|
2657
|
+
...current,
|
|
2658
|
+
...(method === "PATCH" ? body : {}),
|
|
2659
|
+
...(method === "PUT" ? {
|
|
2660
|
+
mode: normalizedString(body.mode) || "manual",
|
|
2661
|
+
responderWhitelist: Array.isArray(body.responderWhitelist) ? body.responderWhitelist.map((item) => String(item)) : [],
|
|
2662
|
+
hotlineWhitelist: Array.isArray(body.hotlineWhitelist) ? body.hotlineWhitelist.map((item) => String(item)) : [],
|
|
2663
|
+
blocklist: Array.isArray(body.blocklist) ? body.blocklist.map((item) => String(item)) : [],
|
|
2664
|
+
} : {}),
|
|
2665
|
+
};
|
|
2666
|
+
next.mode = ["manual", "allow_listed", "allow_all"].includes(next.mode) ? next.mode : "manual";
|
|
2667
|
+
next.responderWhitelist = Array.isArray(next.responderWhitelist) ? next.responderWhitelist.filter(Boolean) : [];
|
|
2668
|
+
next.hotlineWhitelist = Array.isArray(next.hotlineWhitelist) ? next.hotlineWhitelist.filter(Boolean) : [];
|
|
2669
|
+
next.blocklist = Array.isArray(next.blocklist) ? next.blocklist.filter(Boolean) : [];
|
|
2670
|
+
state.config.preferences.caller_policy = next;
|
|
2671
|
+
state.env = saveOpsState(state);
|
|
2672
|
+
sendJson(res, 200, next);
|
|
2673
|
+
return;
|
|
2674
|
+
}
|
|
2675
|
+
sendError(res, 405, "method_not_allowed", "method not allowed");
|
|
2676
|
+
return;
|
|
2677
|
+
}
|
|
2678
|
+
if (method === "POST" && pathname === "/calls/prepare") {
|
|
2679
|
+
const body = await parseJsonBody(req);
|
|
2680
|
+
const response = await prepareCallConfirmation(body);
|
|
2681
|
+
sendJson(res, response.status, response.body);
|
|
2682
|
+
return;
|
|
2683
|
+
}
|
|
2684
|
+
if (method === "POST" && pathname === "/calls/confirm") {
|
|
2685
|
+
const body = await parseJsonBody(req);
|
|
2686
|
+
const response = await confirmPreparedCall(body);
|
|
2687
|
+
sendJson(res, response.status, response.body);
|
|
2688
|
+
return;
|
|
2689
|
+
}
|
|
2690
|
+
const preferenceMatch = pathname.match(/^\/preferences\/task-types\/([^/]+)\/hotline$/);
|
|
2691
|
+
if (preferenceMatch && method === "PUT") {
|
|
2692
|
+
const body = await parseJsonBody(req);
|
|
2693
|
+
const preference = setTaskTypePreference(state, decodeURIComponent(preferenceMatch[1]), {
|
|
2694
|
+
hotline_id: normalizedString(body.hotline_id),
|
|
2695
|
+
responder_id: normalizedString(body.responder_id)
|
|
2696
|
+
});
|
|
2697
|
+
state.env = saveOpsState(state);
|
|
2698
|
+
sendJson(res, 200, {
|
|
2699
|
+
ok: true,
|
|
2700
|
+
preference
|
|
2701
|
+
});
|
|
2702
|
+
return;
|
|
2703
|
+
}
|
|
2704
|
+
if (method === "GET" && pathname === "/preferences/task-types") {
|
|
2705
|
+
sendJson(res, 200, { items: Object.values(ensurePreferenceState(state)) });
|
|
2706
|
+
return;
|
|
2707
|
+
}
|
|
2708
|
+
if (method === "GET" && pathname === "/requests") {
|
|
2709
|
+
const response = await requestJson(processBaseUrl(state.config.runtime.ports.caller), "/controller/requests");
|
|
2710
|
+
sendJson(res, response.status, response.body);
|
|
2711
|
+
return;
|
|
2712
|
+
}
|
|
2713
|
+
const requestMatch = pathname.match(/^\/requests\/([^/]+)$/);
|
|
2714
|
+
if (method === "GET" && requestMatch) {
|
|
2715
|
+
const response = await requestJson(processBaseUrl(state.config.runtime.ports.caller), `/controller/requests/${requestMatch[1]}`);
|
|
2716
|
+
sendJson(res, response.status, response.body);
|
|
2717
|
+
return;
|
|
2718
|
+
}
|
|
2719
|
+
const requestResultMatch = pathname.match(/^\/requests\/([^/]+)\/result$/);
|
|
2720
|
+
if (method === "GET" && requestResultMatch) {
|
|
2721
|
+
const response = await requestJson(processBaseUrl(state.config.runtime.ports.caller), `/controller/requests/${requestResultMatch[1]}/result`);
|
|
2722
|
+
sendJson(res, response.status, response.body);
|
|
2723
|
+
return;
|
|
2724
|
+
}
|
|
2725
|
+
if (method === "POST" && pathname === "/requests") {
|
|
2726
|
+
const body = await parseJsonBody(req);
|
|
2727
|
+
const response = await requestJson(processBaseUrl(state.config.runtime.ports.caller), "/controller/remote-requests", {
|
|
2728
|
+
method: "POST",
|
|
2729
|
+
headers: buildPlatformHeaders(state, runtime),
|
|
2730
|
+
body
|
|
2731
|
+
});
|
|
2732
|
+
sendJson(res, response.status, response.body);
|
|
2733
|
+
return;
|
|
2734
|
+
}
|
|
2735
|
+
if (method === "POST" && pathname === "/requests/example") {
|
|
2736
|
+
const body = await parseJsonBody(req);
|
|
2737
|
+
const response = await dispatchExampleRequest(body);
|
|
2738
|
+
sendJson(res, response.status, response.body);
|
|
2739
|
+
return;
|
|
2740
|
+
}
|
|
2741
|
+
if (method === "GET" && pathname === "/responder") {
|
|
2742
|
+
await syncResponderReviewStatusesFromPlatform();
|
|
2743
|
+
sendJson(res, 200, {
|
|
2744
|
+
enabled: state.config.responder.enabled,
|
|
2745
|
+
responder_id: state.config.responder.responder_id,
|
|
2746
|
+
display_name: state.config.responder.display_name,
|
|
2747
|
+
platform_enabled: platformFeaturesEnabled(state),
|
|
2748
|
+
hotline_count: (state.config.responder.hotlines || []).length,
|
|
2749
|
+
hotlines: (state.config.responder.hotlines || []).map((item) => serializeHotlineForUi(state, runtime, item))
|
|
2750
|
+
});
|
|
2751
|
+
return;
|
|
2752
|
+
}
|
|
2753
|
+
if (method === "GET" && pathname === "/responder/hotlines") {
|
|
2754
|
+
await syncResponderReviewStatusesFromPlatform();
|
|
2755
|
+
sendJson(res, 200, {
|
|
2756
|
+
platform_enabled: platformFeaturesEnabled(state),
|
|
2757
|
+
items: (state.config.responder.hotlines || []).map((item) => serializeHotlineForUi(state, runtime, item))
|
|
2758
|
+
});
|
|
2759
|
+
return;
|
|
2760
|
+
}
|
|
2761
|
+
const hotlineDraftMatch = pathname.match(/^\/responder\/hotlines\/([^/]+)\/draft$/);
|
|
2762
|
+
if (method === "GET" && hotlineDraftMatch) {
|
|
2763
|
+
const hotlineId = decodeURIComponent(hotlineDraftMatch[1]);
|
|
2764
|
+
const hotline = (state.config.responder.hotlines || []).find((item) => item.hotline_id === hotlineId);
|
|
2765
|
+
if (!hotline) {
|
|
2766
|
+
sendError(res, 404, "hotline_not_found", "no hotline found with this id", { hotline_id: hotlineId });
|
|
2767
|
+
return;
|
|
2768
|
+
}
|
|
2769
|
+
await syncResponderReviewStatusesFromPlatform();
|
|
2770
|
+
const registrationDraft = loadHotlineRegistrationDraft(state, hotline);
|
|
2771
|
+
sendJson(res, 200, {
|
|
2772
|
+
ok: Boolean(registrationDraft.draft),
|
|
2773
|
+
hotline_id: hotline.hotline_id,
|
|
2774
|
+
platform_enabled: platformFeaturesEnabled(state),
|
|
2775
|
+
review_status: hotline.review_status || "local_only",
|
|
2776
|
+
submitted_for_review: hotline.submitted_for_review === true,
|
|
2777
|
+
draft_file: registrationDraft.draft_file,
|
|
2778
|
+
local_integration_file: hotline?.metadata?.local?.integration_file || null,
|
|
2779
|
+
local_hook_file: hotline?.metadata?.local?.hook_file || null,
|
|
2780
|
+
draft_ready: Boolean(registrationDraft.draft_file),
|
|
2781
|
+
runtime: buildResponderRuntimeStatus(state, runtime, hotline.hotline_id),
|
|
2782
|
+
draft: registrationDraft.draft
|
|
2783
|
+
});
|
|
2784
|
+
return;
|
|
2785
|
+
}
|
|
2786
|
+
if (method === "POST" && pathname === "/responder/hotlines/example") {
|
|
2787
|
+
const definition = await addOfficialExampleHotline();
|
|
2788
|
+
sendJson(res, 201, {
|
|
2789
|
+
...definition,
|
|
2790
|
+
example: true,
|
|
2791
|
+
message: `${LOCAL_EXAMPLE_DISPLAY_NAME} is configured locally`
|
|
2792
|
+
});
|
|
2793
|
+
return;
|
|
2794
|
+
}
|
|
2795
|
+
if (method === "POST" && pathname === "/responder/hotlines") {
|
|
2796
|
+
const body = await parseJsonBody(req);
|
|
2797
|
+
const definition = {
|
|
2798
|
+
hotline_id: body.hotline_id,
|
|
2799
|
+
display_name: body.display_name || body.hotline_id,
|
|
2800
|
+
enabled: body.enabled !== false,
|
|
2801
|
+
task_types: body.task_types || [],
|
|
2802
|
+
capabilities: body.capabilities || [],
|
|
2803
|
+
tags: body.tags || [],
|
|
2804
|
+
adapter_type: body.adapter_type || "process",
|
|
2805
|
+
adapter: body.adapter || {},
|
|
2806
|
+
metadata: body.metadata || null,
|
|
2807
|
+
timeouts: body.timeouts || { soft_timeout_s: 60, hard_timeout_s: 180 },
|
|
2808
|
+
review_status: "local_only",
|
|
2809
|
+
submitted_for_review: false
|
|
2810
|
+
};
|
|
2811
|
+
const registrationDraft = ensureHotlineRegistrationDraft(state, definition);
|
|
2812
|
+
upsertHotline(state, definition);
|
|
2813
|
+
state.env = saveOpsState(state);
|
|
2814
|
+
await reloadResponderIfRunning();
|
|
2815
|
+
appendSupervisorEvent({
|
|
2816
|
+
type: "hotline_upserted",
|
|
2817
|
+
hotline_id: definition.hotline_id,
|
|
2818
|
+
adapter_type: definition.adapter_type
|
|
2819
|
+
});
|
|
2820
|
+
sendJson(res, 201, {
|
|
2821
|
+
...definition,
|
|
2822
|
+
local_integration_file: registrationDraft.integration_file,
|
|
2823
|
+
local_hook_file: registrationDraft.hook_file,
|
|
2824
|
+
registration_draft_file: registrationDraft.draft_file,
|
|
2825
|
+
registration_draft: registrationDraft.draft,
|
|
2826
|
+
runtime: buildResponderRuntimeStatus(state, runtime, definition.hotline_id)
|
|
2827
|
+
});
|
|
2828
|
+
return;
|
|
2829
|
+
}
|
|
2830
|
+
const hotlineToggleMatch = pathname.match(/^\/responder\/hotlines\/([^/]+)\/(enable|disable)$/);
|
|
2831
|
+
if (method === "POST" && hotlineToggleMatch) {
|
|
2832
|
+
const hotlineId = decodeURIComponent(hotlineToggleMatch[1]);
|
|
2833
|
+
const enabled = hotlineToggleMatch[2] === "enable";
|
|
2834
|
+
const item = setHotlineEnabled(state, hotlineId, enabled);
|
|
2835
|
+
if (!item) {
|
|
2836
|
+
sendError(res, 404, "hotline_not_found", "no hotline found with this id", { hotline_id: hotlineId });
|
|
2837
|
+
return;
|
|
2838
|
+
}
|
|
2839
|
+
state.env = saveOpsState(state);
|
|
2840
|
+
await reloadResponderIfRunning();
|
|
2841
|
+
appendSupervisorEvent({
|
|
2842
|
+
type: "hotline_toggled",
|
|
2843
|
+
hotline_id: item.hotline_id,
|
|
2844
|
+
enabled: item.enabled !== false
|
|
2845
|
+
});
|
|
2846
|
+
sendJson(res, 200, {
|
|
2847
|
+
ok: true,
|
|
2848
|
+
hotline_id: item.hotline_id,
|
|
2849
|
+
enabled: item.enabled !== false,
|
|
2850
|
+
review_status: item.review_status || "local_only",
|
|
2851
|
+
submitted_for_review: item.submitted_for_review === true,
|
|
2852
|
+
runtime: buildResponderRuntimeStatus(state, runtime, item.hotline_id)
|
|
2853
|
+
});
|
|
2854
|
+
return;
|
|
2855
|
+
}
|
|
2856
|
+
const hotlineDeleteMatch = pathname.match(/^\/responder\/hotlines\/([^/]+)$/);
|
|
2857
|
+
if (method === "DELETE" && hotlineDeleteMatch) {
|
|
2858
|
+
const hotlineId = decodeURIComponent(hotlineDeleteMatch[1]);
|
|
2859
|
+
const removed = removeHotline(state, hotlineId);
|
|
2860
|
+
if (!removed) {
|
|
2861
|
+
sendError(res, 404, "hotline_not_found", "no hotline found with this id", { hotline_id: hotlineId });
|
|
2862
|
+
return;
|
|
2863
|
+
}
|
|
2864
|
+
state.env = saveOpsState(state);
|
|
2865
|
+
await reloadResponderIfRunning();
|
|
2866
|
+
appendSupervisorEvent({
|
|
2867
|
+
type: "hotline_removed",
|
|
2868
|
+
hotline_id: removed.hotline_id
|
|
2869
|
+
});
|
|
2870
|
+
sendJson(res, 200, {
|
|
2871
|
+
ok: true,
|
|
2872
|
+
removed: {
|
|
2873
|
+
hotline_id: removed.hotline_id,
|
|
2874
|
+
review_status: removed.review_status || "local_only"
|
|
2875
|
+
},
|
|
2876
|
+
runtime: buildResponderRuntimeStatus(state, runtime, removed.hotline_id)
|
|
2877
|
+
});
|
|
2878
|
+
return;
|
|
2879
|
+
}
|
|
2880
|
+
const hotlineSubmitDraftMatch = pathname.match(/^\/responder\/hotlines\/([^/]+)\/submit-review$/);
|
|
2881
|
+
if (method === "POST" && hotlineSubmitDraftMatch) {
|
|
2882
|
+
const hotlineId = decodeURIComponent(hotlineSubmitDraftMatch[1]);
|
|
2883
|
+
const hotline = (state.config.responder.hotlines || []).find((item) => item.hotline_id === hotlineId);
|
|
2884
|
+
if (!hotline) {
|
|
2885
|
+
sendError(res, 404, "hotline_not_found", "no hotline found with this id", { hotline_id: hotlineId });
|
|
2886
|
+
return;
|
|
2887
|
+
}
|
|
2888
|
+
ensureResponderIdentity(state);
|
|
2889
|
+
state.env = saveOpsState(state);
|
|
2890
|
+
const submitted = await submitPendingResponderReviews({ hotlineId });
|
|
2891
|
+
await reloadResponderIfRunning();
|
|
2892
|
+
appendSupervisorEvent({
|
|
2893
|
+
type: "responder_review_submitted",
|
|
2894
|
+
responder_id: state.config.responder.responder_id,
|
|
2895
|
+
hotline_id: hotlineId,
|
|
2896
|
+
submitted: submitted.body?.submitted || 0,
|
|
2897
|
+
ok: submitted.status === 201
|
|
2898
|
+
});
|
|
2899
|
+
sendJson(res, submitted.status, submitted.body);
|
|
2900
|
+
return;
|
|
2901
|
+
}
|
|
2902
|
+
if (method === "POST" && pathname === "/responder/enable") {
|
|
2903
|
+
const body = await parseJsonBody(req);
|
|
2904
|
+
ensureResponderIdentity(state, {
|
|
2905
|
+
responderId: body.responder_id || state.config.responder.responder_id || null,
|
|
2906
|
+
displayName: body.display_name || state.config.responder.display_name || null
|
|
2907
|
+
});
|
|
2908
|
+
state.config.responder.enabled = true;
|
|
2909
|
+
if (body.hotline_id) {
|
|
2910
|
+
const definition = {
|
|
2911
|
+
hotline_id: body.hotline_id,
|
|
2912
|
+
display_name: body.display_name || body.hotline_id,
|
|
2913
|
+
enabled: true,
|
|
2914
|
+
task_types: body.task_types || [],
|
|
2915
|
+
capabilities: body.capabilities || [],
|
|
2916
|
+
tags: body.tags || [],
|
|
2917
|
+
adapter_type: body.adapter_type || "process",
|
|
2918
|
+
adapter: body.adapter || { cmd: body.cmd || "" },
|
|
2919
|
+
timeouts: body.timeouts || { soft_timeout_s: 60, hard_timeout_s: 180 },
|
|
2920
|
+
review_status: "local_only",
|
|
2921
|
+
submitted_for_review: false
|
|
2922
|
+
};
|
|
2923
|
+
ensureHotlineRegistrationDraft(state, definition);
|
|
2924
|
+
upsertHotline(state, definition);
|
|
2925
|
+
}
|
|
2926
|
+
state.env = saveOpsState(state);
|
|
2927
|
+
await ensureService("responder");
|
|
2928
|
+
appendSupervisorEvent({
|
|
2929
|
+
type: "responder_enabled",
|
|
2930
|
+
responder_id: state.config.responder.responder_id
|
|
2931
|
+
});
|
|
2932
|
+
sendJson(res, 200, {
|
|
2933
|
+
ok: true,
|
|
2934
|
+
responder: state.config.responder,
|
|
2935
|
+
submitted: 0,
|
|
2936
|
+
review: null
|
|
2937
|
+
});
|
|
2938
|
+
return;
|
|
2939
|
+
}
|
|
2940
|
+
if (method === "POST" && pathname === "/responder/submit-review") {
|
|
2941
|
+
const body = await parseJsonBody(req);
|
|
2942
|
+
ensureResponderIdentity(state, {
|
|
2943
|
+
responderId: body.responder_id || state.config.responder.responder_id || null,
|
|
2944
|
+
displayName: body.display_name || state.config.responder.display_name || null
|
|
2945
|
+
});
|
|
2946
|
+
state.env = saveOpsState(state);
|
|
2947
|
+
const submitted = await submitPendingResponderReviews({
|
|
2948
|
+
hotlineId: normalizedString(body.hotline_id) || null
|
|
2949
|
+
});
|
|
2950
|
+
await reloadResponderIfRunning();
|
|
2951
|
+
appendSupervisorEvent({
|
|
2952
|
+
type: "responder_review_submitted",
|
|
2953
|
+
responder_id: state.config.responder.responder_id,
|
|
2954
|
+
hotline_id: normalizedString(body.hotline_id) || null,
|
|
2955
|
+
submitted: submitted.body?.submitted || 0,
|
|
2956
|
+
ok: submitted.status === 201
|
|
2957
|
+
});
|
|
2958
|
+
sendJson(res, submitted.status, submitted.body);
|
|
2959
|
+
return;
|
|
2960
|
+
}
|
|
2961
|
+
if (method === "GET" && pathname === "/runtime/logs") {
|
|
2962
|
+
const service = url.searchParams.get("service");
|
|
2963
|
+
if (!service) {
|
|
2964
|
+
sendError(res, 400, "service_required", "service query parameter is required");
|
|
2965
|
+
return;
|
|
2966
|
+
}
|
|
2967
|
+
const maxLines = Number(url.searchParams.get("max_lines") || 200);
|
|
2968
|
+
sendJson(res, 200, {
|
|
2969
|
+
service,
|
|
2970
|
+
file: getServiceLogFile(service),
|
|
2971
|
+
logs: readServiceLogTail(service, { maxLines })
|
|
2972
|
+
});
|
|
2973
|
+
return;
|
|
2974
|
+
}
|
|
2975
|
+
if (method === "DELETE" && pathname === "/runtime/logs") {
|
|
2976
|
+
const service = url.searchParams.get("service");
|
|
2977
|
+
if (!service) {
|
|
2978
|
+
sendError(res, 400, "service_required", "service query parameter is required");
|
|
2979
|
+
return;
|
|
2980
|
+
}
|
|
2981
|
+
const logFile = getServiceLogFile(service);
|
|
2982
|
+
if (fs.existsSync(logFile)) fs.writeFileSync(logFile, "", "utf8");
|
|
2983
|
+
sendJson(res, 200, { ok: true });
|
|
2984
|
+
return;
|
|
2985
|
+
}
|
|
2986
|
+
if (method === "GET" && pathname === "/runtime/alerts") {
|
|
2987
|
+
const service = url.searchParams.get("service");
|
|
2988
|
+
if (!service) {
|
|
2989
|
+
sendError(res, 400, "service_required", "service query parameter is required");
|
|
2990
|
+
return;
|
|
2991
|
+
}
|
|
2992
|
+
const maxItems = Number(url.searchParams.get("max_items") || 20);
|
|
2993
|
+
sendJson(res, 200, {
|
|
2994
|
+
service,
|
|
2995
|
+
alerts: buildRuntimeAlerts(service, { maxItems })
|
|
2996
|
+
});
|
|
2997
|
+
return;
|
|
2998
|
+
}
|
|
2999
|
+
if (method === "DELETE" && pathname === "/runtime/alerts") {
|
|
3000
|
+
const eventsFile = getSupervisorEventsFile();
|
|
3001
|
+
if (fs.existsSync(eventsFile)) fs.writeFileSync(eventsFile, "", "utf8");
|
|
3002
|
+
sendJson(res, 200, { ok: true });
|
|
3003
|
+
return;
|
|
3004
|
+
}
|
|
3005
|
+
if (method === "GET" && pathname === "/debug/snapshot") {
|
|
3006
|
+
const status = await buildStatus();
|
|
3007
|
+
sendJson(res, 200, {
|
|
3008
|
+
ok: true,
|
|
3009
|
+
generated_at: nowIso(),
|
|
3010
|
+
status,
|
|
3011
|
+
recent_events: readSupervisorEventTail({ maxLines: 50 }),
|
|
3012
|
+
log_tail: {
|
|
3013
|
+
relay: readServiceLogTail("relay", { maxLines: 50 }),
|
|
3014
|
+
caller: readServiceLogTail("caller", { maxLines: 50 }),
|
|
3015
|
+
responder: readServiceLogTail("responder", { maxLines: 50 })
|
|
3016
|
+
}
|
|
3017
|
+
});
|
|
3018
|
+
return;
|
|
3019
|
+
}
|
|
3020
|
+
if (method === "GET" && pathname === "/mcp-adapter/spec") {
|
|
3021
|
+
sendJson(res, 200, {
|
|
3022
|
+
ok: true,
|
|
3023
|
+
spec: buildMcpAdapterSpec()
|
|
3024
|
+
});
|
|
3025
|
+
return;
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
const serviceRestartMatch = pathname.match(/^\/runtime\/services\/([^/]+)\/restart$/);
|
|
3029
|
+
if (method === "POST" && serviceRestartMatch) {
|
|
3030
|
+
const name = serviceRestartMatch[1];
|
|
3031
|
+
if (!["caller", "responder", "relay", "skill-adapter", "mcp-adapter"].includes(name)) {
|
|
3032
|
+
sendError(res, 400, "invalid_service", "service must be caller, responder, relay, skill-adapter, or mcp-adapter");
|
|
3033
|
+
return;
|
|
3034
|
+
}
|
|
3035
|
+
const existing = runtime.processes.get(name);
|
|
3036
|
+
if (existing && !existing.exited) {
|
|
3037
|
+
await stopProcessInfo(existing);
|
|
3038
|
+
}
|
|
3039
|
+
await ensureService(name);
|
|
3040
|
+
appendSupervisorEvent({ type: "service_restarted", service: name });
|
|
3041
|
+
sendJson(res, 200, { ok: true, service: name });
|
|
3042
|
+
return;
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
sendError(res, 404, "not_found", "no matching route", { path: pathname });
|
|
3046
|
+
} catch (error) {
|
|
3047
|
+
if (error instanceof Error && error.message === "invalid_json") {
|
|
3048
|
+
sendError(res, 400, "invalid_json", "request body is not valid JSON");
|
|
3049
|
+
return;
|
|
3050
|
+
}
|
|
3051
|
+
sendError(res, 500, "ops_supervisor_internal_error", error instanceof Error ? error.message : "unknown_error", { retryable: true });
|
|
3052
|
+
}
|
|
3053
|
+
});
|
|
3054
|
+
|
|
3055
|
+
server.startManagedServices = async () => {
|
|
3056
|
+
ensureResponderIdentity(state);
|
|
3057
|
+
state.env = saveOpsState(state);
|
|
3058
|
+
await ensureBaseServices();
|
|
3059
|
+
appendSupervisorEvent({ type: "managed_services_started" });
|
|
3060
|
+
};
|
|
3061
|
+
|
|
3062
|
+
server.stopManagedServices = async () => {
|
|
3063
|
+
for (const processInfo of runtime.processes.values()) {
|
|
3064
|
+
await stopProcessInfo(processInfo);
|
|
3065
|
+
}
|
|
3066
|
+
appendSupervisorEvent({ type: "managed_services_stopped" });
|
|
3067
|
+
};
|
|
3068
|
+
|
|
3069
|
+
return server;
|
|
3070
|
+
}
|