@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/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 CapsuleAgentOptions {
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
- /** Experimental v0.4 Capsule Bus publish input. Subject to change before v1.0. */
91
- type PublishBusEventInput = Omit<PublishBusEventRequest, "sourceServiceCode"> & {
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
- /** Experimental v0.4 Capsule Bus publish hook. Subject to change before v1.0. */
139
+ /** Legacy v0.4 event-routing compatibility hook. Subject to change before v1.0. */
120
140
  publishBusEvent(input: PublishBusEventInput): Promise<PublishBusEventResult>;
121
- private reportService;
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 token must be created in Opstage.
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
- /** Experimental v0.4 Capsule Bus publish hook. Subject to change before v1.0. */
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
- export { type ActionHandler, type ActionPrepareHandler, AgentApiClient, AgentApiError, AgentAuthError, type AgentLogLevel, type AgentLogRecord, type AgentMode, BusDepthExceededError, BusDisabledError, BusRateLimitedError, CapsuleAgent, type CapsuleAgentOptions, type ConfigProvider, type ConsoleLogger, FileTokenStore, type HealthProvider, NetworkError, type PublishBusEventInput, type PublishBusEventResult, type RegisteredAction, RegistrationError, type ServiceSnapshot, type StructuredLogger, type TokenStore };
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
- /** Experimental v0.4 Capsule Bus publish hook. Subject to change before v1.0. */
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.reportService();
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("service-report", this.options.intervals?.serviceReportMs ?? 6e4, () => this.reportService());
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 agent = this.options.agent ?? { code: this.options.service.code, name: this.options.service.name, runtime: this.options.service.runtime };
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 manifest = { kind: "CapsuleService", schemaVersion: "1.0", code: this.options.service.code, name: this.options.service.name, description: this.options.service.description, version: this.options.service.version, runtime: this.options.service.runtime, agentMode: "embedded", ...this.options.service.manifest ?? {} };
263
- return { code: this.options.service.code, name: this.options.service.name, description: this.options.service.description, version: this.options.service.version, runtime: this.options.service.runtime, manifest, actions: [...this.actions.values()].map(({ handler, prepare, inputSchema, outputSchema, ...a }) => a) };
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
- /** Experimental v0.4 Capsule Bus publish hook. Subject to change before v1.0. */
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 Capsule Bus events.");
271
- const sourceServiceCode = input.sourceServiceCode ?? this.options.service.code;
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 reportService() {
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 svc = { ...this.serviceSnapshot(), health, configs };
280
- await this.retry(() => this.client.reportServices(this.agentId, this.token, { services: [svc] }));
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
- const health = await this.runHealth();
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
  };