@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,1612 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
|
|
4
|
+
import { buildStructuredError, canonicalizeResultPackageForSignature } from "@delexec/contracts";
|
|
5
|
+
|
|
6
|
+
export const CALLER_TERMINAL_STATUSES = Object.freeze(["SUCCEEDED", "FAILED", "UNVERIFIED", "TIMED_OUT"]);
|
|
7
|
+
export const CALLER_ACTIVE_STATUSES = Object.freeze(["CREATED", "SENT", "ACKED"]);
|
|
8
|
+
|
|
9
|
+
const TERMINAL_STATUS_SET = new Set(CALLER_TERMINAL_STATUSES);
|
|
10
|
+
const ACTIVE_STATUS_SET = new Set(CALLER_ACTIVE_STATUSES);
|
|
11
|
+
|
|
12
|
+
function nowIso() {
|
|
13
|
+
return new Date().toISOString();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseJsonBody(req) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const chunks = [];
|
|
19
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
20
|
+
req.on("end", () => {
|
|
21
|
+
if (chunks.length === 0) {
|
|
22
|
+
resolve({});
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
27
|
+
} catch {
|
|
28
|
+
reject(new Error("invalid_json"));
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
req.on("error", reject);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sendJson(res, statusCode, data) {
|
|
36
|
+
res.writeHead(statusCode, {
|
|
37
|
+
"content-type": "application/json; charset=utf-8",
|
|
38
|
+
"access-control-allow-origin": "*",
|
|
39
|
+
"access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
|
|
40
|
+
"access-control-allow-headers": "Content-Type, Authorization, X-Platform-Api-Key"
|
|
41
|
+
});
|
|
42
|
+
res.end(JSON.stringify(data));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sendError(res, statusCode, code, message, { retryable, ...extra } = {}) {
|
|
46
|
+
sendJson(res, statusCode, buildStructuredError(code, message, { retryable, ...extra }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function requestJson(baseUrl, pathname, { method = "GET", headers = {}, body } = {}) {
|
|
50
|
+
const response = await fetch(new URL(pathname, baseUrl), {
|
|
51
|
+
method,
|
|
52
|
+
headers: {
|
|
53
|
+
...headers,
|
|
54
|
+
...(body === undefined ? {} : { "content-type": "application/json; charset=utf-8" })
|
|
55
|
+
},
|
|
56
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const text = await response.text();
|
|
60
|
+
return {
|
|
61
|
+
status: response.status,
|
|
62
|
+
headers: response.headers,
|
|
63
|
+
body: text ? JSON.parse(text) : null
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function createUpstreamError(code, response) {
|
|
68
|
+
const error = new Error(code);
|
|
69
|
+
error.code = code;
|
|
70
|
+
error.response = response;
|
|
71
|
+
return error;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizePemString(value) {
|
|
75
|
+
if (typeof value !== "string") {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const trimmed = value.trim();
|
|
79
|
+
if (!trimmed) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return trimmed.replace(/\\n/g, "\n");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isLocalOnlyRegistrationMode(value) {
|
|
86
|
+
return typeof value === "string" && value.trim().toLowerCase() === "local_only";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sendUpstreamError(res, error, fallbackCode, fallbackMessage = "upstream service error") {
|
|
90
|
+
if (error?.response) {
|
|
91
|
+
sendJson(res, error.response.status, error.response.body || { error: { code: fallbackCode, message: fallbackMessage, retryable: true } });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
sendError(res, 502, fallbackCode, error instanceof Error ? error.message : fallbackMessage, { retryable: true });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function loadCallerConfig() {
|
|
99
|
+
return {
|
|
100
|
+
ack_deadline_s: Number(process.env.ACK_DEADLINE_S || 120),
|
|
101
|
+
timeout_confirmation_mode: process.env.TIMEOUT_CONFIRMATION_MODE || "ask_by_default",
|
|
102
|
+
hard_timeout_auto_finalize: String(process.env.HARD_TIMEOUT_AUTO_FINALIZE || "true") === "true",
|
|
103
|
+
poll_interval_active_s: Number(process.env.CALLER_CONTROLLER_POLL_INTERVAL_ACTIVE_S || 5),
|
|
104
|
+
poll_interval_backoff_s: Number(process.env.CALLER_CONTROLLER_POLL_INTERVAL_BACKOFF_S || 15),
|
|
105
|
+
events_sync_batch_size: Number(process.env.CALLER_CONTROLLER_EVENTS_SYNC_BATCH_SIZE || 25)
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function createCallerState() {
|
|
110
|
+
return { requests: new Map() };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function serializeCallerState(state) {
|
|
114
|
+
return {
|
|
115
|
+
requests: Array.from(state.requests.entries())
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function hydrateCallerState(state, snapshot) {
|
|
120
|
+
if (!snapshot) {
|
|
121
|
+
return state;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
state.requests.clear();
|
|
125
|
+
for (const [requestId, request] of snapshot.requests || []) {
|
|
126
|
+
state.requests.set(requestId, request);
|
|
127
|
+
}
|
|
128
|
+
return state;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function createCallerPlatformClient({ baseUrl, apiKey } = {}) {
|
|
132
|
+
if (!baseUrl) {
|
|
133
|
+
throw new Error("caller_platform_base_url_required");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function authHeaders(required = false) {
|
|
137
|
+
if (!apiKey) {
|
|
138
|
+
if (required) {
|
|
139
|
+
throw new Error("caller_platform_api_key_required");
|
|
140
|
+
}
|
|
141
|
+
return {};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
Authorization: `Bearer ${apiKey}`
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
config: {
|
|
151
|
+
baseUrl,
|
|
152
|
+
apiKey: apiKey || null
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
async registerCaller({ contactEmail, contact_email, email } = {}) {
|
|
156
|
+
const response = await requestJson(baseUrl, "/v1/users/register", {
|
|
157
|
+
method: "POST",
|
|
158
|
+
body: {
|
|
159
|
+
...(contactEmail || contact_email ? { contact_email: contactEmail || contact_email } : {}),
|
|
160
|
+
...(email ? { email } : {})
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
if (response.status !== 201) {
|
|
165
|
+
throw createUpstreamError("CALLER_PLATFORM_REGISTER_FAILED", response);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return response.body;
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
async listCatalogHotlines(filters = {}) {
|
|
172
|
+
const params = new URLSearchParams();
|
|
173
|
+
if (filters.status) {
|
|
174
|
+
params.set("status", filters.status);
|
|
175
|
+
}
|
|
176
|
+
if (filters.availability_status) {
|
|
177
|
+
params.set("availability_status", filters.availability_status);
|
|
178
|
+
}
|
|
179
|
+
if (filters.task_type) {
|
|
180
|
+
params.set("task_type", filters.task_type);
|
|
181
|
+
}
|
|
182
|
+
if (filters.capability) {
|
|
183
|
+
params.set("capability", filters.capability);
|
|
184
|
+
}
|
|
185
|
+
if (filters.tag) {
|
|
186
|
+
params.set("tag", filters.tag);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const pathname = `/v2/hotlines${params.size > 0 ? `?${params.toString()}` : ""}`;
|
|
190
|
+
const response = await requestJson(baseUrl, pathname, {
|
|
191
|
+
headers: authHeaders(false)
|
|
192
|
+
});
|
|
193
|
+
if (response.status !== 200) {
|
|
194
|
+
throw createUpstreamError("CALLER_PLATFORM_CATALOG_FAILED", response);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let items = response.body?.items || [];
|
|
198
|
+
if (filters.responder_id) {
|
|
199
|
+
items = items.filter((item) => item.responder_id === filters.responder_id);
|
|
200
|
+
}
|
|
201
|
+
if (filters.hotline_id) {
|
|
202
|
+
items = items.filter((item) => item.hotline_id === filters.hotline_id);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
...response.body,
|
|
207
|
+
items
|
|
208
|
+
};
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
async registerResponder(body = {}) {
|
|
212
|
+
const response = await requestJson(baseUrl, "/v2/responders/register", {
|
|
213
|
+
method: "POST",
|
|
214
|
+
headers: authHeaders(true),
|
|
215
|
+
body
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
if (response.status !== 201) {
|
|
219
|
+
throw createUpstreamError("CALLER_PLATFORM_RESPONDER_REGISTER_FAILED", response);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return response.body;
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
async issueTaskToken({ requestId, responderId, hotlineId }) {
|
|
226
|
+
const response = await requestJson(baseUrl, "/v1/tokens/task", {
|
|
227
|
+
method: "POST",
|
|
228
|
+
headers: authHeaders(true),
|
|
229
|
+
body: {
|
|
230
|
+
request_id: requestId,
|
|
231
|
+
responder_id: responderId,
|
|
232
|
+
hotline_id: hotlineId
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
if (response.status !== 201) {
|
|
237
|
+
throw createUpstreamError("CALLER_PLATFORM_TOKEN_FAILED", response);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return response.body;
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
async getDeliveryMeta({ requestId, responderId, hotlineId, taskToken, resultDelivery }) {
|
|
244
|
+
const response = await requestJson(baseUrl, `/v1/requests/${requestId}/delivery-meta`, {
|
|
245
|
+
method: "POST",
|
|
246
|
+
headers: authHeaders(true),
|
|
247
|
+
body: {
|
|
248
|
+
responder_id: responderId,
|
|
249
|
+
hotline_id: hotlineId,
|
|
250
|
+
task_token: taskToken,
|
|
251
|
+
result_delivery: resultDelivery
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
if (response.status !== 200) {
|
|
256
|
+
throw createUpstreamError("CALLER_PLATFORM_DELIVERY_META_FAILED", response);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return response.body;
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
async getRequestEvents(requestId) {
|
|
263
|
+
const response = await requestJson(baseUrl, `/v1/requests/${requestId}/events`, {
|
|
264
|
+
headers: authHeaders(true)
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
if (response.status !== 200) {
|
|
268
|
+
throw createUpstreamError("CALLER_PLATFORM_EVENTS_FAILED", response);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return response.body;
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
async getRequestEventsBatch(requestIds = []) {
|
|
275
|
+
const response = await requestJson(baseUrl, "/v1/requests/events/batch", {
|
|
276
|
+
method: "POST",
|
|
277
|
+
headers: authHeaders(true),
|
|
278
|
+
body: {
|
|
279
|
+
request_ids: Array.isArray(requestIds) ? requestIds : []
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
if (response.status !== 200) {
|
|
284
|
+
throw createUpstreamError("CALLER_PLATFORM_EVENTS_BATCH_FAILED", response);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return response.body;
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
async postMetricEvent(body) {
|
|
291
|
+
const response = await requestJson(baseUrl, "/v1/metrics/events", {
|
|
292
|
+
method: "POST",
|
|
293
|
+
headers: authHeaders(true),
|
|
294
|
+
body
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
if (response.status !== 202) {
|
|
298
|
+
throw createUpstreamError("CALLER_PLATFORM_METRIC_FAILED", response);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return response.body;
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function createLocalFallbackPlatformClient() {
|
|
307
|
+
const responderId = process.env.RESPONDER_ID || null;
|
|
308
|
+
const responderPublicKeyPem = normalizePemString(process.env.RESPONDER_SIGNING_PUBLIC_KEY_PEM);
|
|
309
|
+
const hotlineIds = String(process.env.HOTLINE_IDS || "")
|
|
310
|
+
.split(",")
|
|
311
|
+
.map((value) => value.trim())
|
|
312
|
+
.filter(Boolean);
|
|
313
|
+
|
|
314
|
+
if (!responderId || !responderPublicKeyPem || hotlineIds.length === 0) {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
config: {
|
|
320
|
+
baseUrl: "local://responder",
|
|
321
|
+
apiKey: null
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
async listCatalogHotlines(filters = {}) {
|
|
325
|
+
let items = hotlineIds.map((hotlineId) => ({
|
|
326
|
+
responder_id: responderId,
|
|
327
|
+
hotline_id: hotlineId,
|
|
328
|
+
display_name: hotlineId,
|
|
329
|
+
availability_status: "healthy",
|
|
330
|
+
responder_public_key_pem: responderPublicKeyPem,
|
|
331
|
+
task_types: [],
|
|
332
|
+
capabilities: [],
|
|
333
|
+
tags: [],
|
|
334
|
+
review_status: "local_only",
|
|
335
|
+
catalog_visibility: "local"
|
|
336
|
+
}));
|
|
337
|
+
if (filters.responder_id) {
|
|
338
|
+
items = items.filter((item) => item.responder_id === filters.responder_id);
|
|
339
|
+
}
|
|
340
|
+
if (filters.hotline_id) {
|
|
341
|
+
items = items.filter((item) => item.hotline_id === filters.hotline_id);
|
|
342
|
+
}
|
|
343
|
+
return { items };
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
async issueTaskToken({ requestId, responderId: nextResponderId, hotlineId }) {
|
|
347
|
+
if (nextResponderId !== responderId || !hotlineIds.includes(hotlineId)) {
|
|
348
|
+
const error = new Error("CATALOG_HOTLINE_NOT_FOUND");
|
|
349
|
+
error.response = {
|
|
350
|
+
status: 404,
|
|
351
|
+
body: buildStructuredError("CATALOG_HOTLINE_NOT_FOUND", "hotline not found or not enabled")
|
|
352
|
+
};
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
task_token: `local_task_${requestId}`,
|
|
357
|
+
claims: {
|
|
358
|
+
mode: "local",
|
|
359
|
+
request_id: requestId,
|
|
360
|
+
responder_id: nextResponderId,
|
|
361
|
+
hotline_id: hotlineId
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
async getDeliveryMeta({ responderId: nextResponderId, hotlineId, taskToken, resultDelivery }) {
|
|
367
|
+
if (nextResponderId !== responderId || !hotlineIds.includes(hotlineId)) {
|
|
368
|
+
const error = new Error("CATALOG_HOTLINE_NOT_FOUND");
|
|
369
|
+
error.response = {
|
|
370
|
+
status: 404,
|
|
371
|
+
body: buildStructuredError("CATALOG_HOTLINE_NOT_FOUND", "hotline not found or not enabled")
|
|
372
|
+
};
|
|
373
|
+
throw error;
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
task_delivery: {
|
|
377
|
+
kind: "local",
|
|
378
|
+
address: responderId,
|
|
379
|
+
receiver: responderId
|
|
380
|
+
},
|
|
381
|
+
result_delivery: resultDelivery || { kind: "local", address: "caller-controller" },
|
|
382
|
+
verification: {
|
|
383
|
+
display_code: crypto.randomBytes(3).toString("hex").toUpperCase()
|
|
384
|
+
},
|
|
385
|
+
responder_public_key_pem: responderPublicKeyPem,
|
|
386
|
+
task_token: taskToken
|
|
387
|
+
};
|
|
388
|
+
},
|
|
389
|
+
|
|
390
|
+
async postMetricEvent() {
|
|
391
|
+
return { accepted: true, mode: "local" };
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function evaluateTimeouts(request, config) {
|
|
397
|
+
if (TERMINAL_STATUS_SET.has(request.status)) {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const now = Date.now();
|
|
402
|
+
const ackDeadlineAt = request.ack_deadline_at ? new Date(request.ack_deadline_at).getTime() : null;
|
|
403
|
+
const softTimeoutAt = new Date(request.soft_timeout_at).getTime();
|
|
404
|
+
const hardTimeoutAt = new Date(request.hard_timeout_at).getTime();
|
|
405
|
+
|
|
406
|
+
if (
|
|
407
|
+
request.status === "SENT" &&
|
|
408
|
+
ackDeadlineAt &&
|
|
409
|
+
now >= ackDeadlineAt &&
|
|
410
|
+
!request.acknowledged_at &&
|
|
411
|
+
request.timeout_decision !== "continue_wait"
|
|
412
|
+
) {
|
|
413
|
+
request.status = "TIMED_OUT";
|
|
414
|
+
request.timed_out_at = nowIso();
|
|
415
|
+
request.last_error_code = "DELIVERY_OR_ACCEPTANCE_TIMEOUT";
|
|
416
|
+
request.needs_timeout_confirmation = false;
|
|
417
|
+
return {
|
|
418
|
+
status: request.status,
|
|
419
|
+
eventType: "caller.request.timed_out",
|
|
420
|
+
code: request.last_error_code
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (
|
|
425
|
+
config.timeout_confirmation_mode === "ask_by_default" &&
|
|
426
|
+
now >= softTimeoutAt &&
|
|
427
|
+
request.timeout_decision === "pending"
|
|
428
|
+
) {
|
|
429
|
+
request.needs_timeout_confirmation = true;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (
|
|
433
|
+
config.hard_timeout_auto_finalize &&
|
|
434
|
+
ACTIVE_STATUS_SET.has(request.status) &&
|
|
435
|
+
now >= hardTimeoutAt &&
|
|
436
|
+
request.timeout_decision !== "continue_wait"
|
|
437
|
+
) {
|
|
438
|
+
request.status = "TIMED_OUT";
|
|
439
|
+
request.timed_out_at = nowIso();
|
|
440
|
+
request.last_error_code = "EXEC_TIMEOUT_HARD";
|
|
441
|
+
request.needs_timeout_confirmation = false;
|
|
442
|
+
return {
|
|
443
|
+
status: request.status,
|
|
444
|
+
eventType: "caller.request.timed_out",
|
|
445
|
+
code: request.last_error_code
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function createRequestRecord(config, body) {
|
|
453
|
+
const requestId = body.request_id || `req_${crypto.randomUUID()}`;
|
|
454
|
+
const ackDeadlineS = Number(body.ack_deadline_s || config.ack_deadline_s || 120);
|
|
455
|
+
const softTimeoutS = Number(body.soft_timeout_s || 90);
|
|
456
|
+
const hardTimeoutS = Number(body.hard_timeout_s || 300);
|
|
457
|
+
const createdAtMs = Date.now();
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
request_id: requestId,
|
|
461
|
+
caller_id: body.caller_id || "caller_default",
|
|
462
|
+
responder_id: body.responder_id || null,
|
|
463
|
+
hotline_id: body.hotline_id || null,
|
|
464
|
+
contract_version: body.contract_version || "0.1.0",
|
|
465
|
+
expected_signer_public_key_pem: body.expected_signer_public_key_pem || null,
|
|
466
|
+
status: "CREATED",
|
|
467
|
+
attempt: Number(body.attempt || 1),
|
|
468
|
+
timeout_decision: "pending",
|
|
469
|
+
needs_timeout_confirmation: false,
|
|
470
|
+
timeline: [{ at: nowIso(), event: "CREATED" }],
|
|
471
|
+
created_at: new Date(createdAtMs).toISOString(),
|
|
472
|
+
updated_at: new Date(createdAtMs).toISOString(),
|
|
473
|
+
ack_deadline_s: ackDeadlineS,
|
|
474
|
+
ack_deadline_at: body.ack_deadline_at || null,
|
|
475
|
+
soft_timeout_s: softTimeoutS,
|
|
476
|
+
hard_timeout_s: hardTimeoutS,
|
|
477
|
+
soft_timeout_at: new Date(createdAtMs + softTimeoutS * 1000).toISOString(),
|
|
478
|
+
hard_timeout_at: new Date(createdAtMs + hardTimeoutS * 1000).toISOString(),
|
|
479
|
+
config_snapshot: {
|
|
480
|
+
ack_deadline_s: ackDeadlineS,
|
|
481
|
+
timeout_confirmation_mode: config.timeout_confirmation_mode,
|
|
482
|
+
hard_timeout_auto_finalize: config.hard_timeout_auto_finalize
|
|
483
|
+
},
|
|
484
|
+
task_token: body.task_token || null,
|
|
485
|
+
result_delivery: body.result_delivery || { kind: "local", address: "caller-controller" },
|
|
486
|
+
verification: body.verification || null,
|
|
487
|
+
delivery_meta: body.delivery_meta || null,
|
|
488
|
+
platform_events: [],
|
|
489
|
+
platform_completed_at: null,
|
|
490
|
+
platform_failed_at: null,
|
|
491
|
+
platform_last_event: null,
|
|
492
|
+
result_package: null,
|
|
493
|
+
contract_draft: body.contract_draft || null,
|
|
494
|
+
last_error_code: null,
|
|
495
|
+
metric_flags: {}
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function getTaskDelivery(request, body = {}) {
|
|
500
|
+
return request.delivery_meta?.task_delivery || body.task_delivery || null;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function getResultDelivery(request, body = {}) {
|
|
504
|
+
return request.delivery_meta?.result_delivery || body.result_delivery || request.result_delivery || null;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function extractEmailResult(envelope) {
|
|
508
|
+
if (!envelope || typeof envelope !== "object") {
|
|
509
|
+
return { resultPackage: null, attachments: [], parseError: false };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (envelope.result_package || envelope.payload?.result_package) {
|
|
513
|
+
return {
|
|
514
|
+
resultPackage: envelope.result_package || envelope.payload?.result_package,
|
|
515
|
+
attachments: envelope.attachments || envelope.payload?.attachments || [],
|
|
516
|
+
parseError: false
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (typeof envelope.body_text === "string" && envelope.body_text.trim()) {
|
|
521
|
+
try {
|
|
522
|
+
return {
|
|
523
|
+
resultPackage: JSON.parse(envelope.body_text),
|
|
524
|
+
attachments: envelope.attachments || [],
|
|
525
|
+
parseError: false
|
|
526
|
+
};
|
|
527
|
+
} catch {
|
|
528
|
+
return { resultPackage: null, attachments: envelope.attachments || [], parseError: true };
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return {
|
|
533
|
+
resultPackage: envelope.payload?.request_id ? envelope.payload : null,
|
|
534
|
+
attachments: envelope.attachments || [],
|
|
535
|
+
parseError: false
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function verifyArtifactBindings(body, attachments = []) {
|
|
540
|
+
const declared = Array.isArray(body.artifacts) ? body.artifacts : [];
|
|
541
|
+
if (declared.length === 0) {
|
|
542
|
+
return attachments.length === 0;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
for (const artifact of declared) {
|
|
546
|
+
const attachment = attachments.find((item) => item.name === artifact.name);
|
|
547
|
+
if (!attachment) {
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
if (artifact.media_type && attachment.media_type !== artifact.media_type) {
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
if (Number.isFinite(Number(artifact.byte_size)) && Number(artifact.byte_size) !== Number(attachment.byte_size)) {
|
|
554
|
+
return false;
|
|
555
|
+
}
|
|
556
|
+
if (artifact.sha256) {
|
|
557
|
+
const digest = crypto.createHash("sha256").update(Buffer.from(attachment.content_base64 || "", "base64")).digest("hex");
|
|
558
|
+
if (digest !== artifact.sha256) {
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return true;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function markUpdated(request, event) {
|
|
568
|
+
request.updated_at = nowIso();
|
|
569
|
+
request.timeline.push({ at: request.updated_at, event });
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function setSentState(request) {
|
|
573
|
+
request.status = "SENT";
|
|
574
|
+
request.last_error_code = null;
|
|
575
|
+
if (!request.sent_at) {
|
|
576
|
+
request.sent_at = nowIso();
|
|
577
|
+
}
|
|
578
|
+
request.ack_deadline_at = new Date(Date.now() + request.ack_deadline_s * 1000).toISOString();
|
|
579
|
+
markUpdated(request, "SENT");
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function resultContextMatchesRequest(request, body) {
|
|
583
|
+
if (body.request_id !== request.request_id) {
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (typeof body.result_version === "string" && body.result_version !== "0.1.0") {
|
|
588
|
+
return false;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (request.responder_id && body.responder_id !== request.responder_id) {
|
|
592
|
+
return false;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (request.hotline_id && body.hotline_id !== request.hotline_id) {
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (request.verification?.display_code && body.verification?.display_code !== request.verification.display_code) {
|
|
600
|
+
return false;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function verifyResultSignature(request, body) {
|
|
607
|
+
if (!body.signature_base64) {
|
|
608
|
+
return body.signature_valid !== false;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (!request.expected_signer_public_key_pem) {
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
try {
|
|
616
|
+
const bytes = Buffer.from(JSON.stringify(canonicalizeResultPackageForSignature(body)), "utf8");
|
|
617
|
+
const signature = Buffer.from(body.signature_base64, "base64");
|
|
618
|
+
const publicKey = crypto.createPublicKey(normalizePemString(request.expected_signer_public_key_pem));
|
|
619
|
+
return crypto.verify(null, bytes, publicKey, signature);
|
|
620
|
+
} catch {
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export function applyResultPackage(request, body, { attachments = [] } = {}) {
|
|
626
|
+
request.result_package = body;
|
|
627
|
+
|
|
628
|
+
if (!resultContextMatchesRequest(request, body)) {
|
|
629
|
+
request.status = "UNVERIFIED";
|
|
630
|
+
request.last_error_code = "RESULT_CONTEXT_MISMATCH";
|
|
631
|
+
markUpdated(request, "RESULT_CONTEXT_MISMATCH");
|
|
632
|
+
return {
|
|
633
|
+
status: request.status,
|
|
634
|
+
eventType: "caller.request.unverified",
|
|
635
|
+
code: request.last_error_code
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (!verifyResultSignature(request, body)) {
|
|
640
|
+
request.status = "UNVERIFIED";
|
|
641
|
+
request.last_error_code = "RESULT_SIGNATURE_INVALID";
|
|
642
|
+
markUpdated(request, "RESULT_SIGNATURE_INVALID");
|
|
643
|
+
return {
|
|
644
|
+
status: request.status,
|
|
645
|
+
eventType: "caller.request.unverified",
|
|
646
|
+
code: request.last_error_code
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (!verifyArtifactBindings(body, attachments)) {
|
|
651
|
+
request.status = "UNVERIFIED";
|
|
652
|
+
request.last_error_code = "RESULT_ARTIFACT_INVALID";
|
|
653
|
+
markUpdated(request, "RESULT_ARTIFACT_INVALID");
|
|
654
|
+
return {
|
|
655
|
+
status: request.status,
|
|
656
|
+
eventType: "caller.request.unverified",
|
|
657
|
+
code: request.last_error_code
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (body.schema_valid === false) {
|
|
662
|
+
request.status = "UNVERIFIED";
|
|
663
|
+
request.last_error_code = "RESULT_SCHEMA_INVALID";
|
|
664
|
+
markUpdated(request, "RESULT_SCHEMA_INVALID");
|
|
665
|
+
return {
|
|
666
|
+
status: request.status,
|
|
667
|
+
eventType: "caller.request.unverified",
|
|
668
|
+
code: request.last_error_code
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (body.status === "ok") {
|
|
673
|
+
request.status = "SUCCEEDED";
|
|
674
|
+
request.last_error_code = null;
|
|
675
|
+
markUpdated(request, "SUCCEEDED");
|
|
676
|
+
return {
|
|
677
|
+
status: request.status,
|
|
678
|
+
eventType: "caller.request.succeeded"
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
request.status = "FAILED";
|
|
683
|
+
request.last_error_code = body.error?.code || "EXEC_UNKNOWN";
|
|
684
|
+
markUpdated(request, "FAILED");
|
|
685
|
+
return {
|
|
686
|
+
status: request.status,
|
|
687
|
+
eventType: "caller.request.failed",
|
|
688
|
+
code: request.last_error_code
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function reportCallerMetric(platformClient, request, eventType, detail = {}) {
|
|
693
|
+
if (!platformClient || !eventType) {
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const metricKey = `${eventType}:${detail.code || ""}`;
|
|
698
|
+
request.metric_flags ||= {};
|
|
699
|
+
if (request.metric_flags[metricKey]) {
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
request.metric_flags[metricKey] = true;
|
|
704
|
+
try {
|
|
705
|
+
await platformClient.postMetricEvent({
|
|
706
|
+
source: "caller-controller",
|
|
707
|
+
event_type: eventType,
|
|
708
|
+
request_id: request.request_id,
|
|
709
|
+
responder_id: request.responder_id,
|
|
710
|
+
hotline_id: request.hotline_id,
|
|
711
|
+
...detail
|
|
712
|
+
});
|
|
713
|
+
} catch (error) {
|
|
714
|
+
request.last_metric_error = error instanceof Error ? error.message : "metric_event_failed";
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async function evaluateTimeoutsWithMetrics(request, config, platformClient) {
|
|
719
|
+
const transition = evaluateTimeouts(request, config);
|
|
720
|
+
if (transition?.eventType) {
|
|
721
|
+
await reportCallerMetric(platformClient, request, transition.eventType, { code: transition.code });
|
|
722
|
+
}
|
|
723
|
+
return transition;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
async function persistCallerState(onStateChanged, state) {
|
|
727
|
+
if (typeof onStateChanged === "function") {
|
|
728
|
+
await onStateChanged(state);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function isCallerRequestActive(request) {
|
|
733
|
+
return ACTIVE_STATUS_SET.has(request.status);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async function pollCallerInbox(
|
|
737
|
+
state,
|
|
738
|
+
transport,
|
|
739
|
+
platformClientResolver,
|
|
740
|
+
onStateChanged,
|
|
741
|
+
receiver = "caller-controller"
|
|
742
|
+
) {
|
|
743
|
+
if (!transport) {
|
|
744
|
+
return { accepted: [], mutated: false };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const polled = await transport.poll({
|
|
748
|
+
limit: 10,
|
|
749
|
+
receiver
|
|
750
|
+
});
|
|
751
|
+
const accepted = [];
|
|
752
|
+
let mutated = false;
|
|
753
|
+
|
|
754
|
+
for (const envelope of polled.items) {
|
|
755
|
+
const { resultPackage, attachments, parseError } = extractEmailResult(envelope);
|
|
756
|
+
if (!resultPackage?.request_id && parseError && envelope.request_id) {
|
|
757
|
+
const request = state.requests.get(envelope.request_id);
|
|
758
|
+
if (request && !TERMINAL_STATUS_SET.has(request.status)) {
|
|
759
|
+
request.status = "UNVERIFIED";
|
|
760
|
+
request.last_error_code = "RESULT_BODY_INVALID_JSON";
|
|
761
|
+
markUpdated(request, "RESULT_BODY_INVALID_JSON");
|
|
762
|
+
mutated = true;
|
|
763
|
+
}
|
|
764
|
+
await transport.ack(envelope.message_id, { receiver });
|
|
765
|
+
accepted.push({ message_id: envelope.message_id, request_id: envelope.request_id });
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
if (!resultPackage?.request_id) {
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const request = state.requests.get(resultPackage.request_id);
|
|
773
|
+
if (!request) {
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
const platformClient = typeof platformClientResolver === "function" ? platformClientResolver(request) : null;
|
|
777
|
+
|
|
778
|
+
if (!TERMINAL_STATUS_SET.has(request.status)) {
|
|
779
|
+
const transition = applyResultPackage(request, resultPackage, { attachments });
|
|
780
|
+
if (transition?.eventType) {
|
|
781
|
+
await reportCallerMetric(platformClient, request, transition.eventType, { code: transition.code });
|
|
782
|
+
}
|
|
783
|
+
mutated = true;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
await transport.ack(envelope.message_id, { receiver });
|
|
787
|
+
accepted.push({ message_id: envelope.message_id, request_id: resultPackage.request_id });
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (mutated) {
|
|
791
|
+
await persistCallerState(onStateChanged, state);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
return { accepted, mutated };
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async function syncCallerActiveRequests(state, config, platformClientFactory, onStateChanged) {
|
|
798
|
+
let mutated = false;
|
|
799
|
+
const activeGroups = new Map();
|
|
800
|
+
const requestClients = new Map();
|
|
801
|
+
|
|
802
|
+
for (const request of state.requests.values()) {
|
|
803
|
+
const platformClient = typeof platformClientFactory === "function" ? platformClientFactory(request) : null;
|
|
804
|
+
requestClients.set(request.request_id, platformClient);
|
|
805
|
+
|
|
806
|
+
if (!platformClient || !isCallerRequestActive(request)) {
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const clientKey = `${platformClient.config?.baseUrl || "unknown"}::${platformClient.config?.apiKey || "anonymous"}`;
|
|
811
|
+
const existingGroup = activeGroups.get(clientKey) || {
|
|
812
|
+
client: platformClient,
|
|
813
|
+
requests: []
|
|
814
|
+
};
|
|
815
|
+
existingGroup.requests.push(request);
|
|
816
|
+
activeGroups.set(clientKey, existingGroup);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const batchSize = Math.max(1, Number(config.events_sync_batch_size || 25));
|
|
820
|
+
|
|
821
|
+
for (const group of activeGroups.values()) {
|
|
822
|
+
const { client: platformClient, requests } = group;
|
|
823
|
+
for (let index = 0; index < requests.length; index += batchSize) {
|
|
824
|
+
const batch = requests.slice(index, index + batchSize);
|
|
825
|
+
const requestIds = batch.map((request) => request.request_id);
|
|
826
|
+
try {
|
|
827
|
+
const response = await platformClient.getRequestEventsBatch(requestIds);
|
|
828
|
+
const byRequestId = new Map((response.items || []).map((item) => [item.request_id, item]));
|
|
829
|
+
for (const request of batch) {
|
|
830
|
+
const item = byRequestId.get(request.request_id);
|
|
831
|
+
if (!item || item.found === false) {
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
const synced = applyPlatformEventsToRequest(request, item.events || item.items || []);
|
|
835
|
+
if (synced.acked) {
|
|
836
|
+
await reportCallerMetric(platformClient, request, "caller.request.acked");
|
|
837
|
+
}
|
|
838
|
+
mutated = true;
|
|
839
|
+
}
|
|
840
|
+
} catch {
|
|
841
|
+
for (const request of batch) {
|
|
842
|
+
try {
|
|
843
|
+
const synced = await syncCallerRequestEvents(request, platformClient);
|
|
844
|
+
if (synced.acked) {
|
|
845
|
+
await reportCallerMetric(platformClient, request, "caller.request.acked");
|
|
846
|
+
}
|
|
847
|
+
mutated = true;
|
|
848
|
+
} catch {
|
|
849
|
+
// ignore background sync failures; foreground APIs still expose explicit sync
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
for (const request of state.requests.values()) {
|
|
857
|
+
const transition = await evaluateTimeoutsWithMetrics(
|
|
858
|
+
request,
|
|
859
|
+
config,
|
|
860
|
+
requestClients.get(request.request_id) || null
|
|
861
|
+
);
|
|
862
|
+
mutated ||= Boolean(transition);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
if (mutated) {
|
|
866
|
+
await persistCallerState(onStateChanged, state);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
return { mutated };
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
export function startCallerBackgroundLoops({
|
|
873
|
+
state,
|
|
874
|
+
config = loadCallerConfig(),
|
|
875
|
+
transport = null,
|
|
876
|
+
receiver = "caller-controller",
|
|
877
|
+
inboxPollIntervalMs = 1000,
|
|
878
|
+
eventsSyncIntervalMs = 1000,
|
|
879
|
+
platformClientFactory = () => null,
|
|
880
|
+
onStateChanged = null,
|
|
881
|
+
logger = console
|
|
882
|
+
} = {}) {
|
|
883
|
+
let stopped = false;
|
|
884
|
+
let inboxRunning = false;
|
|
885
|
+
let syncRunning = false;
|
|
886
|
+
|
|
887
|
+
async function runInboxPoll() {
|
|
888
|
+
if (stopped || inboxRunning || !transport) {
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
inboxRunning = true;
|
|
892
|
+
try {
|
|
893
|
+
await pollCallerInbox(state, transport, platformClientFactory, onStateChanged, receiver);
|
|
894
|
+
} catch (error) {
|
|
895
|
+
logger?.warn?.(`[caller-background] inbox poll failed: ${error instanceof Error ? error.message : "unknown_error"}`);
|
|
896
|
+
} finally {
|
|
897
|
+
inboxRunning = false;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async function runEventSync() {
|
|
902
|
+
if (stopped || syncRunning) {
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
syncRunning = true;
|
|
906
|
+
try {
|
|
907
|
+
await syncCallerActiveRequests(state, config, platformClientFactory, onStateChanged);
|
|
908
|
+
} catch (error) {
|
|
909
|
+
logger?.warn?.(`[caller-background] request sync failed: ${error instanceof Error ? error.message : "unknown_error"}`);
|
|
910
|
+
} finally {
|
|
911
|
+
syncRunning = false;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
void runInboxPoll();
|
|
916
|
+
void runEventSync();
|
|
917
|
+
|
|
918
|
+
const inboxTimer = transport
|
|
919
|
+
? setInterval(() => {
|
|
920
|
+
void runInboxPoll();
|
|
921
|
+
}, inboxPollIntervalMs)
|
|
922
|
+
: null;
|
|
923
|
+
const syncTimer = setInterval(() => {
|
|
924
|
+
void runEventSync();
|
|
925
|
+
}, eventsSyncIntervalMs);
|
|
926
|
+
|
|
927
|
+
return () => {
|
|
928
|
+
stopped = true;
|
|
929
|
+
if (inboxTimer) {
|
|
930
|
+
clearInterval(inboxTimer);
|
|
931
|
+
}
|
|
932
|
+
clearInterval(syncTimer);
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
export async function prepareCallerRequest(request, platformClient, options = {}) {
|
|
937
|
+
const responderId = options.responder_id || options.responderId || request.responder_id;
|
|
938
|
+
const hotlineId = options.hotline_id || options.hotlineId || request.hotline_id;
|
|
939
|
+
if (!responderId || !hotlineId) {
|
|
940
|
+
throw new Error("caller_prepare_requires_responder_and_hotline");
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const issued = await platformClient.issueTaskToken({
|
|
944
|
+
requestId: request.request_id,
|
|
945
|
+
responderId,
|
|
946
|
+
hotlineId
|
|
947
|
+
});
|
|
948
|
+
const deliveryMeta = await platformClient.getDeliveryMeta({
|
|
949
|
+
requestId: request.request_id,
|
|
950
|
+
responderId,
|
|
951
|
+
hotlineId,
|
|
952
|
+
taskToken: issued.task_token,
|
|
953
|
+
resultDelivery: options.result_delivery || options.resultDelivery || request.result_delivery
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
const expectedSignerPublicKeyPem = normalizePemString(request.expected_signer_public_key_pem);
|
|
957
|
+
const deliveredSignerPublicKeyPem = normalizePemString(deliveryMeta.responder_public_key_pem);
|
|
958
|
+
|
|
959
|
+
if (expectedSignerPublicKeyPem && deliveredSignerPublicKeyPem && expectedSignerPublicKeyPem !== deliveredSignerPublicKeyPem) {
|
|
960
|
+
throw new Error("caller_signer_binding_mismatch");
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
request.responder_id = responderId;
|
|
964
|
+
request.hotline_id = hotlineId;
|
|
965
|
+
request.task_token = issued.task_token;
|
|
966
|
+
request.result_delivery = deliveryMeta.result_delivery || request.result_delivery || null;
|
|
967
|
+
request.verification = deliveryMeta.verification || request.verification || null;
|
|
968
|
+
request.delivery_meta = deliveryMeta;
|
|
969
|
+
request.expected_signer_public_key_pem = expectedSignerPublicKeyPem || deliveredSignerPublicKeyPem || null;
|
|
970
|
+
request.last_error_code = null;
|
|
971
|
+
markUpdated(request, "PREPARED");
|
|
972
|
+
|
|
973
|
+
return {
|
|
974
|
+
task_token: issued.task_token,
|
|
975
|
+
claims: issued.claims,
|
|
976
|
+
delivery_meta: deliveryMeta,
|
|
977
|
+
request
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
export function buildDispatchEnvelope(request, body = {}) {
|
|
982
|
+
const taskDelivery = getTaskDelivery(request, body);
|
|
983
|
+
const resultDelivery = getResultDelivery(request, body);
|
|
984
|
+
const deliveryAddress = taskDelivery?.address || body.task_delivery_address || request.responder_id;
|
|
985
|
+
const threadHint = taskDelivery?.thread_hint || request.delivery_meta?.thread_hint || `req:${request.request_id}`;
|
|
986
|
+
|
|
987
|
+
return {
|
|
988
|
+
message_id: body.message_id || `msg_${crypto.randomUUID()}`,
|
|
989
|
+
thread_id: body.thread_id || threadHint,
|
|
990
|
+
from: body.from || "caller-controller",
|
|
991
|
+
to: body.to || deliveryAddress,
|
|
992
|
+
type: body.type || "task.requested",
|
|
993
|
+
request_id: request.request_id,
|
|
994
|
+
responder_id: request.responder_id,
|
|
995
|
+
hotline_id: request.hotline_id,
|
|
996
|
+
task_token: body.task_token || request.task_token || null,
|
|
997
|
+
result_delivery: resultDelivery,
|
|
998
|
+
verification: request.verification || request.delivery_meta?.verification || null,
|
|
999
|
+
payload: body.payload || {},
|
|
1000
|
+
simulate: body.simulate || "success",
|
|
1001
|
+
delay_ms: Number(body.delay_ms || 80),
|
|
1002
|
+
lease_ttl_s: Number(body.lease_ttl_s || 30),
|
|
1003
|
+
priority: Number(body.priority || 5),
|
|
1004
|
+
sent_at: nowIso()
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
export function createTaskContractDraft(request, body = {}) {
|
|
1009
|
+
const taskInput = body.task_input ?? body.input ?? request.task_input ?? {};
|
|
1010
|
+
const outputSchema = body.output_schema ?? request.output_schema ?? null;
|
|
1011
|
+
const taskType = body.task_type || request.task_type || null;
|
|
1012
|
+
const resultDelivery = body.result_delivery || getResultDelivery(request, body);
|
|
1013
|
+
const threadHint = body.thread_hint || getTaskDelivery(request, body)?.thread_hint || `req:${request.request_id}`;
|
|
1014
|
+
const sourceRunId = body.source_run_id || request.source_run_id || null;
|
|
1015
|
+
const createdAt = body.created_at || nowIso();
|
|
1016
|
+
const constraints = {
|
|
1017
|
+
soft_timeout_s: body.soft_timeout_s ?? request.soft_timeout_s ?? null,
|
|
1018
|
+
hard_timeout_s: body.hard_timeout_s ?? request.hard_timeout_s ?? null
|
|
1019
|
+
};
|
|
1020
|
+
|
|
1021
|
+
const contract = {
|
|
1022
|
+
request_id: request.request_id,
|
|
1023
|
+
contract_version: body.contract_version || request.contract_version || "0.1.0",
|
|
1024
|
+
created_at: createdAt,
|
|
1025
|
+
caller: {
|
|
1026
|
+
caller_id: body.caller_id || request.caller_id || "caller_default"
|
|
1027
|
+
},
|
|
1028
|
+
responder: {
|
|
1029
|
+
responder_id: body.responder_id || request.responder_id,
|
|
1030
|
+
hotline_id: body.hotline_id || request.hotline_id
|
|
1031
|
+
},
|
|
1032
|
+
task: {
|
|
1033
|
+
task_type: taskType,
|
|
1034
|
+
input: taskInput,
|
|
1035
|
+
output_schema: outputSchema
|
|
1036
|
+
},
|
|
1037
|
+
constraints,
|
|
1038
|
+
token: body.task_token || request.task_token || null,
|
|
1039
|
+
trace: {
|
|
1040
|
+
thread_hint: threadHint
|
|
1041
|
+
}
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
if (resultDelivery) {
|
|
1045
|
+
contract.caller.result_delivery = resultDelivery;
|
|
1046
|
+
}
|
|
1047
|
+
if (request.verification || body.verification) {
|
|
1048
|
+
contract.verification = body.verification || request.verification;
|
|
1049
|
+
}
|
|
1050
|
+
if (sourceRunId) {
|
|
1051
|
+
contract.trace.source_run_id = sourceRunId;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
request.task_type = taskType;
|
|
1055
|
+
request.task_input = taskInput;
|
|
1056
|
+
request.output_schema = outputSchema;
|
|
1057
|
+
request.result_delivery = resultDelivery;
|
|
1058
|
+
request.source_run_id = sourceRunId;
|
|
1059
|
+
request.contract_draft = contract;
|
|
1060
|
+
markUpdated(request, "CONTRACT_DRAFTED");
|
|
1061
|
+
|
|
1062
|
+
return contract;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
export async function syncCallerRequestEvents(request, platformClient) {
|
|
1066
|
+
const response = await platformClient.getRequestEvents(request.request_id);
|
|
1067
|
+
return applyPlatformEventsToRequest(request, response.events || response.items || []);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
export function applyPlatformEventsToRequest(request, events = []) {
|
|
1071
|
+
request.platform_events = events;
|
|
1072
|
+
request.platform_last_event = events.length > 0 ? events[events.length - 1] : null;
|
|
1073
|
+
|
|
1074
|
+
const ackEvent = events.find((event) => event.event_type === "ACKED");
|
|
1075
|
+
const completedEvent = events.find((event) => event.event_type === "COMPLETED");
|
|
1076
|
+
const failedEvent = events.find((event) => event.event_type === "FAILED");
|
|
1077
|
+
request.platform_completed_at = completedEvent?.finished_at || completedEvent?.at || null;
|
|
1078
|
+
request.platform_failed_at = failedEvent?.finished_at || failedEvent?.at || null;
|
|
1079
|
+
if (ackEvent && !TERMINAL_STATUS_SET.has(request.status) && request.status !== "ACKED") {
|
|
1080
|
+
request.status = "ACKED";
|
|
1081
|
+
request.last_error_code = null;
|
|
1082
|
+
request.acknowledged_at = ackEvent.at || null;
|
|
1083
|
+
request.ack_eta_hint_s = Number.isFinite(Number(ackEvent.eta_hint_s)) ? Number(ackEvent.eta_hint_s) : null;
|
|
1084
|
+
markUpdated(request, "ACKED");
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
return {
|
|
1088
|
+
request_id: request.request_id,
|
|
1089
|
+
events,
|
|
1090
|
+
acked: Boolean(ackEvent),
|
|
1091
|
+
request
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
export function createCallerControllerServer({
|
|
1096
|
+
state = createCallerState(),
|
|
1097
|
+
serviceName = "caller-controller",
|
|
1098
|
+
config = loadCallerConfig(),
|
|
1099
|
+
transport = null,
|
|
1100
|
+
platform = null,
|
|
1101
|
+
background = {},
|
|
1102
|
+
onStateChanged = null
|
|
1103
|
+
} = {}) {
|
|
1104
|
+
const defaultPlatformClient = platform?.baseUrl ? createCallerPlatformClient(platform) : null;
|
|
1105
|
+
const defaultBackgroundPlatformClient = platform?.baseUrl && platform?.apiKey ? createCallerPlatformClient(platform) : null;
|
|
1106
|
+
const localFallbackClient = createLocalFallbackPlatformClient();
|
|
1107
|
+
const requestPlatformAuth = new Map();
|
|
1108
|
+
|
|
1109
|
+
function resolvePlatformConfig(req) {
|
|
1110
|
+
if (!platform?.baseUrl) {
|
|
1111
|
+
return null;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const headerApiKey = req?.headers?.["x-platform-api-key"];
|
|
1115
|
+
if (typeof headerApiKey === "string" && headerApiKey.trim()) {
|
|
1116
|
+
return {
|
|
1117
|
+
baseUrl: platform.baseUrl,
|
|
1118
|
+
apiKey: headerApiKey.trim()
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
return platform;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function resolvePlatformClient(req) {
|
|
1126
|
+
if (isLocalOnlyRegistrationMode(process.env.CALLER_REGISTRATION_MODE) && localFallbackClient) {
|
|
1127
|
+
return localFallbackClient;
|
|
1128
|
+
}
|
|
1129
|
+
const resolved = resolvePlatformConfig(req);
|
|
1130
|
+
if (!resolved?.baseUrl) {
|
|
1131
|
+
return localFallbackClient;
|
|
1132
|
+
}
|
|
1133
|
+
if (resolved === platform && defaultPlatformClient) {
|
|
1134
|
+
return defaultPlatformClient;
|
|
1135
|
+
}
|
|
1136
|
+
return createCallerPlatformClient(resolved);
|
|
1137
|
+
}
|
|
1138
|
+
const server = http.createServer(async (req, res) => {
|
|
1139
|
+
const method = req.method || "GET";
|
|
1140
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
1141
|
+
const pathname = url.pathname;
|
|
1142
|
+
|
|
1143
|
+
try {
|
|
1144
|
+
if (method === "OPTIONS") {
|
|
1145
|
+
res.writeHead(204, {
|
|
1146
|
+
"access-control-allow-origin": "*",
|
|
1147
|
+
"access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
|
|
1148
|
+
"access-control-allow-headers": "Content-Type, Authorization, X-Platform-Api-Key"
|
|
1149
|
+
});
|
|
1150
|
+
res.end();
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
if (method === "GET" && pathname === "/healthz") {
|
|
1155
|
+
sendJson(res, 200, { ok: true, service: serviceName });
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
if (method === "GET" && pathname === "/readyz") {
|
|
1160
|
+
sendJson(res, 200, { ready: true, service: serviceName });
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
if (method === "GET" && pathname === "/") {
|
|
1165
|
+
const platformClient = resolvePlatformClient(req);
|
|
1166
|
+
sendJson(res, 200, {
|
|
1167
|
+
service: serviceName,
|
|
1168
|
+
status: "running",
|
|
1169
|
+
config,
|
|
1170
|
+
platform: platformClient ? { configured: true, base_url: platformClient.config?.baseUrl || null } : { configured: false },
|
|
1171
|
+
local_defaults: {
|
|
1172
|
+
caller_contact_email: process.env.CALLER_CONTACT_EMAIL || null,
|
|
1173
|
+
platform_api_key_configured: Boolean(platform?.apiKey)
|
|
1174
|
+
}
|
|
1175
|
+
});
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
if (method === "GET" && pathname === "/controller/hotlines") {
|
|
1180
|
+
const platformClient = resolvePlatformClient(req);
|
|
1181
|
+
if (!platformClient) {
|
|
1182
|
+
sendError(res, 409, "PLATFORM_NOT_CONFIGURED", "platform client is not configured");
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
try {
|
|
1187
|
+
const catalog = await platformClient.listCatalogHotlines({
|
|
1188
|
+
status: url.searchParams.get("status") || undefined,
|
|
1189
|
+
availability_status: url.searchParams.get("availability_status") || undefined,
|
|
1190
|
+
task_type: url.searchParams.get("task_type") || undefined,
|
|
1191
|
+
capability: url.searchParams.get("capability") || undefined,
|
|
1192
|
+
tag: url.searchParams.get("tag") || undefined,
|
|
1193
|
+
responder_id: url.searchParams.get("responder_id") || undefined,
|
|
1194
|
+
hotline_id: url.searchParams.get("hotline_id") || undefined
|
|
1195
|
+
});
|
|
1196
|
+
sendJson(res, 200, catalog);
|
|
1197
|
+
} catch (error) {
|
|
1198
|
+
sendUpstreamError(res, error, "CALLER_PLATFORM_CATALOG_FAILED", "catalog query failed");
|
|
1199
|
+
}
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
if (method === "POST" && pathname === "/controller/register") {
|
|
1204
|
+
const platformClient = resolvePlatformClient(req);
|
|
1205
|
+
if (!platformClient) {
|
|
1206
|
+
sendError(res, 409, "PLATFORM_NOT_CONFIGURED", "platform client is not configured");
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
try {
|
|
1211
|
+
const body = await parseJsonBody(req);
|
|
1212
|
+
const registered = await platformClient.registerCaller(body);
|
|
1213
|
+
sendJson(res, 201, registered);
|
|
1214
|
+
} catch (error) {
|
|
1215
|
+
sendUpstreamError(res, error, "CALLER_PLATFORM_REGISTER_FAILED", "platform registration failed");
|
|
1216
|
+
}
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
if (method === "POST" && pathname === "/controller/responder/register") {
|
|
1221
|
+
const platformClient = resolvePlatformClient(req);
|
|
1222
|
+
if (!platformClient) {
|
|
1223
|
+
sendError(res, 409, "PLATFORM_NOT_CONFIGURED", "platform client is not configured");
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
try {
|
|
1228
|
+
const body = await parseJsonBody(req);
|
|
1229
|
+
const registered = await platformClient.registerResponder(body);
|
|
1230
|
+
sendJson(res, 201, registered);
|
|
1231
|
+
} catch (error) {
|
|
1232
|
+
sendUpstreamError(res, error, "CALLER_PLATFORM_RESPONDER_REGISTER_FAILED", "responder registration failed");
|
|
1233
|
+
}
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
if (method === "POST" && pathname === "/controller/requests") {
|
|
1238
|
+
const body = await parseJsonBody(req);
|
|
1239
|
+
const record = createRequestRecord(config, body);
|
|
1240
|
+
state.requests.set(record.request_id, record);
|
|
1241
|
+
await persistCallerState(onStateChanged, state);
|
|
1242
|
+
sendJson(res, 201, record);
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
if (method === "GET" && pathname === "/controller/requests") {
|
|
1247
|
+
const items = Array.from(state.requests.values());
|
|
1248
|
+
const platformClient = resolvePlatformClient(req);
|
|
1249
|
+
let mutated = false;
|
|
1250
|
+
for (const item of items) {
|
|
1251
|
+
const transition = await evaluateTimeoutsWithMetrics(item, config, platformClient);
|
|
1252
|
+
mutated ||= Boolean(transition);
|
|
1253
|
+
}
|
|
1254
|
+
if (mutated) {
|
|
1255
|
+
await persistCallerState(onStateChanged, state);
|
|
1256
|
+
}
|
|
1257
|
+
sendJson(res, 200, { items });
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
const requestMatch = pathname.match(/^\/controller\/requests\/([^/]+)$/);
|
|
1262
|
+
if (method === "GET" && requestMatch) {
|
|
1263
|
+
const platformClient = resolvePlatformClient(req);
|
|
1264
|
+
const request = state.requests.get(requestMatch[1]);
|
|
1265
|
+
if (!request) {
|
|
1266
|
+
sendError(res, 404, "REQUEST_NOT_FOUND", "no request found with this id");
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
const transition = await evaluateTimeoutsWithMetrics(request, config, platformClient);
|
|
1270
|
+
if (transition) {
|
|
1271
|
+
await persistCallerState(onStateChanged, state);
|
|
1272
|
+
}
|
|
1273
|
+
sendJson(res, 200, request);
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const requestResultMatch = pathname.match(/^\/controller\/requests\/([^/]+)\/result$/);
|
|
1278
|
+
if (method === "GET" && requestResultMatch) {
|
|
1279
|
+
const request = state.requests.get(requestResultMatch[1]);
|
|
1280
|
+
if (!request) {
|
|
1281
|
+
sendError(res, 404, "REQUEST_NOT_FOUND", "no request found with this id");
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
if (!TERMINAL_STATUS_SET.has(request.status) || !request.result_package) {
|
|
1285
|
+
sendJson(res, 200, { available: false, status: request.status, result_package: null });
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
sendJson(res, 200, {
|
|
1289
|
+
available: true,
|
|
1290
|
+
status: request.status,
|
|
1291
|
+
result_package: request.result_package
|
|
1292
|
+
});
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
const prepareMatch = pathname.match(/^\/controller\/requests\/([^/]+)\/prepare$/);
|
|
1297
|
+
if (method === "POST" && prepareMatch) {
|
|
1298
|
+
const platformClient = resolvePlatformClient(req);
|
|
1299
|
+
if (!platformClient) {
|
|
1300
|
+
sendError(res, 409, "PLATFORM_NOT_CONFIGURED", "platform client is not configured");
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
const request = state.requests.get(prepareMatch[1]);
|
|
1305
|
+
if (!request) {
|
|
1306
|
+
sendError(res, 404, "REQUEST_NOT_FOUND", "no request found with this id");
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
try {
|
|
1311
|
+
const body = await parseJsonBody(req);
|
|
1312
|
+
const platformConfig = resolvePlatformConfig(req);
|
|
1313
|
+
if (platformConfig?.apiKey) {
|
|
1314
|
+
requestPlatformAuth.set(request.request_id, platformConfig);
|
|
1315
|
+
}
|
|
1316
|
+
const prepared = await prepareCallerRequest(request, platformClient, body);
|
|
1317
|
+
await persistCallerState(onStateChanged, state);
|
|
1318
|
+
sendJson(res, 200, prepared);
|
|
1319
|
+
} catch (error) {
|
|
1320
|
+
if (error instanceof Error && error.message === "caller_prepare_requires_responder_and_hotline") {
|
|
1321
|
+
sendError(res, 400, "CONTRACT_INVALID_PREPARE_REQUEST", "responder_id and hotline_id are required");
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
if (error instanceof Error && error.message === "caller_signer_binding_mismatch") {
|
|
1325
|
+
sendError(res, 409, "SIGNER_BINDING_MISMATCH", "expected signer public key does not match catalog");
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
sendUpstreamError(res, error, "CALLER_PLATFORM_PREPARE_FAILED", "request preparation failed");
|
|
1329
|
+
}
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
if (method === "POST" && pathname === "/controller/remote-requests") {
|
|
1334
|
+
const platformClient = resolvePlatformClient(req);
|
|
1335
|
+
if (!platformClient) {
|
|
1336
|
+
sendError(res, 409, "PLATFORM_NOT_CONFIGURED", "platform client is not configured");
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
if (!transport) {
|
|
1340
|
+
sendError(res, 409, "TRANSPORT_NOT_CONFIGURED", "transport adapter is not configured");
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
try {
|
|
1345
|
+
const body = await parseJsonBody(req);
|
|
1346
|
+
const request = createRequestRecord(config, body);
|
|
1347
|
+
state.requests.set(request.request_id, request);
|
|
1348
|
+
|
|
1349
|
+
const platformConfig = resolvePlatformConfig(req);
|
|
1350
|
+
if (platformConfig?.apiKey) {
|
|
1351
|
+
requestPlatformAuth.set(request.request_id, platformConfig);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
const prepared = await prepareCallerRequest(request, platformClient, body);
|
|
1355
|
+
const contract = createTaskContractDraft(request, body);
|
|
1356
|
+
const envelope = buildDispatchEnvelope(request, {
|
|
1357
|
+
...body,
|
|
1358
|
+
task_token: prepared.task_token
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
await transport.send(envelope);
|
|
1362
|
+
if (!TERMINAL_STATUS_SET.has(request.status)) {
|
|
1363
|
+
setSentState(request);
|
|
1364
|
+
}
|
|
1365
|
+
await reportCallerMetric(platformClient, request, "caller.request.dispatched");
|
|
1366
|
+
await persistCallerState(onStateChanged, state);
|
|
1367
|
+
|
|
1368
|
+
sendJson(res, 201, {
|
|
1369
|
+
request_id: request.request_id,
|
|
1370
|
+
request,
|
|
1371
|
+
task_token: prepared.task_token,
|
|
1372
|
+
delivery_meta: prepared.delivery_meta,
|
|
1373
|
+
contract,
|
|
1374
|
+
envelope
|
|
1375
|
+
});
|
|
1376
|
+
} catch (error) {
|
|
1377
|
+
if (error instanceof Error && error.message === "caller_prepare_requires_responder_and_hotline") {
|
|
1378
|
+
sendError(res, 400, "CONTRACT_INVALID_REMOTE_REQUEST", "responder_id and hotline_id are required");
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
if (error instanceof Error && error.message === "caller_signer_binding_mismatch") {
|
|
1382
|
+
sendError(res, 409, "SIGNER_BINDING_MISMATCH", "expected signer public key does not match catalog");
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
sendUpstreamError(res, error, "CALLER_REMOTE_REQUEST_FAILED", "remote request dispatch failed");
|
|
1386
|
+
}
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
const contractMatch = pathname.match(/^\/controller\/requests\/([^/]+)\/contract-draft$/);
|
|
1391
|
+
if (method === "POST" && contractMatch) {
|
|
1392
|
+
const request = state.requests.get(contractMatch[1]);
|
|
1393
|
+
if (!request) {
|
|
1394
|
+
sendError(res, 404, "REQUEST_NOT_FOUND", "no request found with this id");
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
const body = await parseJsonBody(req);
|
|
1399
|
+
const contract = createTaskContractDraft(request, body);
|
|
1400
|
+
await persistCallerState(onStateChanged, state);
|
|
1401
|
+
sendJson(res, 200, { request_id: request.request_id, contract });
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
const markSentMatch = pathname.match(/^\/controller\/requests\/([^/]+)\/mark-sent$/);
|
|
1406
|
+
if (method === "POST" && markSentMatch) {
|
|
1407
|
+
const request = state.requests.get(markSentMatch[1]);
|
|
1408
|
+
if (!request) {
|
|
1409
|
+
sendError(res, 404, "REQUEST_NOT_FOUND", "no request found with this id");
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
if (!TERMINAL_STATUS_SET.has(request.status)) {
|
|
1414
|
+
setSentState(request);
|
|
1415
|
+
await persistCallerState(onStateChanged, state);
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
sendJson(res, 200, request);
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
const dispatchMatch = pathname.match(/^\/controller\/requests\/([^/]+)\/dispatch$/);
|
|
1423
|
+
if (method === "POST" && dispatchMatch) {
|
|
1424
|
+
const platformClient = resolvePlatformClient(req);
|
|
1425
|
+
const request = state.requests.get(dispatchMatch[1]);
|
|
1426
|
+
if (!request) {
|
|
1427
|
+
sendError(res, 404, "REQUEST_NOT_FOUND", "no request found with this id");
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
if (!transport) {
|
|
1432
|
+
sendError(res, 409, "TRANSPORT_NOT_CONFIGURED", "transport adapter is not configured");
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
const body = await parseJsonBody(req);
|
|
1437
|
+
const envelope = buildDispatchEnvelope(request, body);
|
|
1438
|
+
|
|
1439
|
+
await transport.send(envelope);
|
|
1440
|
+
|
|
1441
|
+
if (!TERMINAL_STATUS_SET.has(request.status)) {
|
|
1442
|
+
setSentState(request);
|
|
1443
|
+
}
|
|
1444
|
+
await reportCallerMetric(platformClient, request, "caller.request.dispatched");
|
|
1445
|
+
await persistCallerState(onStateChanged, state);
|
|
1446
|
+
|
|
1447
|
+
sendJson(res, 202, { accepted: true, envelope, request });
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
const syncEventsMatch = pathname.match(/^\/controller\/requests\/([^/]+)\/sync-events$/);
|
|
1452
|
+
if (method === "POST" && syncEventsMatch) {
|
|
1453
|
+
const platformClient = resolvePlatformClient(req);
|
|
1454
|
+
if (!platformClient) {
|
|
1455
|
+
sendError(res, 409, "PLATFORM_NOT_CONFIGURED", "platform client is not configured");
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
const request = state.requests.get(syncEventsMatch[1]);
|
|
1460
|
+
if (!request) {
|
|
1461
|
+
sendError(res, 404, "REQUEST_NOT_FOUND", "no request found with this id");
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
try {
|
|
1466
|
+
const synced = await syncCallerRequestEvents(request, platformClient);
|
|
1467
|
+
if (synced.acked) {
|
|
1468
|
+
await reportCallerMetric(platformClient, request, "caller.request.acked");
|
|
1469
|
+
}
|
|
1470
|
+
await persistCallerState(onStateChanged, state);
|
|
1471
|
+
sendJson(res, 200, synced);
|
|
1472
|
+
} catch (error) {
|
|
1473
|
+
sendUpstreamError(res, error, "CALLER_PLATFORM_EVENTS_FAILED", "event sync failed");
|
|
1474
|
+
}
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
const ackMatch = pathname.match(/^\/controller\/requests\/([^/]+)\/ack$/);
|
|
1479
|
+
if (method === "POST" && ackMatch) {
|
|
1480
|
+
const platformClient = resolvePlatformClient(req);
|
|
1481
|
+
const request = state.requests.get(ackMatch[1]);
|
|
1482
|
+
if (!request) {
|
|
1483
|
+
sendError(res, 404, "REQUEST_NOT_FOUND", "no request found with this id");
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
if (!TERMINAL_STATUS_SET.has(request.status)) {
|
|
1488
|
+
request.status = "ACKED";
|
|
1489
|
+
request.acknowledged_at = nowIso();
|
|
1490
|
+
request.last_error_code = null;
|
|
1491
|
+
markUpdated(request, "ACKED");
|
|
1492
|
+
}
|
|
1493
|
+
await reportCallerMetric(platformClient, request, "caller.request.acked");
|
|
1494
|
+
await persistCallerState(onStateChanged, state);
|
|
1495
|
+
|
|
1496
|
+
sendJson(res, 200, request);
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
if (method === "POST" && requestResultMatch) {
|
|
1501
|
+
const platformClient = resolvePlatformClient(req);
|
|
1502
|
+
const request = state.requests.get(requestResultMatch[1]);
|
|
1503
|
+
if (!request) {
|
|
1504
|
+
sendError(res, 404, "REQUEST_NOT_FOUND", "no request found with this id");
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
if (TERMINAL_STATUS_SET.has(request.status)) {
|
|
1509
|
+
sendError(res, 409, "REQUEST_ALREADY_TERMINAL", "request has already reached a terminal state", { status: request.status });
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
const body = await parseJsonBody(req);
|
|
1514
|
+
const transition = applyResultPackage(request, body);
|
|
1515
|
+
if (transition?.eventType) {
|
|
1516
|
+
await reportCallerMetric(platformClient, request, transition.eventType, { code: transition.code });
|
|
1517
|
+
}
|
|
1518
|
+
await persistCallerState(onStateChanged, state);
|
|
1519
|
+
sendJson(res, 200, request);
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
if (method === "POST" && pathname === "/controller/inbox/pull") {
|
|
1524
|
+
if (!transport) {
|
|
1525
|
+
sendError(res, 409, "TRANSPORT_NOT_CONFIGURED", "transport adapter is not configured");
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
const body = await parseJsonBody(req);
|
|
1530
|
+
const platformConfig = resolvePlatformConfig(req);
|
|
1531
|
+
const result = await pollCallerInbox(
|
|
1532
|
+
state,
|
|
1533
|
+
transport,
|
|
1534
|
+
() => (platformConfig?.apiKey ? createCallerPlatformClient(platformConfig) : null),
|
|
1535
|
+
onStateChanged,
|
|
1536
|
+
body.receiver || "caller-controller"
|
|
1537
|
+
);
|
|
1538
|
+
sendJson(res, 200, { accepted: result.accepted });
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
const timeoutMatch = pathname.match(/^\/controller\/requests\/([^/]+)\/timeout-decision$/);
|
|
1543
|
+
if (method === "POST" && timeoutMatch) {
|
|
1544
|
+
const platformClient = resolvePlatformClient(req);
|
|
1545
|
+
const request = state.requests.get(timeoutMatch[1]);
|
|
1546
|
+
if (!request) {
|
|
1547
|
+
sendError(res, 404, "REQUEST_NOT_FOUND", "no request found with this id");
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
const body = await parseJsonBody(req);
|
|
1552
|
+
const continueWait = body.continue_wait === true;
|
|
1553
|
+
|
|
1554
|
+
request.timeout_decision = continueWait ? "continue_wait" : "stop_wait";
|
|
1555
|
+
request.needs_timeout_confirmation = false;
|
|
1556
|
+
if (!continueWait && !TERMINAL_STATUS_SET.has(request.status)) {
|
|
1557
|
+
request.status = "TIMED_OUT";
|
|
1558
|
+
request.last_error_code = "EXEC_TIMEOUT_MANUAL_STOP";
|
|
1559
|
+
request.timed_out_at = nowIso();
|
|
1560
|
+
}
|
|
1561
|
+
markUpdated(request, continueWait ? "TIMEOUT_DECISION_CONTINUE" : "TIMEOUT_DECISION_STOP");
|
|
1562
|
+
if (!continueWait) {
|
|
1563
|
+
await reportCallerMetric(platformClient, request, "caller.request.timed_out", {
|
|
1564
|
+
code: request.last_error_code
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
await persistCallerState(onStateChanged, state);
|
|
1568
|
+
|
|
1569
|
+
sendJson(res, 200, request);
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
sendError(res, 404, "not_found", "no matching route", { path: pathname });
|
|
1574
|
+
} catch (error) {
|
|
1575
|
+
if (error.message === "invalid_json") {
|
|
1576
|
+
sendError(res, 400, "CONTRACT_INVALID_JSON", "request body is not valid JSON");
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
sendError(res, 500, "CALLER_CONTROLLER_INTERNAL_ERROR", error instanceof Error ? error.message : "unknown_error", { retryable: true });
|
|
1581
|
+
}
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1584
|
+
const backgroundEnabled = background.enabled === true;
|
|
1585
|
+
let stopBackground = () => {};
|
|
1586
|
+
if (backgroundEnabled) {
|
|
1587
|
+
stopBackground = startCallerBackgroundLoops({
|
|
1588
|
+
state,
|
|
1589
|
+
config,
|
|
1590
|
+
transport,
|
|
1591
|
+
receiver: background.receiver || "caller-controller",
|
|
1592
|
+
inboxPollIntervalMs: Number(background.inboxPollIntervalMs || 250),
|
|
1593
|
+
eventsSyncIntervalMs: Number(background.eventsSyncIntervalMs || 250),
|
|
1594
|
+
platformClientFactory: (request) => {
|
|
1595
|
+
const auth = requestPlatformAuth.get(request.request_id);
|
|
1596
|
+
if (auth?.baseUrl && auth?.apiKey) {
|
|
1597
|
+
return createCallerPlatformClient(auth);
|
|
1598
|
+
}
|
|
1599
|
+
if (defaultBackgroundPlatformClient) {
|
|
1600
|
+
return defaultBackgroundPlatformClient;
|
|
1601
|
+
}
|
|
1602
|
+
return null;
|
|
1603
|
+
},
|
|
1604
|
+
onStateChanged
|
|
1605
|
+
});
|
|
1606
|
+
server.on("close", () => {
|
|
1607
|
+
stopBackground();
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
return server;
|
|
1612
|
+
}
|