@xtrape/capsule-agent-node 0.4.0 → 0.6.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/NOTICE +3 -3
- package/README.md +137 -63
- package/dist/index.cjs +315 -16
- package/dist/index.d.cts +167 -17
- package/dist/index.d.ts +167 -17
- package/dist/index.js +315 -15
- package/package.json +9 -8
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ActionPrepareResult, PublishBusEventRequest, RuntimeKind, HealthReportInput, ConfigItemInput, ActionDefinitionInput, PublishBusEventResponse, ReportedService, RegisterAgentRequest, AgentHeartbeatRequest, ServiceReportRequest, ReportCommandResultRequest } from '@xtrape/capsule-contracts-node';
|
|
1
|
+
import { ActionPrepareResult, PublishBusEventRequest, RuntimeKind, HealthReportInput, ConfigItemInput, ActionDefinitionInput, PublishBusEventResponse, ReportedService, RegisterAgentRequest, AgentHeartbeatRequest, ServiceReportRequest, ReportCommandResultRequest, ConversationMessage, ServiceManifest, ServiceInstance, ServiceInstanceHealth } from '@xtrape/capsule-contracts-node';
|
|
2
2
|
|
|
3
3
|
type AgentMode = "embedded";
|
|
4
4
|
type ActionHandler = (payload: Record<string, unknown>) => Promise<{
|
|
@@ -38,7 +38,15 @@ interface StructuredLogger {
|
|
|
38
38
|
* `structuredLogger` wins.
|
|
39
39
|
*/
|
|
40
40
|
type ConsoleLogger = Pick<Console, "debug" | "info" | "warn" | "error">;
|
|
41
|
-
interface
|
|
41
|
+
interface WorkerDefinition {
|
|
42
|
+
code: string;
|
|
43
|
+
name: string;
|
|
44
|
+
description?: string;
|
|
45
|
+
version: string;
|
|
46
|
+
runtime: RuntimeKind;
|
|
47
|
+
manifest?: Record<string, unknown>;
|
|
48
|
+
}
|
|
49
|
+
interface CapsuleAgentOptionsBase {
|
|
42
50
|
backendUrl: string;
|
|
43
51
|
registrationToken?: string;
|
|
44
52
|
tokenStore?: {
|
|
@@ -49,17 +57,12 @@ interface CapsuleAgentOptions {
|
|
|
49
57
|
name?: string;
|
|
50
58
|
runtime?: RuntimeKind;
|
|
51
59
|
};
|
|
52
|
-
service: {
|
|
53
|
-
code: string;
|
|
54
|
-
name: string;
|
|
55
|
-
description?: string;
|
|
56
|
-
version: string;
|
|
57
|
-
runtime: RuntimeKind;
|
|
58
|
-
manifest?: Record<string, unknown>;
|
|
59
|
-
};
|
|
60
60
|
intervals?: {
|
|
61
61
|
heartbeatMs?: number;
|
|
62
62
|
commandPollMs?: number;
|
|
63
|
+
/** Canonical Worker Runtime Report interval. */
|
|
64
|
+
workerReportMs?: number;
|
|
65
|
+
/** @deprecated Compatibility alias for `workerReportMs`. */
|
|
63
66
|
serviceReportMs?: number;
|
|
64
67
|
};
|
|
65
68
|
heartbeatIntervalSeconds?: number;
|
|
@@ -75,6 +78,19 @@ interface CapsuleAgentOptions {
|
|
|
75
78
|
structuredLogger?: StructuredLogger;
|
|
76
79
|
failOnStartError?: boolean;
|
|
77
80
|
}
|
|
81
|
+
/** Canonical Embedded Agent options: exactly one runtime unit via `worker`. */
|
|
82
|
+
type WorkerAgentOptions = CapsuleAgentOptionsBase & {
|
|
83
|
+
worker: WorkerDefinition;
|
|
84
|
+
/** @deprecated Legacy alias; do not combine with `worker`. */
|
|
85
|
+
service?: never;
|
|
86
|
+
};
|
|
87
|
+
/** Legacy Embedded Agent options: exactly one runtime unit via `service`. */
|
|
88
|
+
type LegacyServiceAgentOptions = CapsuleAgentOptionsBase & {
|
|
89
|
+
/** @deprecated Legacy compatibility alias for `worker`. */
|
|
90
|
+
service: WorkerDefinition;
|
|
91
|
+
worker?: never;
|
|
92
|
+
};
|
|
93
|
+
type CapsuleAgentOptions = WorkerAgentOptions | LegacyServiceAgentOptions;
|
|
78
94
|
type HealthProvider = () => Promise<HealthReportInput> | HealthReportInput;
|
|
79
95
|
type ConfigProvider = () => Promise<ConfigItemInput[]> | ConfigItemInput[];
|
|
80
96
|
interface RegisteredAction extends ActionDefinitionInput {
|
|
@@ -87,9 +103,12 @@ interface TokenStore {
|
|
|
87
103
|
clear(): Promise<void>;
|
|
88
104
|
}
|
|
89
105
|
type ServiceSnapshot = ReportedService;
|
|
90
|
-
|
|
91
|
-
type
|
|
106
|
+
type WorkerSnapshot = ReportedService;
|
|
107
|
+
type XtrapeAgentOptions = CapsuleAgentOptions;
|
|
108
|
+
/** Legacy v0.4 event-routing publish input. Subject to change before v1.0. */
|
|
109
|
+
type PublishBusEventInput = Omit<PublishBusEventRequest, "sourceServiceCode" | "experimental"> & {
|
|
92
110
|
sourceServiceCode?: string;
|
|
111
|
+
experimental?: PublishBusEventRequest["experimental"];
|
|
93
112
|
};
|
|
94
113
|
type PublishBusEventResult = PublishBusEventResponse;
|
|
95
114
|
|
|
@@ -114,11 +133,12 @@ declare class CapsuleAgent {
|
|
|
114
133
|
stop(): Promise<void>;
|
|
115
134
|
private loop;
|
|
116
135
|
private ensureRegistered;
|
|
136
|
+
private workerDefinition;
|
|
117
137
|
private serviceSnapshot;
|
|
118
138
|
runHealth(): Promise<HealthReportInput>;
|
|
119
|
-
/**
|
|
139
|
+
/** Legacy v0.4 event-routing compatibility hook. Subject to change before v1.0. */
|
|
120
140
|
publishBusEvent(input: PublishBusEventInput): Promise<PublishBusEventResult>;
|
|
121
|
-
private
|
|
141
|
+
private reportWorker;
|
|
122
142
|
private heartbeat;
|
|
123
143
|
private pollOnce;
|
|
124
144
|
private execute;
|
|
@@ -156,7 +176,8 @@ declare class AgentApiError extends Error {
|
|
|
156
176
|
/**
|
|
157
177
|
* The backend rejected the registration token (expired, revoked, already
|
|
158
178
|
* used, malformed). The agent cannot proceed without operator action: a
|
|
159
|
-
* fresh registration
|
|
179
|
+
* a fresh registration credential must be created in Panel. The current API
|
|
180
|
+
* still calls this value a registration token.
|
|
160
181
|
*/
|
|
161
182
|
declare class RegistrationError extends AgentApiError {
|
|
162
183
|
constructor(message: string, status: number, body?: unknown, code?: string);
|
|
@@ -201,6 +222,10 @@ declare class AgentApiClient {
|
|
|
201
222
|
}>;
|
|
202
223
|
heartbeat(agentId: string, token: string, req: AgentHeartbeatRequest): Promise<unknown>;
|
|
203
224
|
reportServices(agentId: string, token: string, req: ServiceReportRequest): Promise<unknown>;
|
|
225
|
+
/** Report Workers through the canonical endpoint, with a legacy fallback. */
|
|
226
|
+
reportWorkers(agentId: string, token: string, req: {
|
|
227
|
+
workers: ReportedService[];
|
|
228
|
+
}): Promise<unknown>;
|
|
204
229
|
pollCommands(agentId: string, token: string): Promise<{
|
|
205
230
|
type: "ACTION_EXECUTE" | "ACTION_PREPARE";
|
|
206
231
|
id: string;
|
|
@@ -216,7 +241,7 @@ declare class AgentApiClient {
|
|
|
216
241
|
completedAt?: string | null | undefined;
|
|
217
242
|
}[]>;
|
|
218
243
|
reportResult(agentId: string, token: string, commandId: string, req: ReportCommandResultRequest): Promise<unknown>;
|
|
219
|
-
/**
|
|
244
|
+
/** Legacy v0.4 event-routing compatibility hook. Subject to change before v1.0. */
|
|
220
245
|
publishBusEvent(agentId: string, token: string, req: PublishBusEventInput): Promise<{
|
|
221
246
|
experimental: "v0.4-experimental";
|
|
222
247
|
eventId: string;
|
|
@@ -233,4 +258,129 @@ declare class AgentApiClient {
|
|
|
233
258
|
}>;
|
|
234
259
|
}
|
|
235
260
|
|
|
236
|
-
|
|
261
|
+
/**
|
|
262
|
+
* Xtrape CE Phase 0 service agent.
|
|
263
|
+
*
|
|
264
|
+
* Framework-agnostic SDK for an `xtrape-service` to:
|
|
265
|
+
* - register an instance to `xtrape-server-ce`;
|
|
266
|
+
* - send heartbeats;
|
|
267
|
+
* - validate inbound ConversationMessages and build replies.
|
|
268
|
+
*
|
|
269
|
+
* See xtrape-docs runtime-platform/04 (registration & heartbeat) and /03 (message).
|
|
270
|
+
*/
|
|
271
|
+
interface CeServiceAgentOptions {
|
|
272
|
+
/** xtrape-server-ce base URL, e.g. http://localhost:3000 */
|
|
273
|
+
serverUrl: string;
|
|
274
|
+
/** this service's manifest */
|
|
275
|
+
manifest: ServiceManifest;
|
|
276
|
+
/** this instance's reachable address (URL) */
|
|
277
|
+
address: string;
|
|
278
|
+
/** optional fixed instance id; defaults to `<serviceId>-<rand>` */
|
|
279
|
+
instanceId?: string;
|
|
280
|
+
/**
|
|
281
|
+
* Shared registration token. When server-ce has `SERVER_CE_REGISTRATION_TOKEN`
|
|
282
|
+
* set, registration requires it; the agent sends it as the
|
|
283
|
+
* `x-xtrape-registration-token` header on register.
|
|
284
|
+
*/
|
|
285
|
+
registrationToken?: string;
|
|
286
|
+
/** injectable fetch (tests); defaults to global fetch */
|
|
287
|
+
fetchImpl?: typeof fetch;
|
|
288
|
+
}
|
|
289
|
+
type CeMessageReply = string | {
|
|
290
|
+
content: string;
|
|
291
|
+
messageType?: ConversationMessage["messageType"];
|
|
292
|
+
payload?: Record<string, unknown>;
|
|
293
|
+
};
|
|
294
|
+
/** Handler invoked with a validated inbound message; returns the reply content. */
|
|
295
|
+
type CeMessageHandler = (msg: ConversationMessage) => CeMessageReply | Promise<CeMessageReply>;
|
|
296
|
+
declare class CeServiceAgent {
|
|
297
|
+
readonly serviceId: string;
|
|
298
|
+
readonly instanceId: string;
|
|
299
|
+
private readonly serverUrl;
|
|
300
|
+
private readonly manifest;
|
|
301
|
+
private readonly address;
|
|
302
|
+
private readonly tenant;
|
|
303
|
+
private readonly registrationToken?;
|
|
304
|
+
private readonly fetchImpl;
|
|
305
|
+
private health;
|
|
306
|
+
private hbTimer?;
|
|
307
|
+
constructor(opts: CeServiceAgentOptions);
|
|
308
|
+
/** The ServiceInstance this agent reports on register/heartbeat. */
|
|
309
|
+
instance(): ServiceInstance;
|
|
310
|
+
/** POST /v1/instances { manifest, instance } (sends the registration token if set). */
|
|
311
|
+
register(): Promise<{
|
|
312
|
+
instanceId: string;
|
|
313
|
+
}>;
|
|
314
|
+
/**
|
|
315
|
+
* Lifecycle convenience: register, then start periodic heartbeats. Returns a
|
|
316
|
+
* `stop()` that stops heartbeats and best-effort deregisters — so a service
|
|
317
|
+
* does not have to wire register/heartbeat/deregister by hand.
|
|
318
|
+
*/
|
|
319
|
+
start(opts?: {
|
|
320
|
+
heartbeatMs?: number;
|
|
321
|
+
metrics?: () => Record<string, unknown>;
|
|
322
|
+
}): Promise<() => Promise<void>>;
|
|
323
|
+
setHealth(h: ServiceInstanceHealth): void;
|
|
324
|
+
/** POST /v1/instances/{id}/heartbeat */
|
|
325
|
+
sendHeartbeat(metricsSummary?: Record<string, unknown>): Promise<void>;
|
|
326
|
+
/** Start a periodic heartbeat; returns a stop function. Timer is unref'd. */
|
|
327
|
+
startHeartbeat(intervalMs?: number, metrics?: () => Record<string, unknown>): () => void;
|
|
328
|
+
stopHeartbeat(): void;
|
|
329
|
+
/** DELETE /v1/instances/{id} — graceful deregister. */
|
|
330
|
+
deregister(): Promise<void>;
|
|
331
|
+
/**
|
|
332
|
+
* Validate an inbound message, run the handler, and build the reply message
|
|
333
|
+
* (senderType=SERVICE, targeted back at the sender, with replyToMessageId).
|
|
334
|
+
*/
|
|
335
|
+
handleMessage(raw: unknown, handler: CeMessageHandler): Promise<ConversationMessage>;
|
|
336
|
+
private post;
|
|
337
|
+
private fetchJson;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* One-call bootstrap for an `xtrape-service`: start an HTTP listener that serves
|
|
342
|
+
* `POST /messages` (and `GET /health`), register the instance to xtrape-server-ce,
|
|
343
|
+
* and run heartbeats. Returns a handle whose `close()` stops heartbeats,
|
|
344
|
+
* deregisters, and closes the listener.
|
|
345
|
+
*
|
|
346
|
+
* Uses only `node:http` so it stays framework-agnostic. A service that already
|
|
347
|
+
* has an HTTP server can use `CeServiceAgent` directly instead.
|
|
348
|
+
*/
|
|
349
|
+
interface ServeCeServiceOptions {
|
|
350
|
+
/** xtrape-server-ce base URL, e.g. http://localhost:3000 */
|
|
351
|
+
serverUrl: string;
|
|
352
|
+
manifest: ServiceManifest;
|
|
353
|
+
/** business handler invoked per inbound ConversationMessage */
|
|
354
|
+
handler: CeMessageHandler;
|
|
355
|
+
/** shared registration token, if server-ce requires one */
|
|
356
|
+
registrationToken?: string;
|
|
357
|
+
/** listen port; 0 picks an ephemeral port (default 0) */
|
|
358
|
+
port?: number;
|
|
359
|
+
/** listen host (default 0.0.0.0) */
|
|
360
|
+
host?: string;
|
|
361
|
+
/**
|
|
362
|
+
* Address server-ce should call back.
|
|
363
|
+
*
|
|
364
|
+
* Required for deployed/containerized use. When omitted, the SDK only derives
|
|
365
|
+
* a default address for explicit local-only hosts such as `127.0.0.1` or
|
|
366
|
+
* `localhost`.
|
|
367
|
+
*/
|
|
368
|
+
address?: string;
|
|
369
|
+
/** heartbeat interval ms; 0 disables (default 10000) */
|
|
370
|
+
heartbeatMs?: number;
|
|
371
|
+
instanceId?: string;
|
|
372
|
+
/** injectable fetch (tests); defaults to global fetch */
|
|
373
|
+
fetchImpl?: typeof fetch;
|
|
374
|
+
}
|
|
375
|
+
interface ServeCeServiceHandle {
|
|
376
|
+
agent: CeServiceAgent;
|
|
377
|
+
/** advertised address registered with server-ce */
|
|
378
|
+
address: string;
|
|
379
|
+
/** actual listening port */
|
|
380
|
+
port: number;
|
|
381
|
+
/** stop heartbeats, deregister (best-effort), and close the HTTP server */
|
|
382
|
+
close(): Promise<void>;
|
|
383
|
+
}
|
|
384
|
+
declare function serveCeService(opts: ServeCeServiceOptions): Promise<ServeCeServiceHandle>;
|
|
385
|
+
|
|
386
|
+
export { type ActionHandler, type ActionPrepareHandler, AgentApiClient, AgentApiError, AgentAuthError, type AgentLogLevel, type AgentLogRecord, type AgentMode, BusDepthExceededError, BusDisabledError, BusRateLimitedError, CapsuleAgent, type CapsuleAgentOptions, type CeMessageHandler, type CeMessageReply, CeServiceAgent, type CeServiceAgentOptions, type ConfigProvider, type ConsoleLogger, FileTokenStore, type HealthProvider, type LegacyServiceAgentOptions, NetworkError, type PublishBusEventInput, type PublishBusEventResult, type RegisteredAction, RegistrationError, type ServeCeServiceHandle, type ServeCeServiceOptions, type ServiceSnapshot, type StructuredLogger, type TokenStore, type WorkerAgentOptions, type WorkerDefinition, type WorkerSnapshot, CapsuleAgent as XtrapeAgent, type XtrapeAgentOptions, serveCeService };
|
package/dist/index.js
CHANGED
|
@@ -101,13 +101,22 @@ var AgentApiClient = class {
|
|
|
101
101
|
reportServices(agentId, token, req) {
|
|
102
102
|
return this.request(`/api/agents/${agentId}/services/report`, { method: "POST", token, body: JSON.stringify(req) });
|
|
103
103
|
}
|
|
104
|
+
/** Report Workers through the canonical endpoint, with a legacy fallback. */
|
|
105
|
+
async reportWorkers(agentId, token, req) {
|
|
106
|
+
try {
|
|
107
|
+
return await this.request(`/api/agents/${agentId}/workers/report`, { method: "POST", token, body: JSON.stringify(req) });
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (!(error instanceof AgentApiError) || error.status !== 404) throw error;
|
|
110
|
+
return this.reportServices(agentId, token, { services: req.workers });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
104
113
|
pollCommands(agentId, token) {
|
|
105
114
|
return this.request(`/api/agents/${agentId}/commands`, { method: "GET", token });
|
|
106
115
|
}
|
|
107
116
|
reportResult(agentId, token, commandId, req) {
|
|
108
117
|
return this.request(`/api/agents/${agentId}/commands/${commandId}/result`, { method: "POST", token, body: JSON.stringify(req) });
|
|
109
118
|
}
|
|
110
|
-
/**
|
|
119
|
+
/** Legacy v0.4 event-routing compatibility hook. Subject to change before v1.0. */
|
|
111
120
|
publishBusEvent(agentId, token, req) {
|
|
112
121
|
return this.request(`/api/agents/${agentId}/bus/events`, { method: "POST", token, body: JSON.stringify({ ...req, experimental: req.experimental ?? "v0.4-experimental" }) });
|
|
113
122
|
}
|
|
@@ -180,6 +189,12 @@ function emitLog(consoleSink, structured, level, message, fields) {
|
|
|
180
189
|
var CapsuleAgent = class {
|
|
181
190
|
constructor(options) {
|
|
182
191
|
this.options = options;
|
|
192
|
+
if (options.worker && options.service) {
|
|
193
|
+
throw new Error("Agent options accept `worker` or legacy `service`, not both.");
|
|
194
|
+
}
|
|
195
|
+
if (!options.worker && !options.service) {
|
|
196
|
+
throw new Error("Agent options require `worker` (or legacy `service`).");
|
|
197
|
+
}
|
|
183
198
|
this.client = new AgentApiClient(options.backendUrl);
|
|
184
199
|
this.store = new FileTokenStore(options.tokenStore?.file);
|
|
185
200
|
this.logger = options.logger ?? console;
|
|
@@ -213,7 +228,7 @@ var CapsuleAgent = class {
|
|
|
213
228
|
this.stopped = false;
|
|
214
229
|
try {
|
|
215
230
|
await this.ensureRegistered();
|
|
216
|
-
await this.
|
|
231
|
+
await this.reportWorker();
|
|
217
232
|
await this.heartbeat();
|
|
218
233
|
await this.pollOnce();
|
|
219
234
|
} catch (e) {
|
|
@@ -222,7 +237,7 @@ var CapsuleAgent = class {
|
|
|
222
237
|
}
|
|
223
238
|
if (this.options.autoStartLoops !== false) {
|
|
224
239
|
this.loop("heartbeat", this.options.intervals?.heartbeatMs ?? (this.options.heartbeatIntervalSeconds ?? 30) * 1e3, () => this.heartbeat());
|
|
225
|
-
this.loop("
|
|
240
|
+
this.loop("worker-report", this.options.intervals?.workerReportMs ?? this.options.intervals?.serviceReportMs ?? 6e4, () => this.reportWorker());
|
|
226
241
|
this.loop("command-poll", this.options.intervals?.commandPollMs ?? (this.options.commandPollIntervalSeconds ?? 5) * 1e3, () => this.pollOnce());
|
|
227
242
|
}
|
|
228
243
|
}
|
|
@@ -251,38 +266,45 @@ var CapsuleAgent = class {
|
|
|
251
266
|
if (this.agentId) return;
|
|
252
267
|
}
|
|
253
268
|
if (!this.options.registrationToken) throw new Error("OPSTAGE registration token is required for first registration");
|
|
254
|
-
const
|
|
269
|
+
const worker = this.workerDefinition();
|
|
270
|
+
const agent = this.options.agent ?? { code: worker.code, name: worker.name, runtime: worker.runtime };
|
|
255
271
|
const res = await this.retry(() => this.client.register({ registrationToken: this.options.registrationToken, agent: { code: agent.code, name: agent.name, mode: "embedded", runtime: agent.runtime ?? "nodejs" }, service: this.serviceSnapshot() }));
|
|
256
272
|
this.agentId = res.agentId;
|
|
257
273
|
this.token = res.agentToken;
|
|
258
274
|
await this.store.save(`${this.agentId}:${this.token}`);
|
|
259
275
|
this.log("info", `registered agent ${this.agentId}`);
|
|
260
276
|
}
|
|
277
|
+
workerDefinition() {
|
|
278
|
+
if (this.options.worker) {
|
|
279
|
+
return this.options.worker;
|
|
280
|
+
}
|
|
281
|
+
return this.options.service;
|
|
282
|
+
}
|
|
261
283
|
serviceSnapshot() {
|
|
262
|
-
const
|
|
263
|
-
|
|
284
|
+
const worker = this.workerDefinition();
|
|
285
|
+
const manifest = { kind: "Worker", schemaVersion: "1.0", code: worker.code, name: worker.name, description: worker.description, version: worker.version, runtime: worker.runtime, agentMode: "embedded", ...worker.manifest ?? {} };
|
|
286
|
+
return { code: worker.code, name: worker.name, description: worker.description, version: worker.version, runtime: worker.runtime, manifest, actions: [...this.actions.values()].map(({ handler, prepare, inputSchema, outputSchema, ...a }) => a) };
|
|
264
287
|
}
|
|
265
288
|
async runHealth() {
|
|
266
289
|
return this.healthProvider();
|
|
267
290
|
}
|
|
268
|
-
/**
|
|
291
|
+
/** Legacy v0.4 event-routing compatibility hook. Subject to change before v1.0. */
|
|
269
292
|
async publishBusEvent(input) {
|
|
270
|
-
if (!this.agentId || !this.token) throw new Error("Agent must be started before publishing
|
|
271
|
-
const sourceServiceCode = input.sourceServiceCode ?? this.
|
|
293
|
+
if (!this.agentId || !this.token) throw new Error("Agent must be started before publishing routed events.");
|
|
294
|
+
const sourceServiceCode = input.sourceServiceCode ?? this.workerDefinition().code;
|
|
272
295
|
const result = await this.retry(() => this.client.publishBusEvent(this.agentId, this.token, { ...input, sourceServiceCode }));
|
|
273
296
|
this.log("info", "experimental bus event published", { eventId: result.eventId, eventType: input.eventType, sourceServiceCode, routedCommands: result.routedCommands.length });
|
|
274
297
|
return result;
|
|
275
298
|
}
|
|
276
|
-
async
|
|
299
|
+
async reportWorker() {
|
|
277
300
|
if (!this.agentId || !this.token) return;
|
|
278
301
|
const [health, configs] = await Promise.all([this.runHealth(), this.configProvider()]);
|
|
279
|
-
const
|
|
280
|
-
await this.retry(() => this.client.
|
|
302
|
+
const worker = { ...this.serviceSnapshot(), health, configs };
|
|
303
|
+
await this.retry(() => this.client.reportWorkers(this.agentId, this.token, { workers: [worker] }));
|
|
281
304
|
}
|
|
282
305
|
async heartbeat() {
|
|
283
306
|
if (!this.agentId || !this.token) return;
|
|
284
|
-
|
|
285
|
-
await this.retry(() => this.client.heartbeat(this.agentId, this.token, { health }));
|
|
307
|
+
await this.retry(() => this.client.heartbeat(this.agentId, this.token, {}));
|
|
286
308
|
}
|
|
287
309
|
async pollOnce() {
|
|
288
310
|
if (!this.agentId || !this.token) return;
|
|
@@ -368,6 +390,281 @@ var CapsuleAgent = class {
|
|
|
368
390
|
return { value: obj };
|
|
369
391
|
}
|
|
370
392
|
};
|
|
393
|
+
|
|
394
|
+
// src/ce/index.ts
|
|
395
|
+
import { randomUUID } from "crypto";
|
|
396
|
+
import {
|
|
397
|
+
CE_CONTRACT_VERSION,
|
|
398
|
+
ServiceInstanceSchema,
|
|
399
|
+
ConversationMessageSchema
|
|
400
|
+
} from "@xtrape/capsule-contracts-node";
|
|
401
|
+
var CeInboundMessageValidationError = class extends Error {
|
|
402
|
+
constructor(message, options) {
|
|
403
|
+
super(message, options);
|
|
404
|
+
this.name = "CeInboundMessageValidationError";
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
var CeServiceAgent = class {
|
|
408
|
+
serviceId;
|
|
409
|
+
instanceId;
|
|
410
|
+
serverUrl;
|
|
411
|
+
manifest;
|
|
412
|
+
address;
|
|
413
|
+
tenant;
|
|
414
|
+
registrationToken;
|
|
415
|
+
fetchImpl;
|
|
416
|
+
health = "HEALTHY";
|
|
417
|
+
hbTimer;
|
|
418
|
+
constructor(opts) {
|
|
419
|
+
this.serverUrl = opts.serverUrl.replace(/\/+$/, "");
|
|
420
|
+
this.manifest = opts.manifest;
|
|
421
|
+
this.address = opts.address;
|
|
422
|
+
this.tenant = opts.manifest.tenant ?? "default";
|
|
423
|
+
this.serviceId = opts.manifest.service.id;
|
|
424
|
+
this.instanceId = opts.instanceId ?? `${this.serviceId}-${randomUUID().slice(0, 8)}`;
|
|
425
|
+
this.registrationToken = opts.registrationToken;
|
|
426
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
427
|
+
}
|
|
428
|
+
/** The ServiceInstance this agent reports on register/heartbeat. */
|
|
429
|
+
instance() {
|
|
430
|
+
return ServiceInstanceSchema.parse({
|
|
431
|
+
contractVersion: CE_CONTRACT_VERSION,
|
|
432
|
+
serviceId: this.serviceId,
|
|
433
|
+
instanceId: this.instanceId,
|
|
434
|
+
tenant: this.tenant,
|
|
435
|
+
address: this.address,
|
|
436
|
+
version: this.manifest.service.version,
|
|
437
|
+
health: this.health
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
/** POST /v1/instances { manifest, instance } (sends the registration token if set). */
|
|
441
|
+
async register() {
|
|
442
|
+
const headers = this.registrationToken ? { "x-xtrape-registration-token": this.registrationToken } : void 0;
|
|
443
|
+
await this.post("/v1/instances", { manifest: this.manifest, instance: this.instance() }, headers);
|
|
444
|
+
return { instanceId: this.instanceId };
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Lifecycle convenience: register, then start periodic heartbeats. Returns a
|
|
448
|
+
* `stop()` that stops heartbeats and best-effort deregisters — so a service
|
|
449
|
+
* does not have to wire register/heartbeat/deregister by hand.
|
|
450
|
+
*/
|
|
451
|
+
async start(opts = {}) {
|
|
452
|
+
await this.register();
|
|
453
|
+
if (opts.heartbeatMs !== 0) this.startHeartbeat(opts.heartbeatMs ?? 1e4, opts.metrics);
|
|
454
|
+
return async () => {
|
|
455
|
+
this.stopHeartbeat();
|
|
456
|
+
await this.deregister().catch(() => {
|
|
457
|
+
});
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
setHealth(h) {
|
|
461
|
+
this.health = h;
|
|
462
|
+
}
|
|
463
|
+
/** POST /v1/instances/{id}/heartbeat */
|
|
464
|
+
async sendHeartbeat(metricsSummary) {
|
|
465
|
+
const hb = {
|
|
466
|
+
contractVersion: CE_CONTRACT_VERSION,
|
|
467
|
+
serviceId: this.serviceId,
|
|
468
|
+
instanceId: this.instanceId,
|
|
469
|
+
tenant: this.tenant,
|
|
470
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
471
|
+
health: this.health,
|
|
472
|
+
...metricsSummary ? { metricsSummary } : {}
|
|
473
|
+
};
|
|
474
|
+
await this.post(`/v1/instances/${encodeURIComponent(this.instanceId)}/heartbeat`, hb);
|
|
475
|
+
}
|
|
476
|
+
/** Start a periodic heartbeat; returns a stop function. Timer is unref'd. */
|
|
477
|
+
startHeartbeat(intervalMs = 1e4, metrics) {
|
|
478
|
+
this.stopHeartbeat();
|
|
479
|
+
this.hbTimer = setInterval(() => {
|
|
480
|
+
void this.sendHeartbeat(metrics?.()).catch(() => {
|
|
481
|
+
});
|
|
482
|
+
}, intervalMs);
|
|
483
|
+
this.hbTimer.unref?.();
|
|
484
|
+
return () => this.stopHeartbeat();
|
|
485
|
+
}
|
|
486
|
+
stopHeartbeat() {
|
|
487
|
+
if (this.hbTimer) {
|
|
488
|
+
clearInterval(this.hbTimer);
|
|
489
|
+
this.hbTimer = void 0;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/** DELETE /v1/instances/{id} — graceful deregister. */
|
|
493
|
+
async deregister() {
|
|
494
|
+
await this.fetchJson(`/v1/instances/${encodeURIComponent(this.instanceId)}`, { method: "DELETE" });
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Validate an inbound message, run the handler, and build the reply message
|
|
498
|
+
* (senderType=SERVICE, targeted back at the sender, with replyToMessageId).
|
|
499
|
+
*/
|
|
500
|
+
async handleMessage(raw, handler) {
|
|
501
|
+
let msg;
|
|
502
|
+
try {
|
|
503
|
+
msg = ConversationMessageSchema.parse(raw);
|
|
504
|
+
} catch (error) {
|
|
505
|
+
throw new CeInboundMessageValidationError(
|
|
506
|
+
error instanceof Error ? error.message : "invalid inbound ConversationMessage",
|
|
507
|
+
{ cause: error }
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
const out = await handler(msg);
|
|
511
|
+
const reply = typeof out === "string" ? { content: out } : out;
|
|
512
|
+
return ConversationMessageSchema.parse({
|
|
513
|
+
contractVersion: CE_CONTRACT_VERSION,
|
|
514
|
+
messageId: `msg_${randomUUID().slice(0, 12)}`,
|
|
515
|
+
conversationId: msg.conversationId,
|
|
516
|
+
tenant: msg.tenant ?? "default",
|
|
517
|
+
// reply back to whoever sent it (USER/SERVICE/SYSTEM are all valid targets)
|
|
518
|
+
targetType: msg.senderType,
|
|
519
|
+
targetId: msg.senderId,
|
|
520
|
+
senderType: "SERVICE",
|
|
521
|
+
senderId: this.serviceId,
|
|
522
|
+
messageType: reply.messageType ?? "SUMMARY",
|
|
523
|
+
content: reply.content,
|
|
524
|
+
replyToMessageId: msg.messageId,
|
|
525
|
+
...reply.payload ? { payload: reply.payload } : {}
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
post(path, body, headers) {
|
|
529
|
+
return this.fetchJson(path, { method: "POST", body: JSON.stringify(body), ...headers ? { headers } : {} });
|
|
530
|
+
}
|
|
531
|
+
async fetchJson(path, init) {
|
|
532
|
+
const res = await this.fetchImpl(this.serverUrl + path, {
|
|
533
|
+
...init,
|
|
534
|
+
headers: { "content-type": "application/json", ...init.headers ?? {} }
|
|
535
|
+
});
|
|
536
|
+
const text = await res.text();
|
|
537
|
+
if (!res.ok) {
|
|
538
|
+
throw new Error(`server-ce ${path} -> ${res.status}: ${text}`);
|
|
539
|
+
}
|
|
540
|
+
if (!text) return void 0;
|
|
541
|
+
try {
|
|
542
|
+
return JSON.parse(text);
|
|
543
|
+
} catch (error) {
|
|
544
|
+
throw new Error(`server-ce ${path} returned non-JSON success response: ${text}`, { cause: error });
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
// src/ce/serve.ts
|
|
550
|
+
import { createServer } from "http";
|
|
551
|
+
function send(res, status, body) {
|
|
552
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
553
|
+
res.end(JSON.stringify(body));
|
|
554
|
+
}
|
|
555
|
+
function readBody(req) {
|
|
556
|
+
return new Promise((resolve, reject) => {
|
|
557
|
+
let data = "";
|
|
558
|
+
req.on("data", (c) => data += c);
|
|
559
|
+
req.on("end", () => resolve(data));
|
|
560
|
+
req.on("error", reject);
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
function isLocalOnlyHost(host) {
|
|
564
|
+
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
565
|
+
}
|
|
566
|
+
function defaultLocalAddress(host, port) {
|
|
567
|
+
if (host === "::1") return `http://[::1]:${port}`;
|
|
568
|
+
return `http://${host}:${port}`;
|
|
569
|
+
}
|
|
570
|
+
function listen(server, port, host) {
|
|
571
|
+
return new Promise((resolve, reject) => {
|
|
572
|
+
const onError = (error) => {
|
|
573
|
+
server.off("listening", onListening);
|
|
574
|
+
reject(error);
|
|
575
|
+
};
|
|
576
|
+
const onListening = () => {
|
|
577
|
+
server.off("error", onError);
|
|
578
|
+
resolve();
|
|
579
|
+
};
|
|
580
|
+
server.once("error", onError);
|
|
581
|
+
server.once("listening", onListening);
|
|
582
|
+
server.listen(port, host);
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
function sendMessageError(res, error) {
|
|
586
|
+
if (error instanceof CeInboundMessageValidationError) {
|
|
587
|
+
send(res, 400, { code: "VALIDATION_FAILED", message: error.message, retryable: false });
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const message = error instanceof Error ? error.message : "message handler failed";
|
|
591
|
+
send(res, 500, { code: "HANDLER_FAILED", message, retryable: true });
|
|
592
|
+
}
|
|
593
|
+
async function serveCeService(opts) {
|
|
594
|
+
const host = opts.host ?? "0.0.0.0";
|
|
595
|
+
let agent;
|
|
596
|
+
const server = createServer((req, res) => {
|
|
597
|
+
void (async () => {
|
|
598
|
+
try {
|
|
599
|
+
if (req.method === "GET" && req.url === "/health") {
|
|
600
|
+
send(res, 200, { ok: true, service: opts.manifest.service.id });
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (req.method === "POST" && req.url === "/messages") {
|
|
604
|
+
if (!agent) {
|
|
605
|
+
send(res, 503, { code: "NOT_READY", message: "service not registered yet", retryable: true });
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
const raw = await readBody(req);
|
|
609
|
+
let payload;
|
|
610
|
+
try {
|
|
611
|
+
payload = raw ? JSON.parse(raw) : {};
|
|
612
|
+
} catch (error) {
|
|
613
|
+
send(res, 400, {
|
|
614
|
+
code: "VALIDATION_FAILED",
|
|
615
|
+
message: error instanceof Error ? error.message : "invalid JSON request body",
|
|
616
|
+
retryable: false
|
|
617
|
+
});
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
const reply = await agent.handleMessage(payload, opts.handler);
|
|
621
|
+
send(res, 200, reply);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
send(res, 404, { code: "NOT_FOUND", message: "not found", retryable: false });
|
|
625
|
+
} catch (e) {
|
|
626
|
+
sendMessageError(res, e);
|
|
627
|
+
}
|
|
628
|
+
})();
|
|
629
|
+
});
|
|
630
|
+
await listen(server, opts.port ?? 0, host);
|
|
631
|
+
const info = server.address();
|
|
632
|
+
const port = typeof info === "object" && info ? info.port : opts.port ?? 0;
|
|
633
|
+
const address = opts.address ?? (isLocalOnlyHost(host) ? defaultLocalAddress(host, port) : void 0);
|
|
634
|
+
if (!address) {
|
|
635
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
636
|
+
throw new Error(
|
|
637
|
+
`serveCeService requires an explicit address when host=${host}. For deployed/containerized use, pass a reachable callback URL such as http://demo-service:8080.`
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
try {
|
|
641
|
+
agent = new CeServiceAgent({
|
|
642
|
+
serverUrl: opts.serverUrl,
|
|
643
|
+
manifest: opts.manifest,
|
|
644
|
+
address,
|
|
645
|
+
instanceId: opts.instanceId,
|
|
646
|
+
registrationToken: opts.registrationToken,
|
|
647
|
+
fetchImpl: opts.fetchImpl
|
|
648
|
+
});
|
|
649
|
+
await agent.register();
|
|
650
|
+
if (opts.heartbeatMs !== 0) agent.startHeartbeat(opts.heartbeatMs ?? 1e4);
|
|
651
|
+
} catch (error) {
|
|
652
|
+
agent?.stopHeartbeat();
|
|
653
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
654
|
+
throw error;
|
|
655
|
+
}
|
|
656
|
+
return {
|
|
657
|
+
agent,
|
|
658
|
+
address,
|
|
659
|
+
port,
|
|
660
|
+
async close() {
|
|
661
|
+
agent?.stopHeartbeat();
|
|
662
|
+
await agent?.deregister().catch(() => {
|
|
663
|
+
});
|
|
664
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
}
|
|
371
668
|
export {
|
|
372
669
|
AgentApiClient,
|
|
373
670
|
AgentApiError,
|
|
@@ -376,7 +673,10 @@ export {
|
|
|
376
673
|
BusDisabledError,
|
|
377
674
|
BusRateLimitedError,
|
|
378
675
|
CapsuleAgent,
|
|
676
|
+
CeServiceAgent,
|
|
379
677
|
FileTokenStore,
|
|
380
678
|
NetworkError,
|
|
381
|
-
RegistrationError
|
|
679
|
+
RegistrationError,
|
|
680
|
+
CapsuleAgent as XtrapeAgent,
|
|
681
|
+
serveCeService
|
|
382
682
|
};
|