@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,1202 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
|
|
4
|
+
import { buildStructuredError, canonicalizeResultPackageForSignature } from "@delexec/contracts";
|
|
5
|
+
import {
|
|
6
|
+
createConfiguredHotlineExecutor,
|
|
7
|
+
createExampleFunctionExecutor,
|
|
8
|
+
createFunctionExecutor,
|
|
9
|
+
createSimulatorExecutor,
|
|
10
|
+
createHotlineRouterExecutor,
|
|
11
|
+
deferTask
|
|
12
|
+
} from "./executors.js";
|
|
13
|
+
|
|
14
|
+
function nowIso() {
|
|
15
|
+
return new Date().toISOString();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseJsonBody(req) {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const chunks = [];
|
|
21
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
22
|
+
req.on("end", () => {
|
|
23
|
+
if (chunks.length === 0) {
|
|
24
|
+
resolve({});
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
29
|
+
} catch {
|
|
30
|
+
reject(new Error("invalid_json"));
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
req.on("error", reject);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sendJson(res, statusCode, data) {
|
|
38
|
+
res.writeHead(statusCode, {
|
|
39
|
+
"content-type": "application/json; charset=utf-8",
|
|
40
|
+
"access-control-allow-origin": "*",
|
|
41
|
+
"access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
|
|
42
|
+
"access-control-allow-headers": "Content-Type, Authorization, X-Platform-Api-Key"
|
|
43
|
+
});
|
|
44
|
+
res.end(JSON.stringify(data));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sendError(res, statusCode, code, message, { retryable, ...extra } = {}) {
|
|
48
|
+
sendJson(res, statusCode, buildStructuredError(code, message, { retryable, ...extra }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function postJson(baseUrl, pathname, { method = "POST", headers = {}, body } = {}) {
|
|
52
|
+
const response = await fetch(new URL(pathname, baseUrl), {
|
|
53
|
+
method,
|
|
54
|
+
headers: {
|
|
55
|
+
"content-type": "application/json; charset=utf-8",
|
|
56
|
+
...headers
|
|
57
|
+
},
|
|
58
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const text = await response.text();
|
|
62
|
+
return {
|
|
63
|
+
status: response.status,
|
|
64
|
+
body: text ? JSON.parse(text) : null
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function postMetricEvent(platform, body) {
|
|
69
|
+
if (!platform?.baseUrl || !platform.apiKey) {
|
|
70
|
+
return { ok: false, skipped: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const response = await postJson(platform.baseUrl, "/v1/metrics/events", {
|
|
74
|
+
headers: {
|
|
75
|
+
Authorization: `Bearer ${platform.apiKey}`
|
|
76
|
+
},
|
|
77
|
+
body
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return { ok: response.status >= 200 && response.status < 300, response };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function registerResponderOnPlatform(platform, body) {
|
|
84
|
+
if (!platform?.baseUrl) {
|
|
85
|
+
throw new Error("responder_platform_base_url_required");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const response = await postJson(platform.baseUrl, "/v2/responders/register", {
|
|
89
|
+
headers: platform.apiKey
|
|
90
|
+
? {
|
|
91
|
+
Authorization: `Bearer ${platform.apiKey}`
|
|
92
|
+
}
|
|
93
|
+
: {},
|
|
94
|
+
body
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (response.status !== 201) {
|
|
98
|
+
const error = new Error("RESPONDER_PLATFORM_REGISTER_FAILED");
|
|
99
|
+
error.response = response;
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return response.body;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function persistResponderState(onStateChanged, state) {
|
|
107
|
+
if (typeof onStateChanged === "function") {
|
|
108
|
+
await onStateChanged(state);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildResultTiming(task) {
|
|
113
|
+
const acceptedAt = task.accepted_at || task.enqueued_at || nowIso();
|
|
114
|
+
const finishedAt = task.completed_at || nowIso();
|
|
115
|
+
const acceptedMs = Date.parse(acceptedAt);
|
|
116
|
+
const finishedMs = Date.parse(finishedAt);
|
|
117
|
+
const elapsedMs =
|
|
118
|
+
Number.isFinite(acceptedMs) && Number.isFinite(finishedMs) ? Math.max(0, finishedMs - acceptedMs) : task.delay_ms;
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
accepted_at: acceptedAt,
|
|
122
|
+
finished_at: finishedAt,
|
|
123
|
+
elapsed_ms: elapsedMs
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function buildBaseResultPayload(task) {
|
|
128
|
+
return {
|
|
129
|
+
message_type: "remote_hotline_result",
|
|
130
|
+
request_id: task.request_id,
|
|
131
|
+
result_version: "0.1.0",
|
|
132
|
+
responder_id: task.responder_id,
|
|
133
|
+
hotline_id: task.hotline_id,
|
|
134
|
+
verification: task.verification || null,
|
|
135
|
+
timing: buildResultTiming(task)
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function buildErrorResultPayload(task, { code, message, retryable = false, schemaValid = true, usage } = {}) {
|
|
140
|
+
return {
|
|
141
|
+
...buildBaseResultPayload(task),
|
|
142
|
+
status: "error",
|
|
143
|
+
error: {
|
|
144
|
+
code,
|
|
145
|
+
message,
|
|
146
|
+
retryable
|
|
147
|
+
},
|
|
148
|
+
schema_valid: schemaValid,
|
|
149
|
+
usage: usage || { tokens_in: 0, tokens_out: 0 }
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function buildGuardrailError(code, message) {
|
|
154
|
+
return {
|
|
155
|
+
status: "error",
|
|
156
|
+
error: {
|
|
157
|
+
code,
|
|
158
|
+
message,
|
|
159
|
+
retryable: false
|
|
160
|
+
},
|
|
161
|
+
schema_valid: true,
|
|
162
|
+
usage: { tokens_in: 0, tokens_out: 0 }
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function buildResultPayload(task, execution) {
|
|
167
|
+
if (!execution || typeof execution !== "object") {
|
|
168
|
+
return buildErrorResultPayload(task, {
|
|
169
|
+
code: "EXECUTOR_INVALID_RESULT",
|
|
170
|
+
message: "Responder executor returned an invalid result object"
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (execution.status === "error") {
|
|
175
|
+
return buildErrorResultPayload(task, {
|
|
176
|
+
code: execution.error?.code || "EXECUTOR_RUNTIME_ERROR",
|
|
177
|
+
message: execution.error?.message || "Responder executor reported an error",
|
|
178
|
+
retryable: execution.error?.retryable === true,
|
|
179
|
+
schemaValid: execution.schema_valid !== false,
|
|
180
|
+
usage: execution.usage
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (execution.status !== "ok") {
|
|
185
|
+
return buildErrorResultPayload(task, {
|
|
186
|
+
code: "EXECUTOR_INVALID_RESULT",
|
|
187
|
+
message: "Responder executor must return status 'ok' or 'error'"
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
...buildBaseResultPayload(task),
|
|
193
|
+
status: "ok",
|
|
194
|
+
output: "output" in execution ? execution.output : null,
|
|
195
|
+
artifacts: sanitizeArtifactsForResult(execution.artifacts),
|
|
196
|
+
schema_valid: execution.schema_valid !== false,
|
|
197
|
+
usage: execution.usage || { tokens_in: 0, tokens_out: 0 }
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function normalizeArtifactContent(artifact) {
|
|
202
|
+
if (Buffer.isBuffer(artifact?.content)) {
|
|
203
|
+
return artifact.content;
|
|
204
|
+
}
|
|
205
|
+
if (artifact?.content_base64) {
|
|
206
|
+
return Buffer.from(artifact.content_base64, "base64");
|
|
207
|
+
}
|
|
208
|
+
if (typeof artifact?.content === "string") {
|
|
209
|
+
return Buffer.from(artifact.content, "utf8");
|
|
210
|
+
}
|
|
211
|
+
return Buffer.alloc(0);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function materializeArtifacts(executionArtifacts = []) {
|
|
215
|
+
return (Array.isArray(executionArtifacts) ? executionArtifacts : []).map((artifact, index) => {
|
|
216
|
+
const content = normalizeArtifactContent(artifact);
|
|
217
|
+
return {
|
|
218
|
+
artifact_id: artifact?.artifact_id || `art_${index + 1}`,
|
|
219
|
+
name: artifact?.name || `artifact-${index + 1}.bin`,
|
|
220
|
+
media_type: artifact?.media_type || "application/octet-stream",
|
|
221
|
+
byte_size: content.length,
|
|
222
|
+
sha256: crypto.createHash("sha256").update(content).digest("hex"),
|
|
223
|
+
delivery: {
|
|
224
|
+
kind: "email_attachment"
|
|
225
|
+
},
|
|
226
|
+
content_base64: content.toString("base64")
|
|
227
|
+
};
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function applyExecutionArtifacts(task, execution = {}) {
|
|
232
|
+
if (!execution || typeof execution !== "object") {
|
|
233
|
+
return execution;
|
|
234
|
+
}
|
|
235
|
+
if (!Array.isArray(execution.artifacts) || execution.artifacts.length === 0) {
|
|
236
|
+
return execution;
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
...execution,
|
|
240
|
+
artifacts: materializeArtifacts(execution.artifacts)
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function sanitizeArtifactsForResult(artifacts = []) {
|
|
245
|
+
return (Array.isArray(artifacts) ? artifacts : []).map(({ content_base64, ...artifact }) => artifact);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function enforceArtifactSizeLimit(task, execution = {}) {
|
|
249
|
+
const maxAttachmentBytes = Number(process.env.EMAIL_MAX_ATTACHMENT_BYTES || 5 * 1024 * 1024);
|
|
250
|
+
const artifacts = Array.isArray(execution.artifacts) ? execution.artifacts : [];
|
|
251
|
+
const totalBytes = artifacts.reduce((sum, artifact) => sum + Number(artifact.byte_size || 0), 0);
|
|
252
|
+
if (totalBytes <= maxAttachmentBytes) {
|
|
253
|
+
return execution;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
status: "error",
|
|
258
|
+
error: {
|
|
259
|
+
code: "RESULT_ARTIFACT_TOO_LARGE",
|
|
260
|
+
message: `artifact payload exceeds email limit ${maxAttachmentBytes} bytes`,
|
|
261
|
+
retryable: false
|
|
262
|
+
},
|
|
263
|
+
schema_valid: true,
|
|
264
|
+
usage: execution.usage || { tokens_in: 0, tokens_out: 0 }
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function signResultPayload(payload, state) {
|
|
269
|
+
const signingBytes = Buffer.from(JSON.stringify(canonicalizeResultPackageForSignature(payload)), "utf8");
|
|
270
|
+
const signature = crypto.sign(null, signingBytes, state.signing.privateKey);
|
|
271
|
+
return {
|
|
272
|
+
...payload,
|
|
273
|
+
signature_algorithm: "Ed25519",
|
|
274
|
+
signer_public_key_pem: state.signing.publicKeyPem,
|
|
275
|
+
signature_base64: signature.toString("base64")
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function sendResultEnvelope(task, state, transport) {
|
|
280
|
+
const target = task.result_delivery?.address || task.return_route || task.reply_to;
|
|
281
|
+
if (!transport || !target || !task.result_package) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
await transport.send({
|
|
286
|
+
message_id: `msg_result_${crypto.randomUUID()}`,
|
|
287
|
+
thread_id: task.thread_id || `req:${task.request_id}`,
|
|
288
|
+
from: state.identity.responder_id,
|
|
289
|
+
to: target,
|
|
290
|
+
type: "task.result",
|
|
291
|
+
request_id: task.request_id,
|
|
292
|
+
responder_id: state.identity.responder_id,
|
|
293
|
+
hotline_id: task.hotline_id,
|
|
294
|
+
verification: task.verification || null,
|
|
295
|
+
body_text: JSON.stringify(task.result_package),
|
|
296
|
+
attachments: ((task.execution_artifacts || []) || []).map((artifact) => ({
|
|
297
|
+
name: artifact.name,
|
|
298
|
+
media_type: artifact.media_type,
|
|
299
|
+
content_base64: artifact.content_base64,
|
|
300
|
+
byte_size: artifact.byte_size
|
|
301
|
+
})),
|
|
302
|
+
result_package: task.result_package,
|
|
303
|
+
sent_at: nowIso()
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function ackPlatform(task, platform) {
|
|
308
|
+
if (!platform?.baseUrl || !platform.apiKey) {
|
|
309
|
+
return { ok: false, skipped: true };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const response = await postJson(platform.baseUrl, `/v1/requests/${task.request_id}/ack`, {
|
|
313
|
+
headers: {
|
|
314
|
+
Authorization: `Bearer ${platform.apiKey}`
|
|
315
|
+
},
|
|
316
|
+
body: {
|
|
317
|
+
responder_id: platform.responderId || task.responder_id,
|
|
318
|
+
hotline_id: task.hotline_id,
|
|
319
|
+
eta_hint_s: Math.max(1, Math.ceil(task.delay_ms / 1000))
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
return { ok: response.status >= 200 && response.status < 300, response };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function postRequestLifecycleEvent(task, platform, eventType, detail = {}) {
|
|
327
|
+
if (!platform?.baseUrl || !platform.apiKey) {
|
|
328
|
+
return { ok: false, skipped: true };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const response = await postJson(platform.baseUrl, `/v1/requests/${task.request_id}/events`, {
|
|
332
|
+
headers: {
|
|
333
|
+
Authorization: `Bearer ${platform.apiKey}`
|
|
334
|
+
},
|
|
335
|
+
body: {
|
|
336
|
+
responder_id: platform.responderId || task.responder_id,
|
|
337
|
+
hotline_id: task.hotline_id,
|
|
338
|
+
event_type: eventType,
|
|
339
|
+
...detail
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
return { ok: response.status >= 200 && response.status < 300, response };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function heartbeatPlatform(state, platform, status = "healthy") {
|
|
347
|
+
if (!platform?.baseUrl || !platform.apiKey || !state?.identity?.responder_id) {
|
|
348
|
+
return { ok: false, skipped: true };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const response = await postJson(platform.baseUrl, `/v1/responders/${state.identity.responder_id}/heartbeat`, {
|
|
352
|
+
headers: {
|
|
353
|
+
Authorization: `Bearer ${platform.apiKey}`
|
|
354
|
+
},
|
|
355
|
+
body: {
|
|
356
|
+
status
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
return { ok: response.status >= 200 && response.status < 300, response };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function introspectTaskToken(task, platform) {
|
|
364
|
+
if (!platform?.baseUrl || !platform.apiKey || !task.task_token) {
|
|
365
|
+
return { active: true, skipped: true };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (String(task.task_token).startsWith("local_task_")) {
|
|
369
|
+
return {
|
|
370
|
+
active: true,
|
|
371
|
+
skipped: true,
|
|
372
|
+
local_issued: true
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const response = await postJson(platform.baseUrl, "/v1/tokens/introspect", {
|
|
377
|
+
headers: {
|
|
378
|
+
Authorization: `Bearer ${platform.apiKey}`
|
|
379
|
+
},
|
|
380
|
+
body: {
|
|
381
|
+
task_token: task.task_token
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
return response.body || { active: false, error: { code: "AUTH_INTROSPECT_FAILED", message: "token introspection request failed", retryable: true } };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function createTaskRecord(input, state, overrides = {}) {
|
|
389
|
+
const requestId = input.request_id || `req_${crypto.randomUUID()}`;
|
|
390
|
+
const acceptedAt = nowIso();
|
|
391
|
+
const payload = input.payload ?? input.task_input ?? null;
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
task_id: input.task_id || `task_${crypto.randomUUID()}`,
|
|
395
|
+
request_id: requestId,
|
|
396
|
+
hotline_id: input.hotline_id || state.identity.hotline_ids[0],
|
|
397
|
+
task_type: input.task_type || null,
|
|
398
|
+
task_input: input.task_input ?? input.payload ?? null,
|
|
399
|
+
payload,
|
|
400
|
+
constraints: input.constraints || null,
|
|
401
|
+
simulate: input.simulate || "success",
|
|
402
|
+
priority: Number(input.priority || 5),
|
|
403
|
+
delay_ms: Number(input.delay_ms || 80),
|
|
404
|
+
lease_ttl_s: Number(input.lease_ttl_s || 30),
|
|
405
|
+
status: "QUEUED",
|
|
406
|
+
acked: true,
|
|
407
|
+
accepted_at: acceptedAt,
|
|
408
|
+
enqueued_at: acceptedAt,
|
|
409
|
+
updated_at: acceptedAt,
|
|
410
|
+
result_package: null,
|
|
411
|
+
result_delivery: overrides.result_delivery ?? input.result_delivery ?? null,
|
|
412
|
+
verification: overrides.verification ?? input.verification ?? null,
|
|
413
|
+
return_route: overrides.return_route ?? input.return_route ?? null,
|
|
414
|
+
reply_to: overrides.reply_to ?? input.reply_to ?? null,
|
|
415
|
+
thread_id: overrides.thread_id ?? input.thread_id ?? `req:${requestId}`,
|
|
416
|
+
task_token: input.task_token || null,
|
|
417
|
+
responder_id: input.responder_id || state.identity.responder_id,
|
|
418
|
+
raw_envelope: input.raw_envelope || null
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function createExecutorContext(task) {
|
|
423
|
+
return {
|
|
424
|
+
requestId: task.request_id,
|
|
425
|
+
responderId: task.responder_id,
|
|
426
|
+
hotlineId: task.hotline_id,
|
|
427
|
+
taskType: task.task_type,
|
|
428
|
+
taskInput: task.task_input,
|
|
429
|
+
payload: task.payload,
|
|
430
|
+
constraints: task.constraints,
|
|
431
|
+
rawEnvelope: task.raw_envelope,
|
|
432
|
+
task
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function reportResponderMetric(platform, task, eventType, detail = {}) {
|
|
437
|
+
const metricKey = `${eventType}:${detail.code || ""}`;
|
|
438
|
+
task.metric_flags ||= {};
|
|
439
|
+
if (task.metric_flags[metricKey]) {
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
task.metric_flags[metricKey] = true;
|
|
444
|
+
await postMetricEvent(platform, {
|
|
445
|
+
source: "responder-controller",
|
|
446
|
+
event_type: eventType,
|
|
447
|
+
request_id: task.request_id,
|
|
448
|
+
responder_id: task.responder_id,
|
|
449
|
+
hotline_id: task.hotline_id,
|
|
450
|
+
...detail
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function validateTaskGuardrails(task, { executor, guardrails = {} } = {}) {
|
|
455
|
+
const hardTimeoutS = Number(task.constraints?.hard_timeout_s);
|
|
456
|
+
const softTimeoutS = Number(task.constraints?.soft_timeout_s);
|
|
457
|
+
const hasHardTimeout = Number.isFinite(hardTimeoutS);
|
|
458
|
+
const hasSoftTimeout = Number.isFinite(softTimeoutS);
|
|
459
|
+
const maxHardTimeoutS = Number.isFinite(Number(guardrails.maxHardTimeoutS))
|
|
460
|
+
? Number(guardrails.maxHardTimeoutS)
|
|
461
|
+
: null;
|
|
462
|
+
const allowedTaskTypes = Array.isArray(guardrails.allowedTaskTypes)
|
|
463
|
+
? guardrails.allowedTaskTypes
|
|
464
|
+
: Array.isArray(executor?.allowedTaskTypes)
|
|
465
|
+
? executor.allowedTaskTypes
|
|
466
|
+
: typeof executor?.getAllowedTaskTypes === "function"
|
|
467
|
+
? executor.getAllowedTaskTypes(task.hotline_id)
|
|
468
|
+
: null;
|
|
469
|
+
|
|
470
|
+
if (hasSoftTimeout && softTimeoutS <= 0) {
|
|
471
|
+
return buildGuardrailError("CONTRACT_INVALID_TIMEOUT", "soft_timeout_s must be greater than 0");
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (hasHardTimeout && hardTimeoutS <= 0) {
|
|
475
|
+
return buildGuardrailError("CONTRACT_INVALID_TIMEOUT", "hard_timeout_s must be greater than 0");
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (hasSoftTimeout && hasHardTimeout && softTimeoutS > hardTimeoutS) {
|
|
479
|
+
return buildGuardrailError("CONTRACT_INVALID_TIMEOUT", "soft_timeout_s cannot exceed hard_timeout_s");
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (hasHardTimeout && maxHardTimeoutS && hardTimeoutS > maxHardTimeoutS) {
|
|
483
|
+
return buildGuardrailError(
|
|
484
|
+
"CONTRACT_TIMEOUT_EXCEEDS_RESPONDER_LIMIT",
|
|
485
|
+
`hard_timeout_s exceeds responder limit ${maxHardTimeoutS}`
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (task.task_type && Array.isArray(allowedTaskTypes) && allowedTaskTypes.length > 0) {
|
|
490
|
+
if (!allowedTaskTypes.includes(task.task_type)) {
|
|
491
|
+
return buildGuardrailError(
|
|
492
|
+
"CONTRACT_TASK_TYPE_UNSUPPORTED",
|
|
493
|
+
`task_type '${task.task_type}' is not allowed by responder guardrail`
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function finalizeTask(task, state, transport, platform, execution) {
|
|
502
|
+
const executionWithArtifacts = enforceArtifactSizeLimit(task, applyExecutionArtifacts(task, execution));
|
|
503
|
+
task.status = "COMPLETED";
|
|
504
|
+
task.completed_at = nowIso();
|
|
505
|
+
task.updated_at = task.completed_at;
|
|
506
|
+
task.execution_artifacts = Array.isArray(executionWithArtifacts.artifacts) ? executionWithArtifacts.artifacts : [];
|
|
507
|
+
task.result_package = signResultPayload(buildResultPayload(task, executionWithArtifacts), state);
|
|
508
|
+
await sendResultEnvelope(task, state, transport);
|
|
509
|
+
const lifecycleEvent =
|
|
510
|
+
task.result_package.status === "ok"
|
|
511
|
+
? { eventType: "COMPLETED", detail: { status: "ok", finished_at: task.completed_at } }
|
|
512
|
+
: {
|
|
513
|
+
eventType: "FAILED",
|
|
514
|
+
detail: {
|
|
515
|
+
status: "error",
|
|
516
|
+
error_code: task.result_package.error?.code || "EXEC_UNKNOWN",
|
|
517
|
+
finished_at: task.completed_at
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
try {
|
|
521
|
+
await postRequestLifecycleEvent(task, platform, lifecycleEvent.eventType, lifecycleEvent.detail);
|
|
522
|
+
} catch {
|
|
523
|
+
// Completion events are observational only and must not invalidate result delivery.
|
|
524
|
+
}
|
|
525
|
+
await reportResponderMetric(
|
|
526
|
+
platform,
|
|
527
|
+
task,
|
|
528
|
+
task.result_package.status === "ok" ? "responder.task.succeeded" : "responder.task.failed",
|
|
529
|
+
task.result_package.status === "error" ? { code: task.result_package.error?.code || "EXEC_UNKNOWN" } : {}
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function failTask(task, state, transport, platform, error) {
|
|
534
|
+
await finalizeTask(task, state, transport, platform, {
|
|
535
|
+
status: "error",
|
|
536
|
+
error: {
|
|
537
|
+
code: "EXECUTOR_RUNTIME_ERROR",
|
|
538
|
+
message: error instanceof Error ? error.message : "unknown_error",
|
|
539
|
+
retryable: false
|
|
540
|
+
},
|
|
541
|
+
schema_valid: true,
|
|
542
|
+
usage: { tokens_in: 0, tokens_out: 0 }
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export function createResponderState(options = {}) {
|
|
547
|
+
const workerConcurrency = Math.max(1, Number(options.workerConcurrency || process.env.RESPONDER_WORKER_CONCURRENCY || 1));
|
|
548
|
+
const signing = options.signing
|
|
549
|
+
? {
|
|
550
|
+
privateKey: crypto.createPrivateKey(options.signing.privateKeyPem),
|
|
551
|
+
publicKeyPem: options.signing.publicKeyPem
|
|
552
|
+
}
|
|
553
|
+
: (() => {
|
|
554
|
+
const generated = crypto.generateKeyPairSync("ed25519");
|
|
555
|
+
return {
|
|
556
|
+
privateKey: generated.privateKey,
|
|
557
|
+
publicKeyPem: generated.publicKey.export({ type: "spki", format: "pem" }).toString()
|
|
558
|
+
};
|
|
559
|
+
})();
|
|
560
|
+
|
|
561
|
+
return {
|
|
562
|
+
tasks: new Map(),
|
|
563
|
+
requestIndex: new Map(),
|
|
564
|
+
queue: [],
|
|
565
|
+
activeTaskIds: [],
|
|
566
|
+
workerConcurrency,
|
|
567
|
+
signing,
|
|
568
|
+
identity: {
|
|
569
|
+
responder_id: options.responderId || "responder_starlight",
|
|
570
|
+
hotline_ids: options.hotlineIds || ["starlight.creative.studio.v1"]
|
|
571
|
+
},
|
|
572
|
+
hotlines: Array.isArray(options.hotlines) ? options.hotlines : [],
|
|
573
|
+
heartbeat: {
|
|
574
|
+
status: "healthy",
|
|
575
|
+
last_sent_at: null
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export function serializeResponderState(state) {
|
|
581
|
+
return {
|
|
582
|
+
tasks: Array.from(state.tasks.entries()),
|
|
583
|
+
requestIndex: Array.from(state.requestIndex.entries()),
|
|
584
|
+
queue: [...state.queue],
|
|
585
|
+
activeTaskIds: [...(state.activeTaskIds || [])],
|
|
586
|
+
workerConcurrency: state.workerConcurrency,
|
|
587
|
+
identity: state.identity,
|
|
588
|
+
hotlines: state.hotlines,
|
|
589
|
+
heartbeat: state.heartbeat
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export function hydrateResponderState(state, snapshot) {
|
|
594
|
+
if (!snapshot) {
|
|
595
|
+
return state;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
state.tasks.clear();
|
|
599
|
+
for (const [taskId, task] of snapshot.tasks || []) {
|
|
600
|
+
state.tasks.set(taskId, task);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
state.requestIndex.clear();
|
|
604
|
+
for (const [requestId, taskId] of snapshot.requestIndex || []) {
|
|
605
|
+
state.requestIndex.set(requestId, taskId);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
state.queue = Array.isArray(snapshot.queue) ? [...snapshot.queue] : [];
|
|
609
|
+
state.activeTaskIds = [];
|
|
610
|
+
state.workerConcurrency = Math.max(1, Number(snapshot.workerConcurrency || state.workerConcurrency || 1));
|
|
611
|
+
state.identity = snapshot.identity || state.identity;
|
|
612
|
+
state.hotlines = Array.isArray(snapshot.hotlines) ? snapshot.hotlines : state.hotlines;
|
|
613
|
+
state.heartbeat = snapshot.heartbeat || state.heartbeat;
|
|
614
|
+
return state;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function getTaskByRequestId(state, requestId) {
|
|
618
|
+
const taskId = state.requestIndex.get(requestId);
|
|
619
|
+
return taskId ? state.tasks.get(taskId) || null : null;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function rememberTask(state, task) {
|
|
623
|
+
state.tasks.set(task.task_id, task);
|
|
624
|
+
state.requestIndex.set(task.request_id, task.task_id);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function workerConcurrencyForState(state, override = null) {
|
|
628
|
+
return Math.max(1, Number(override || state.workerConcurrency || 1));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function runQueuedTask(task, state, { executor, transport = null, platform = null, onStateChanged = null } = {}) {
|
|
632
|
+
await persistResponderState(onStateChanged, state);
|
|
633
|
+
await new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(task.delay_ms || 0))));
|
|
634
|
+
|
|
635
|
+
try {
|
|
636
|
+
const execution = await executor.execute(createExecutorContext(task));
|
|
637
|
+
if (execution?.deferred === true) {
|
|
638
|
+
task.status = "RUNNING";
|
|
639
|
+
task.updated_at = nowIso();
|
|
640
|
+
task.deferred_reason = execution.reason || "deferred";
|
|
641
|
+
await persistResponderState(onStateChanged, state);
|
|
642
|
+
} else {
|
|
643
|
+
await finalizeTask(task, state, transport, platform, execution);
|
|
644
|
+
await persistResponderState(onStateChanged, state);
|
|
645
|
+
}
|
|
646
|
+
} catch (error) {
|
|
647
|
+
await failTask(task, state, transport, platform, error);
|
|
648
|
+
await persistResponderState(onStateChanged, state);
|
|
649
|
+
} finally {
|
|
650
|
+
state.activeTaskIds = (state.activeTaskIds || []).filter((taskId) => taskId !== task.task_id);
|
|
651
|
+
await persistResponderState(onStateChanged, state);
|
|
652
|
+
scheduleProcessQueue(state, { executor, transport, platform, onStateChanged });
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function scheduleProcessQueue(state, { executor, transport = null, platform = null, onStateChanged = null, workerConcurrency = null } = {}) {
|
|
657
|
+
const maxWorkers = workerConcurrencyForState(state, workerConcurrency);
|
|
658
|
+
while ((state.activeTaskIds || []).length < maxWorkers) {
|
|
659
|
+
const nextTaskId = state.queue.shift();
|
|
660
|
+
if (!nextTaskId) {
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const task = state.tasks.get(nextTaskId);
|
|
665
|
+
if (!task) {
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
task.status = "RUNNING";
|
|
670
|
+
task.started_at = nowIso();
|
|
671
|
+
task.updated_at = task.started_at;
|
|
672
|
+
task.lease_expires_at = new Date(Date.now() + task.lease_ttl_s * 1000).toISOString();
|
|
673
|
+
state.activeTaskIds = [...(state.activeTaskIds || []), task.task_id];
|
|
674
|
+
|
|
675
|
+
void runQueuedTask(task, state, {
|
|
676
|
+
executor,
|
|
677
|
+
transport,
|
|
678
|
+
platform,
|
|
679
|
+
onStateChanged
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async function enqueueTask(
|
|
685
|
+
state,
|
|
686
|
+
task,
|
|
687
|
+
{ executor, transport = null, platform = null, onStateChanged = null, workerConcurrency = null } = {}
|
|
688
|
+
) {
|
|
689
|
+
rememberTask(state, task);
|
|
690
|
+
state.queue.push(task.task_id);
|
|
691
|
+
|
|
692
|
+
state.queue.sort((leftId, rightId) => {
|
|
693
|
+
const left = state.tasks.get(leftId);
|
|
694
|
+
const right = state.tasks.get(rightId);
|
|
695
|
+
if (!left || !right) {
|
|
696
|
+
return 0;
|
|
697
|
+
}
|
|
698
|
+
if (left.priority !== right.priority) {
|
|
699
|
+
return left.priority - right.priority;
|
|
700
|
+
}
|
|
701
|
+
return left.enqueued_at.localeCompare(right.enqueued_at);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
await persistResponderState(onStateChanged, state);
|
|
705
|
+
scheduleProcessQueue(state, { executor, transport, platform, onStateChanged, workerConcurrency });
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async function processResponderInbox(state, {
|
|
709
|
+
executor,
|
|
710
|
+
transport = null,
|
|
711
|
+
platform = null,
|
|
712
|
+
guardrails = {},
|
|
713
|
+
onStateChanged = null,
|
|
714
|
+
receiver = null,
|
|
715
|
+
limit = 10
|
|
716
|
+
} = {}) {
|
|
717
|
+
if (!transport) {
|
|
718
|
+
return { accepted: [] };
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const polled = await transport.poll({
|
|
722
|
+
limit,
|
|
723
|
+
receiver: receiver || state.identity.responder_id
|
|
724
|
+
});
|
|
725
|
+
const accepted = [];
|
|
726
|
+
|
|
727
|
+
for (const envelope of polled.items) {
|
|
728
|
+
if (envelope.responder_id && envelope.responder_id !== state.identity.responder_id) {
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
if (envelope.hotline_id && !state.identity.hotline_ids.includes(envelope.hotline_id)) {
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
await postMetricEvent(platform, {
|
|
736
|
+
source: "responder-controller",
|
|
737
|
+
event_type: "responder.task.received",
|
|
738
|
+
request_id: envelope.request_id || null,
|
|
739
|
+
responder_id: state.identity.responder_id,
|
|
740
|
+
hotline_id: envelope.hotline_id || null
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
const existing = getTaskByRequestId(state, envelope.request_id);
|
|
744
|
+
if (existing) {
|
|
745
|
+
if (existing.result_package) {
|
|
746
|
+
await sendResultEnvelope(
|
|
747
|
+
{
|
|
748
|
+
...existing,
|
|
749
|
+
return_route: envelope.from || existing.return_route,
|
|
750
|
+
reply_to: envelope.from || existing.reply_to,
|
|
751
|
+
thread_id: envelope.thread_id || existing.thread_id
|
|
752
|
+
},
|
|
753
|
+
state,
|
|
754
|
+
transport
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
await transport.ack(envelope.message_id);
|
|
759
|
+
accepted.push({
|
|
760
|
+
message_id: envelope.message_id,
|
|
761
|
+
task_id: existing.task_id,
|
|
762
|
+
deduped: true,
|
|
763
|
+
replayed: Boolean(existing.result_package)
|
|
764
|
+
});
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const task = createTaskRecord(
|
|
769
|
+
{
|
|
770
|
+
...envelope,
|
|
771
|
+
raw_envelope: envelope
|
|
772
|
+
},
|
|
773
|
+
state,
|
|
774
|
+
{
|
|
775
|
+
return_route: envelope.from || null,
|
|
776
|
+
reply_to: envelope.from || "caller-controller",
|
|
777
|
+
thread_id: envelope.thread_id || `req:${envelope.request_id}`,
|
|
778
|
+
result_delivery: envelope.result_delivery || null,
|
|
779
|
+
verification: envelope.verification || null
|
|
780
|
+
}
|
|
781
|
+
);
|
|
782
|
+
|
|
783
|
+
const introspection = await introspectTaskToken(task, platform);
|
|
784
|
+
if (introspection.active === false) {
|
|
785
|
+
task.status = "COMPLETED";
|
|
786
|
+
task.completed_at = nowIso();
|
|
787
|
+
task.updated_at = task.completed_at;
|
|
788
|
+
task.result_package = signResultPayload(
|
|
789
|
+
buildErrorResultPayload(task, {
|
|
790
|
+
code: introspection.error?.code || introspection.error || "AUTH_TOKEN_INVALID",
|
|
791
|
+
message: introspection.error?.message || "Task token rejected during responder validation"
|
|
792
|
+
}),
|
|
793
|
+
state
|
|
794
|
+
);
|
|
795
|
+
rememberTask(state, task);
|
|
796
|
+
await sendResultEnvelope(task, state, transport);
|
|
797
|
+
await reportResponderMetric(platform, task, "responder.task.rejected", {
|
|
798
|
+
code: introspection.error?.code || introspection.error || "AUTH_TOKEN_INVALID"
|
|
799
|
+
});
|
|
800
|
+
await persistResponderState(onStateChanged, state);
|
|
801
|
+
} else {
|
|
802
|
+
const guardrailError = validateTaskGuardrails(task, { executor, guardrails });
|
|
803
|
+
if (guardrailError) {
|
|
804
|
+
task.status = "COMPLETED";
|
|
805
|
+
task.completed_at = nowIso();
|
|
806
|
+
task.updated_at = task.completed_at;
|
|
807
|
+
task.result_package = signResultPayload(buildResultPayload(task, guardrailError), state);
|
|
808
|
+
rememberTask(state, task);
|
|
809
|
+
await sendResultEnvelope(task, state, transport);
|
|
810
|
+
await reportResponderMetric(platform, task, "responder.task.rejected", {
|
|
811
|
+
code: guardrailError.error.code
|
|
812
|
+
});
|
|
813
|
+
await persistResponderState(onStateChanged, state);
|
|
814
|
+
} else {
|
|
815
|
+
await enqueueTask(state, task, { executor, transport, platform, onStateChanged });
|
|
816
|
+
await reportResponderMetric(platform, task, "responder.task.accepted");
|
|
817
|
+
const acked = await ackPlatform(task, platform);
|
|
818
|
+
task.acked = acked.ok;
|
|
819
|
+
await persistResponderState(onStateChanged, state);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
await transport.ack(envelope.message_id);
|
|
824
|
+
accepted.push({ message_id: envelope.message_id, task_id: task.task_id });
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
return { accepted };
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
export function startResponderHeartbeatLoop({
|
|
831
|
+
state,
|
|
832
|
+
platform = null,
|
|
833
|
+
intervalMs = 30000,
|
|
834
|
+
logger = console,
|
|
835
|
+
onStateChanged = null
|
|
836
|
+
} = {}) {
|
|
837
|
+
if (!platform?.baseUrl || !platform.apiKey || !state?.identity?.responder_id) {
|
|
838
|
+
return () => {};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
let stopped = false;
|
|
842
|
+
|
|
843
|
+
async function sendHeartbeat() {
|
|
844
|
+
if (stopped) {
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
try {
|
|
849
|
+
const result = await heartbeatPlatform(state, platform, state.heartbeat?.status || "healthy");
|
|
850
|
+
if (result.ok) {
|
|
851
|
+
state.heartbeat.last_sent_at = nowIso();
|
|
852
|
+
await persistResponderState(onStateChanged, state);
|
|
853
|
+
}
|
|
854
|
+
} catch (error) {
|
|
855
|
+
logger?.warn?.(
|
|
856
|
+
`[responder-heartbeat] failed for ${state.identity.responder_id}: ${
|
|
857
|
+
error instanceof Error ? error.message : "unknown_error"
|
|
858
|
+
}`
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
void sendHeartbeat();
|
|
864
|
+
const timer = setInterval(() => {
|
|
865
|
+
void sendHeartbeat();
|
|
866
|
+
}, intervalMs);
|
|
867
|
+
|
|
868
|
+
return () => {
|
|
869
|
+
stopped = true;
|
|
870
|
+
clearInterval(timer);
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
export function startResponderInboxLoop({
|
|
875
|
+
state,
|
|
876
|
+
executor = createSimulatorExecutor(),
|
|
877
|
+
transport = null,
|
|
878
|
+
platform = null,
|
|
879
|
+
guardrails = {},
|
|
880
|
+
onStateChanged = null,
|
|
881
|
+
intervalMs = 250,
|
|
882
|
+
receiver = null,
|
|
883
|
+
logger = console
|
|
884
|
+
} = {}) {
|
|
885
|
+
if (!transport) {
|
|
886
|
+
return () => {};
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
let stopped = false;
|
|
890
|
+
let running = false;
|
|
891
|
+
|
|
892
|
+
async function pullInbox() {
|
|
893
|
+
if (stopped || running) {
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
running = true;
|
|
897
|
+
try {
|
|
898
|
+
await processResponderInbox(state, {
|
|
899
|
+
executor,
|
|
900
|
+
transport,
|
|
901
|
+
platform,
|
|
902
|
+
guardrails,
|
|
903
|
+
onStateChanged,
|
|
904
|
+
receiver
|
|
905
|
+
});
|
|
906
|
+
} catch (error) {
|
|
907
|
+
logger?.warn?.(`[responder-inbox] pull failed: ${error instanceof Error ? error.message : "unknown_error"}`);
|
|
908
|
+
} finally {
|
|
909
|
+
running = false;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
void pullInbox();
|
|
914
|
+
const timer = setInterval(() => {
|
|
915
|
+
void pullInbox();
|
|
916
|
+
}, intervalMs);
|
|
917
|
+
|
|
918
|
+
return () => {
|
|
919
|
+
stopped = true;
|
|
920
|
+
clearInterval(timer);
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
export function createResponderControllerServer({
|
|
925
|
+
state = createResponderState(),
|
|
926
|
+
serviceName = "responder-controller",
|
|
927
|
+
transport = null,
|
|
928
|
+
platform = null,
|
|
929
|
+
executor = createSimulatorExecutor(),
|
|
930
|
+
guardrails = {},
|
|
931
|
+
background = {},
|
|
932
|
+
onStateChanged = null,
|
|
933
|
+
onPlatformConfigured = null
|
|
934
|
+
} = {}) {
|
|
935
|
+
const workerConcurrency = workerConcurrencyForState(state, background.workerConcurrency);
|
|
936
|
+
state.workerConcurrency = workerConcurrency;
|
|
937
|
+
const server = http.createServer(async (req, res) => {
|
|
938
|
+
const method = req.method || "GET";
|
|
939
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
940
|
+
const pathname = url.pathname;
|
|
941
|
+
|
|
942
|
+
try {
|
|
943
|
+
if (method === "OPTIONS") {
|
|
944
|
+
res.writeHead(204, {
|
|
945
|
+
"access-control-allow-origin": "*",
|
|
946
|
+
"access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
|
|
947
|
+
"access-control-allow-headers": "Content-Type, Authorization, X-Platform-Api-Key"
|
|
948
|
+
});
|
|
949
|
+
res.end();
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
if (method === "GET" && pathname === "/healthz") {
|
|
954
|
+
sendJson(res, 200, { ok: true, service: serviceName });
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
if (method === "GET" && pathname === "/readyz") {
|
|
959
|
+
sendJson(res, 200, { ready: true, service: serviceName });
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
if (method === "GET" && pathname === "/") {
|
|
964
|
+
sendJson(res, 200, {
|
|
965
|
+
service: serviceName,
|
|
966
|
+
status: "running",
|
|
967
|
+
executor: executor.name || "unknown",
|
|
968
|
+
responder_id: state.identity.responder_id,
|
|
969
|
+
hotline_ids: state.identity.hotline_ids,
|
|
970
|
+
worker_concurrency: workerConcurrency,
|
|
971
|
+
configured_hotlines:
|
|
972
|
+
typeof executor?.listHotlines === "function"
|
|
973
|
+
? executor.listHotlines()
|
|
974
|
+
: Array.isArray(state.hotlines)
|
|
975
|
+
? state.hotlines
|
|
976
|
+
: [],
|
|
977
|
+
guardrails: {
|
|
978
|
+
max_hard_timeout_s: Number.isFinite(Number(guardrails.maxHardTimeoutS))
|
|
979
|
+
? Number(guardrails.maxHardTimeoutS)
|
|
980
|
+
: null,
|
|
981
|
+
allowed_task_types: Array.isArray(guardrails.allowedTaskTypes) ? guardrails.allowedTaskTypes : null
|
|
982
|
+
}
|
|
983
|
+
});
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
if (method === "GET" && pathname === "/controller/public-key") {
|
|
988
|
+
sendJson(res, 200, {
|
|
989
|
+
responder_id: state.identity.responder_id,
|
|
990
|
+
public_key_pem: state.signing.publicKeyPem
|
|
991
|
+
});
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if (method === "POST" && pathname === "/controller/register") {
|
|
996
|
+
try {
|
|
997
|
+
const body = await parseJsonBody(req);
|
|
998
|
+
const responderId = body.responder_id || state.identity.responder_id;
|
|
999
|
+
const hotlineId = body.hotline_id || state.identity.hotline_ids[0];
|
|
1000
|
+
const headerApiKey = req.headers["x-platform-api-key"];
|
|
1001
|
+
const registerPlatform = {
|
|
1002
|
+
...platform,
|
|
1003
|
+
apiKey:
|
|
1004
|
+
(typeof headerApiKey === "string" && headerApiKey.trim()) ||
|
|
1005
|
+
body.platform_api_key ||
|
|
1006
|
+
platform?.apiKey ||
|
|
1007
|
+
null
|
|
1008
|
+
};
|
|
1009
|
+
const registered = await registerResponderOnPlatform(registerPlatform, {
|
|
1010
|
+
responder_id: responderId,
|
|
1011
|
+
hotline_id: hotlineId,
|
|
1012
|
+
display_name: body.display_name || `${responderId} ${hotlineId}`,
|
|
1013
|
+
template_ref: body.template_ref || `${hotlineId}@v1`,
|
|
1014
|
+
task_delivery_address: body.task_delivery_address || `local://relay/${responderId}/${hotlineId}`,
|
|
1015
|
+
responder_public_key_pem: state.signing.publicKeyPem,
|
|
1016
|
+
task_types: body.task_types || [],
|
|
1017
|
+
capabilities: body.capabilities || [],
|
|
1018
|
+
tags: body.tags || [],
|
|
1019
|
+
input_schema: body.input_schema || null,
|
|
1020
|
+
output_schema: body.output_schema || null,
|
|
1021
|
+
contact_email: body.contact_email || null,
|
|
1022
|
+
support_email: body.support_email || null
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
state.identity.responder_id = registered.responder_id;
|
|
1026
|
+
state.identity.hotline_ids = Array.from(new Set([...(state.identity.hotline_ids || []), registered.hotline_id]));
|
|
1027
|
+
if (platform) {
|
|
1028
|
+
platform.apiKey = registered.api_key || platform.apiKey;
|
|
1029
|
+
platform.responderId = registered.responder_id;
|
|
1030
|
+
}
|
|
1031
|
+
await persistResponderState(onStateChanged, state);
|
|
1032
|
+
if (typeof onPlatformConfigured === "function") {
|
|
1033
|
+
await onPlatformConfigured({
|
|
1034
|
+
platform,
|
|
1035
|
+
state,
|
|
1036
|
+
registered
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
sendJson(res, 201, registered);
|
|
1040
|
+
} catch (error) {
|
|
1041
|
+
if (error instanceof Error && error.message === "responder_platform_base_url_required") {
|
|
1042
|
+
sendError(res, 409, "PLATFORM_NOT_CONFIGURED", "platform base URL is not configured");
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
if (error?.response) {
|
|
1046
|
+
sendJson(res, error.response.status, error.response.body || { error: { code: "RESPONDER_PLATFORM_REGISTER_FAILED", message: "registration rejected by platform", retryable: false } });
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
sendError(res, 502, "RESPONDER_PLATFORM_REGISTER_FAILED", error instanceof Error ? error.message : "unknown_error", { retryable: true });
|
|
1050
|
+
}
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
if (method === "POST" && pathname === "/controller/tasks") {
|
|
1055
|
+
const body = await parseJsonBody(req);
|
|
1056
|
+
const task = createTaskRecord(body, state);
|
|
1057
|
+
|
|
1058
|
+
const existing = getTaskByRequestId(state, task.request_id);
|
|
1059
|
+
if (existing) {
|
|
1060
|
+
sendJson(res, existing.result_package ? 200 : 202, {
|
|
1061
|
+
accepted: !existing.result_package,
|
|
1062
|
+
deduped: true,
|
|
1063
|
+
replayed: Boolean(existing.result_package),
|
|
1064
|
+
task_id: existing.task_id,
|
|
1065
|
+
request_id: existing.request_id,
|
|
1066
|
+
status: existing.status,
|
|
1067
|
+
result_package: existing.result_package || null
|
|
1068
|
+
});
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
await enqueueTask(state, task, { executor, transport, platform, onStateChanged, workerConcurrency });
|
|
1073
|
+
|
|
1074
|
+
sendJson(res, 202, {
|
|
1075
|
+
accepted: true,
|
|
1076
|
+
task_id: task.task_id,
|
|
1077
|
+
request_id: task.request_id,
|
|
1078
|
+
status: task.status,
|
|
1079
|
+
queue_policy: {
|
|
1080
|
+
mode: "priority_fifo",
|
|
1081
|
+
lease_ttl_s: task.lease_ttl_s,
|
|
1082
|
+
worker_concurrency: workerConcurrency
|
|
1083
|
+
}
|
|
1084
|
+
});
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
if (method === "POST" && pathname === "/controller/inbox/pull") {
|
|
1089
|
+
if (!transport) {
|
|
1090
|
+
sendError(res, 409, "TRANSPORT_NOT_CONFIGURED", "message transport is not configured");
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const body = await parseJsonBody(req);
|
|
1095
|
+
const result = await processResponderInbox(state, {
|
|
1096
|
+
executor,
|
|
1097
|
+
transport,
|
|
1098
|
+
platform,
|
|
1099
|
+
guardrails,
|
|
1100
|
+
onStateChanged,
|
|
1101
|
+
receiver: body.receiver || state.identity.responder_id,
|
|
1102
|
+
limit: Number(body.limit || 10)
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
sendJson(res, 200, { accepted: result.accepted });
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
if (method === "GET" && pathname === "/controller/queue") {
|
|
1110
|
+
const queued = state.queue.map((taskId) => state.tasks.get(taskId)).filter(Boolean);
|
|
1111
|
+
const runningIds = new Set(state.activeTaskIds || []);
|
|
1112
|
+
const running = Array.from(state.tasks.values()).filter(
|
|
1113
|
+
(task) => task.status === "RUNNING" || runningIds.has(task.task_id)
|
|
1114
|
+
);
|
|
1115
|
+
sendJson(res, 200, { queued, running });
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const taskMatch = pathname.match(/^\/controller\/tasks\/([^/]+)$/);
|
|
1120
|
+
if (method === "GET" && taskMatch) {
|
|
1121
|
+
const task = state.tasks.get(taskMatch[1]);
|
|
1122
|
+
if (!task) {
|
|
1123
|
+
sendError(res, 404, "TASK_NOT_FOUND", "task does not exist");
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
sendJson(res, 200, task);
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const resultMatch = pathname.match(/^\/controller\/tasks\/([^/]+)\/result$/);
|
|
1132
|
+
if (method === "GET" && resultMatch) {
|
|
1133
|
+
const task = state.tasks.get(resultMatch[1]);
|
|
1134
|
+
if (!task) {
|
|
1135
|
+
sendError(res, 404, "TASK_NOT_FOUND", "task does not exist");
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
if (!task.result_package) {
|
|
1140
|
+
sendJson(res, 202, { available: false, status: task.status });
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
sendJson(res, 200, { available: true, status: task.status, result_package: task.result_package });
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
const replayMatch = pathname.match(/^\/controller\/tasks\/([^/]+)\/replay$/);
|
|
1149
|
+
if (method === "POST" && replayMatch) {
|
|
1150
|
+
const task = state.tasks.get(replayMatch[1]);
|
|
1151
|
+
if (!task) {
|
|
1152
|
+
sendError(res, 404, "TASK_NOT_FOUND", "task does not exist");
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
if (!task.result_package) {
|
|
1157
|
+
sendError(res, 409, "RESULT_NOT_READY", "task result is not yet available", { status: task.status });
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
sendJson(res, 200, { replayed: true, result_package: task.result_package });
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
sendError(res, 404, "not_found", "no matching route", { path: pathname });
|
|
1166
|
+
} catch (error) {
|
|
1167
|
+
if (error.message === "invalid_json") {
|
|
1168
|
+
sendError(res, 400, "CONTRACT_INVALID_JSON", "request body is not valid JSON");
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
sendError(res, 500, "RESPONDER_RUNTIME_INTERNAL_ERROR", error instanceof Error ? error.message : "unknown_error", { retryable: true });
|
|
1173
|
+
}
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
if (background.enabled === true) {
|
|
1177
|
+
const stopInboxLoop = startResponderInboxLoop({
|
|
1178
|
+
state,
|
|
1179
|
+
executor,
|
|
1180
|
+
transport,
|
|
1181
|
+
platform,
|
|
1182
|
+
guardrails,
|
|
1183
|
+
onStateChanged,
|
|
1184
|
+
intervalMs: Number(background.inboxPollIntervalMs || 250),
|
|
1185
|
+
receiver: background.receiver || state.identity.responder_id
|
|
1186
|
+
});
|
|
1187
|
+
server.on("close", () => {
|
|
1188
|
+
stopInboxLoop();
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
return server;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
export {
|
|
1196
|
+
createConfiguredHotlineExecutor,
|
|
1197
|
+
createExampleFunctionExecutor,
|
|
1198
|
+
createFunctionExecutor,
|
|
1199
|
+
createSimulatorExecutor,
|
|
1200
|
+
createHotlineRouterExecutor,
|
|
1201
|
+
deferTask
|
|
1202
|
+
};
|