@mcoda/mswarm 0.1.80 → 0.1.85

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/runtime.js CHANGED
@@ -3,8 +3,9 @@ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { cpus, freemem, hostname, homedir, loadavg, platform, totalmem, userInfo } from "node:os";
4
4
  import { spawn } from "node:child_process";
5
5
  import { createHash, createHmac, randomUUID } from "node:crypto";
6
+ import { GlobalRepository } from "@mcoda/db";
6
7
  import { MswarmCodaliExecutor } from "./codali-executor.js";
7
- import { MSWARM_CAPABILITY_SCHEMA_VERSION, assertMswarmSafeRelativePath, validateMswarmArchiveEntry, buildMswarmCapabilityNames, buildMswarmPrivateCapabilityCatalogEntry, buildMswarmLocalArtifactUri, buildMswarmSandboxProfile, defaultMswarmArtifactAccessPolicy, defaultMswarmArtifactRetentionPolicy, projectMswarmPublicCapabilities, validateMswarmGenericJobRequest } from "@mcoda/shared";
8
+ import { MSWARM_CAPABILITY_SCHEMA_VERSION, CryptoHelper, assertMswarmSafeRelativePath, validateMswarmArchiveEntry, buildMswarmCapabilityNames, buildMswarmPrivateCapabilityCatalogEntry, buildMswarmLocalArtifactUri, buildMswarmSandboxProfile, defaultMswarmArtifactAccessPolicy, defaultMswarmArtifactRetentionPolicy, projectMswarmPublicCapabilities, validateMswarmGenericJobRequest } from "@mcoda/shared";
8
9
  const DEFAULT_GATEWAY_BASE_URL = "http://127.0.0.1:8080";
9
10
  const DEFAULT_SETUP_GATEWAY_BASE_URL = "https://api.mswarm.org";
10
11
  const DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434";
@@ -26,7 +27,12 @@ const DEFAULT_MCODA_LIST_ARGS = ["agent", "list", "--json", "--refresh-health"];
26
27
  const DEFAULT_COMMAND_MAX_BUFFER = 16 * 1024 * 1024;
27
28
  const DEFAULT_LOCAL_ARTIFACT_MAX_BYTES = 512 * 1024 * 1024;
28
29
  const DEFAULT_JOB_POLL_WAIT_MS = 25000;
30
+ const DEFAULT_SELF_HOSTED_JOBS_POLL_PATH = "/v1/swarm/self-hosted/node/jobs/poll";
31
+ const DEFAULT_SELF_HOSTED_JOBS_START_PATH_TEMPLATE = "/v1/swarm/self-hosted/node/jobs/:jobId/start";
32
+ const DEFAULT_SELF_HOSTED_JOBS_EVENTS_PATH_TEMPLATE = "/v1/swarm/self-hosted/node/jobs/:jobId/events";
33
+ const DEFAULT_SELF_HOSTED_JOBS_RESULT_PATH_TEMPLATE = "/v1/swarm/self-hosted/node/jobs/:jobId/result";
29
34
  const DEFAULT_STREAM_EVENT_BATCH_SIZE = 8;
35
+ const SELF_HOSTED_PROTOCOL_MISMATCH_CODE = "self_hosted_protocol_mismatch";
30
36
  const OWNER_LOCAL_TEST_ECHO_JOB_TYPE = "tenant.test-echo";
31
37
  const TEST_ECHO_RUNNER_ID = "test.echo";
32
38
  const RENDER_BLENDER_JOB_TYPE = "render.blender";
@@ -402,6 +408,37 @@ function parseCliOptions(argv) {
402
408
  function trimTrailingSlash(value) {
403
409
  return value.replace(/\/+$/g, "");
404
410
  }
411
+ function lifecyclePath(value, fallback) {
412
+ return optionalText(value) || fallback;
413
+ }
414
+ function resolveLifecycleUrl(gatewayBaseUrl, pathOrUrl) {
415
+ try {
416
+ return new URL(pathOrUrl).toString();
417
+ }
418
+ catch {
419
+ return new URL(pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`, `${trimTrailingSlash(gatewayBaseUrl)}/`).toString();
420
+ }
421
+ }
422
+ function resolveLifecycleTemplate(template, jobId) {
423
+ const encoded = encodeURIComponent(jobId);
424
+ return template
425
+ .replace(/:jobId\b/g, encoded)
426
+ .replace(/:job_id\b/g, encoded)
427
+ .replace(/\{jobId\}/g, encoded)
428
+ .replace(/\{job_id\}/g, encoded);
429
+ }
430
+ function usageFromTokenCounts(promptTokens, completionTokens) {
431
+ const prompt = positiveInteger(promptTokens) ?? 0;
432
+ const completion = positiveInteger(completionTokens) ?? 0;
433
+ return { prompt_tokens: prompt, completion_tokens: completion, total_tokens: prompt + completion };
434
+ }
435
+ function openAiUsage(response) {
436
+ const usage = isRecord(response?.usage) ? response.usage : undefined;
437
+ const prompt = positiveInteger(usage?.prompt_tokens) ?? 0;
438
+ const completion = positiveInteger(usage?.completion_tokens) ?? 0;
439
+ const total = positiveInteger(usage?.total_tokens) ?? prompt + completion;
440
+ return { prompt_tokens: prompt, completion_tokens: completion, total_tokens: total };
441
+ }
405
442
  function defaultStatePath() {
406
443
  return join(homedir(), ".mswarm", "self-hosted-node", "config.json");
407
444
  }
@@ -631,6 +668,18 @@ function isAgentExposed(agentSlug, defaultModel, bestUsage, config) {
631
668
  }
632
669
  return config.exposeAllModels;
633
670
  }
671
+ class HttpStatusError extends Error {
672
+ constructor(url, status, responseText) {
673
+ super(`request_failed:${status}:${responseText.slice(0, 200)}`);
674
+ this.name = "HttpStatusError";
675
+ this.url = url;
676
+ this.status = status;
677
+ this.responseText = responseText;
678
+ }
679
+ }
680
+ function isHttpStatusError(error, status) {
681
+ return error instanceof HttpStatusError && (status === undefined || error.status === status);
682
+ }
634
683
  async function fetchJson(fetchImpl, url, init, timeoutMs) {
635
684
  const controller = new AbortController();
636
685
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
@@ -638,7 +687,7 @@ async function fetchJson(fetchImpl, url, init, timeoutMs) {
638
687
  const response = await fetchImpl(url, { ...init, signal: controller.signal });
639
688
  if (!response.ok) {
640
689
  const text = await response.text().catch(() => "");
641
- throw new Error(`request_failed:${response.status}:${text.slice(0, 200)}`);
690
+ throw new HttpStatusError(url, response.status, text);
642
691
  }
643
692
  return (await response.json());
644
693
  }
@@ -1168,6 +1217,18 @@ export async function readSelfHostedNodeConfig(env = process.env) {
1168
1217
  const maxConcurrentLlmJobs = parsePositiveInteger(env.MSWARM_SELF_HOSTED_MAX_CONCURRENT_LLM_JOBS, state.max_concurrent_llm_jobs || maxConcurrentJobs);
1169
1218
  return {
1170
1219
  gatewayBaseUrl: trimTrailingSlash(gatewayBaseUrl),
1220
+ jobsPollPath: optionalText(env.MSWARM_SELF_HOSTED_JOBS_POLL_PATH) ||
1221
+ state.jobs_poll_path ||
1222
+ DEFAULT_SELF_HOSTED_JOBS_POLL_PATH,
1223
+ jobsStartPathTemplate: optionalText(env.MSWARM_SELF_HOSTED_JOBS_START_PATH_TEMPLATE) ||
1224
+ state.jobs_start_path_template ||
1225
+ DEFAULT_SELF_HOSTED_JOBS_START_PATH_TEMPLATE,
1226
+ jobsEventsPathTemplate: optionalText(env.MSWARM_SELF_HOSTED_JOBS_EVENTS_PATH_TEMPLATE) ||
1227
+ state.jobs_events_path_template ||
1228
+ DEFAULT_SELF_HOSTED_JOBS_EVENTS_PATH_TEMPLATE,
1229
+ jobsResultPathTemplate: optionalText(env.MSWARM_SELF_HOSTED_JOBS_RESULT_PATH_TEMPLATE) ||
1230
+ state.jobs_result_path_template ||
1231
+ DEFAULT_SELF_HOSTED_JOBS_RESULT_PATH_TEMPLATE,
1171
1232
  nodeId,
1172
1233
  serverName: optionalText(env.MSWARM_SELF_HOSTED_SERVER_NAME) || state.server_name || null,
1173
1234
  relayMode: parseRelayMode(env.MSWARM_SELF_HOSTED_RELAY_MODE || state.relay_mode),
@@ -1608,6 +1669,20 @@ function configText(config, ...keys) {
1608
1669
  }
1609
1670
  return undefined;
1610
1671
  }
1672
+ async function defaultMcodaAgentAuthResolver(agent) {
1673
+ const agentId = optionalText(agent.id);
1674
+ if (!agentId) {
1675
+ return undefined;
1676
+ }
1677
+ const repo = await GlobalRepository.create();
1678
+ try {
1679
+ const secret = await repo.getAgentAuthSecret(agentId);
1680
+ return secret?.encryptedSecret ? CryptoHelper.decryptSecret(secret.encryptedSecret) : undefined;
1681
+ }
1682
+ finally {
1683
+ await repo.close();
1684
+ }
1685
+ }
1611
1686
  function mcodaAgentDefaultModel(agent) {
1612
1687
  return (optionalText(agent.defaultModel) ||
1613
1688
  optionalText(agent.default_model) ||
@@ -1623,18 +1698,35 @@ function resolveCodaliProviderForAgent(agent) {
1623
1698
  if (["ollama-remote", "ollama-cli", "ollama", "local-model"].includes(adapter || "")) {
1624
1699
  return "ollama-remote";
1625
1700
  }
1626
- if (adapter === "openai" || adapter === "openai-compatible" || adapter === "openai-cli") {
1701
+ if (adapter === "openai" ||
1702
+ adapter === "openai-api" ||
1703
+ adapter === "openai-compatible" ||
1704
+ adapter === "openai-cli") {
1627
1705
  return "openai-compatible";
1628
1706
  }
1629
1707
  if (adapter === "codex-cli")
1630
1708
  return "codex-cli";
1631
1709
  return adapter || undefined;
1632
1710
  }
1711
+ function mcodaAgentRequiresApiKey(agent, mapped) {
1712
+ if (mapped.provider !== "openai-compatible") {
1713
+ return false;
1714
+ }
1715
+ if (mapped.localRunner) {
1716
+ return mapped.authMode === "bearer";
1717
+ }
1718
+ const adapter = optionalText(agent.adapter) || "";
1719
+ if (["openai", "openai-api", "openai-compatible"].includes(adapter)) {
1720
+ return mapped.authMode !== "none";
1721
+ }
1722
+ return mapped.authMode === "bearer";
1723
+ }
1633
1724
  function mapMcodaAgentToCodaliAgent(agent, fallbackSlug) {
1634
1725
  const adapter = optionalText(agent.adapter) || "unknown";
1635
1726
  const model = mcodaAgentDefaultModel(agent) || fallbackSlug;
1636
1727
  const config = agent.config ?? null;
1637
1728
  const localRunner = normalizeMcodaLocalRunnerConfig(adapter, config);
1729
+ const configAuthMode = normalizeLocalRunnerAuthMode(config?.authMode);
1638
1730
  const supportsTools = optionalBoolean(agent.supportsTools, agent.supports_tools, localRunner?.supportsTools) === true;
1639
1731
  return {
1640
1732
  slug: optionalText(agent.slug) || fallbackSlug,
@@ -1649,7 +1741,7 @@ function mapMcodaAgentToCodaliAgent(agent, fallbackSlug) {
1649
1741
  : configText(config, "apiKey", "api_key"),
1650
1742
  localRunner,
1651
1743
  runnerKind: localRunner?.runnerKind,
1652
- authMode: localRunner?.authMode,
1744
+ authMode: localRunner?.authMode ?? configAuthMode,
1653
1745
  dummyBearerToken: localRunner?.dummyBearerToken,
1654
1746
  headers: localRunner?.headers,
1655
1747
  extraBody: localRunner?.extraBody,
@@ -1687,10 +1779,12 @@ const DOCDEX_JOB_ERROR_CODES = new Set([
1687
1779
  ]);
1688
1780
  const PRE_START_JOB_ERROR_CODES = new Set([
1689
1781
  "selected_agent_unavailable",
1782
+ "selected_agent_auth_unavailable",
1690
1783
  "selected_agent_unhealthy",
1691
1784
  "validation_failed",
1692
1785
  "docdex_context_missing",
1693
1786
  "docdex_api_key_missing",
1787
+ SELF_HOSTED_PROTOCOL_MISMATCH_CODE,
1694
1788
  ]);
1695
1789
  class SelfHostedDocdexJobError extends Error {
1696
1790
  constructor(code, message) {
@@ -1706,6 +1800,18 @@ class SelfHostedPreStartJobError extends Error {
1706
1800
  this.code = code;
1707
1801
  }
1708
1802
  }
1803
+ class SelfHostedProtocolMismatchError extends Error {
1804
+ constructor(input) {
1805
+ super(`Gateway is missing lifecycle endpoint ${input.endpoint} at ${input.gatewayBaseUrl} ` +
1806
+ `(runtime package version ${input.runtimePackageVersion}). ` +
1807
+ "Run a direct local agent/model instead, for example qwen3.6-llama.cpp.");
1808
+ this.code = SELF_HOSTED_PROTOCOL_MISMATCH_CODE;
1809
+ this.name = SELF_HOSTED_PROTOCOL_MISMATCH_CODE;
1810
+ this.endpoint = input.endpoint;
1811
+ this.gatewayBaseUrl = input.gatewayBaseUrl;
1812
+ this.runtimePackageVersion = input.runtimePackageVersion;
1813
+ }
1814
+ }
1709
1815
  function normalizeDocdexCapabilityMap(value) {
1710
1816
  const record = objectRecord(value);
1711
1817
  if (!record)
@@ -3214,9 +3320,36 @@ export class McodaLocalAgentExecutor {
3214
3320
  export class MswarmSelfHostedNodeClient {
3215
3321
  constructor(input) {
3216
3322
  this.gatewayBaseUrl = trimTrailingSlash(input.gatewayBaseUrl);
3323
+ this.jobsPollPath = lifecyclePath(input.jobsPollPath, DEFAULT_SELF_HOSTED_JOBS_POLL_PATH);
3324
+ this.jobsStartPathTemplate = lifecyclePath(input.jobsStartPathTemplate, DEFAULT_SELF_HOSTED_JOBS_START_PATH_TEMPLATE);
3325
+ this.jobsEventsPathTemplate = lifecyclePath(input.jobsEventsPathTemplate, DEFAULT_SELF_HOSTED_JOBS_EVENTS_PATH_TEMPLATE);
3326
+ this.jobsResultPathTemplate = lifecyclePath(input.jobsResultPathTemplate, DEFAULT_SELF_HOSTED_JOBS_RESULT_PATH_TEMPLATE);
3217
3327
  this.fetchImpl = input.fetchImpl || fetch;
3218
3328
  this.timeoutMs = input.timeoutMs || DEFAULT_REQUEST_TIMEOUT_MS;
3219
3329
  }
3330
+ lifecycleEndpoint(kind) {
3331
+ const path = kind === "poll"
3332
+ ? this.jobsPollPath
3333
+ : kind === "start"
3334
+ ? this.jobsStartPathTemplate
3335
+ : kind === "events"
3336
+ ? this.jobsEventsPathTemplate
3337
+ : this.jobsResultPathTemplate;
3338
+ return `POST ${path}`;
3339
+ }
3340
+ lifecycleGatewayBaseUrl() {
3341
+ return this.gatewayBaseUrl;
3342
+ }
3343
+ lifecycleUrl(kind, jobId) {
3344
+ const path = kind === "poll"
3345
+ ? this.jobsPollPath
3346
+ : kind === "start"
3347
+ ? this.jobsStartPathTemplate
3348
+ : kind === "events"
3349
+ ? this.jobsEventsPathTemplate
3350
+ : this.jobsResultPathTemplate;
3351
+ return resolveLifecycleUrl(this.gatewayBaseUrl, jobId ? resolveLifecycleTemplate(path, jobId) : path);
3352
+ }
3220
3353
  async enroll(nodeId, enrollmentToken) {
3221
3354
  return fetchJson(this.fetchImpl, `${this.gatewayBaseUrl}/v1/swarm/self-hosted/node/enroll`, {
3222
3355
  method: "POST",
@@ -3268,7 +3401,7 @@ export class MswarmSelfHostedNodeClient {
3268
3401
  }, this.timeoutMs);
3269
3402
  }
3270
3403
  async pollJob(runtimeToken, payload) {
3271
- return fetchJson(this.fetchImpl, `${this.gatewayBaseUrl}/v1/swarm/self-hosted/node/jobs/poll`, {
3404
+ return fetchJson(this.fetchImpl, this.lifecycleUrl("poll"), {
3272
3405
  method: "POST",
3273
3406
  headers: {
3274
3407
  "content-type": "application/json",
@@ -3278,7 +3411,7 @@ export class MswarmSelfHostedNodeClient {
3278
3411
  }, Math.max(this.timeoutMs, (payload.wait_ms || 0) + 5000));
3279
3412
  }
3280
3413
  async postJobResult(runtimeToken, jobId, payload) {
3281
- return fetchJson(this.fetchImpl, `${this.gatewayBaseUrl}/v1/swarm/self-hosted/node/jobs/${encodeURIComponent(jobId)}/result`, {
3414
+ return fetchJson(this.fetchImpl, this.lifecycleUrl("result", jobId), {
3282
3415
  method: "POST",
3283
3416
  headers: {
3284
3417
  "content-type": "application/json",
@@ -3288,7 +3421,7 @@ export class MswarmSelfHostedNodeClient {
3288
3421
  }, this.timeoutMs);
3289
3422
  }
3290
3423
  async postJobStart(runtimeToken, jobId, payload) {
3291
- return fetchJson(this.fetchImpl, `${this.gatewayBaseUrl}/v1/swarm/self-hosted/node/jobs/${encodeURIComponent(jobId)}/start`, {
3424
+ return fetchJson(this.fetchImpl, this.lifecycleUrl("start", jobId), {
3292
3425
  method: "POST",
3293
3426
  headers: {
3294
3427
  "content-type": "application/json",
@@ -3298,7 +3431,7 @@ export class MswarmSelfHostedNodeClient {
3298
3431
  }, this.timeoutMs);
3299
3432
  }
3300
3433
  async postJobEvents(runtimeToken, jobId, payload) {
3301
- return fetchJson(this.fetchImpl, `${this.gatewayBaseUrl}/v1/swarm/self-hosted/node/jobs/${encodeURIComponent(jobId)}/events`, {
3434
+ return fetchJson(this.fetchImpl, this.lifecycleUrl("events", jobId), {
3302
3435
  method: "POST",
3303
3436
  headers: {
3304
3437
  "content-type": "application/json",
@@ -3316,11 +3449,16 @@ export class SelfHostedNodeRuntime {
3316
3449
  this.queuedGenericJobs = 0;
3317
3450
  this.latencySamplesMs = [];
3318
3451
  this.recentFailures = [];
3452
+ this.lifecyclePollingDisabled = false;
3319
3453
  this.config = config;
3320
3454
  this.gateway =
3321
3455
  deps?.gateway ||
3322
3456
  new MswarmSelfHostedNodeClient({
3323
3457
  gatewayBaseUrl: config.gatewayBaseUrl,
3458
+ jobsPollPath: config.jobsPollPath,
3459
+ jobsStartPathTemplate: config.jobsStartPathTemplate,
3460
+ jobsEventsPathTemplate: config.jobsEventsPathTemplate,
3461
+ jobsResultPathTemplate: config.jobsResultPathTemplate,
3324
3462
  fetchImpl: deps?.fetchImpl,
3325
3463
  timeoutMs: config.requestTimeoutMs
3326
3464
  });
@@ -3331,6 +3469,7 @@ export class SelfHostedNodeRuntime {
3331
3469
  args: config.mcodaListArgs,
3332
3470
  timeoutMs: config.requestTimeoutMs
3333
3471
  });
3472
+ this.mcodaAgentAuthResolver = deps?.mcodaAgentAuthResolver || defaultMcodaAgentAuthResolver;
3334
3473
  this.mcodaExecutor =
3335
3474
  deps?.mcodaExecutor ||
3336
3475
  new McodaLocalAgentExecutor({
@@ -3395,6 +3534,53 @@ export class SelfHostedNodeRuntime {
3395
3534
  this.recentFailures.splice(MAX_TELEMETRY_FAILURES);
3396
3535
  }
3397
3536
  }
3537
+ lifecycleProtocolMismatch(kind, error) {
3538
+ if (!isHttpStatusError(error, 404)) {
3539
+ return undefined;
3540
+ }
3541
+ return new SelfHostedProtocolMismatchError({
3542
+ endpoint: this.gateway.lifecycleEndpoint(kind),
3543
+ gatewayBaseUrl: this.gateway.lifecycleGatewayBaseUrl(),
3544
+ runtimePackageVersion: this.config.nodeVersion
3545
+ });
3546
+ }
3547
+ isLifecycleProtocolDegradedState(state) {
3548
+ return state?.lifecycle_health_reason === SELF_HOSTED_PROTOCOL_MISMATCH_CODE;
3549
+ }
3550
+ async readLifecycleState() {
3551
+ try {
3552
+ return await readSelfHostedNodeState(this.config.statePath);
3553
+ }
3554
+ catch {
3555
+ return {};
3556
+ }
3557
+ }
3558
+ async markLifecycleProtocolDegraded(error) {
3559
+ this.lifecyclePollingDisabled = true;
3560
+ this.recentFailures.unshift({
3561
+ execution_class: "agentic",
3562
+ code: SELF_HOSTED_PROTOCOL_MISMATCH_CODE,
3563
+ at: new Date().toISOString()
3564
+ });
3565
+ this.recentFailures.splice(MAX_TELEMETRY_FAILURES);
3566
+ const state = await this.readLifecycleState();
3567
+ await writeSelfHostedNodeState(this.config.statePath, {
3568
+ ...state,
3569
+ lifecycle_health_status: "degraded",
3570
+ lifecycle_health_reason: SELF_HOSTED_PROTOCOL_MISMATCH_CODE,
3571
+ lifecycle_health_message: error.message,
3572
+ lifecycle_health_updated_at: new Date().toISOString()
3573
+ });
3574
+ }
3575
+ jobResultPayload(result) {
3576
+ return {
3577
+ ...result,
3578
+ node_id: this.config.nodeId,
3579
+ ...(result.status === "success"
3580
+ ? { usage: result.usage || openAiUsage(result.openai_response) }
3581
+ : {})
3582
+ };
3583
+ }
3398
3584
  averageLatencyMs(fallback = null) {
3399
3585
  if (this.latencySamplesMs.length === 0) {
3400
3586
  return fallback;
@@ -3488,6 +3674,12 @@ export class SelfHostedNodeRuntime {
3488
3674
  throw new Error("Bootstrap response did not include node_id and runtime_token");
3489
3675
  }
3490
3676
  const heartbeatInterval = bootstrap.heartbeat_interval_seconds || setupConfig.heartbeatIntervalSeconds;
3677
+ const relay = bootstrap.relay || {};
3678
+ const relayGatewayBaseUrl = optionalText(relay.gateway_base_url) || setupConfig.gatewayBaseUrl;
3679
+ const jobsPollPath = lifecyclePath(relay.jobs_poll_path, DEFAULT_SELF_HOSTED_JOBS_POLL_PATH);
3680
+ const jobsStartPathTemplate = lifecyclePath(relay.jobs_start_path_template, DEFAULT_SELF_HOSTED_JOBS_START_PATH_TEMPLATE);
3681
+ const jobsEventsPathTemplate = lifecyclePath(relay.jobs_events_path_template, DEFAULT_SELF_HOSTED_JOBS_EVENTS_PATH_TEMPLATE);
3682
+ const jobsResultPathTemplate = lifecyclePath(relay.jobs_result_path_template, DEFAULT_SELF_HOSTED_JOBS_RESULT_PATH_TEMPLATE);
3491
3683
  const state = {
3492
3684
  node_id: nodeId,
3493
3685
  server_name: optionalText(bootstrap.node?.server_name) || setupConfig.serverName,
@@ -3501,7 +3693,15 @@ export class SelfHostedNodeRuntime {
3501
3693
  heartbeat_timeout_seconds: bootstrap.heartbeat_timeout_seconds,
3502
3694
  enrolled_at: new Date().toISOString(),
3503
3695
  updated_at: new Date().toISOString(),
3504
- gateway_base_url: setupConfig.gatewayBaseUrl,
3696
+ gateway_base_url: trimTrailingSlash(relayGatewayBaseUrl),
3697
+ jobs_poll_path: jobsPollPath,
3698
+ jobs_start_path_template: jobsStartPathTemplate,
3699
+ jobs_events_path_template: jobsEventsPathTemplate,
3700
+ jobs_result_path_template: jobsResultPathTemplate,
3701
+ lifecycle_health_status: "healthy",
3702
+ lifecycle_health_reason: undefined,
3703
+ lifecycle_health_message: undefined,
3704
+ lifecycle_health_updated_at: new Date().toISOString(),
3505
3705
  ollama_base_url: setupConfig.ollamaBaseUrl,
3506
3706
  discovery_mode: setupConfig.discoveryMode,
3507
3707
  mcoda_bin: setupConfig.mcodaBin,
@@ -3525,8 +3725,23 @@ export class SelfHostedNodeRuntime {
3525
3725
  };
3526
3726
  await writeSelfHostedNodeState(setupConfig.statePath, state);
3527
3727
  await writeSelfHostedRuntimeToken(setupConfig.runtimeTokenPath, runtimeToken);
3728
+ const runtimeGateway = deps?.gateway && trimTrailingSlash(relayGatewayBaseUrl) === setupConfig.gatewayBaseUrl
3729
+ ? deps.gateway
3730
+ : new MswarmSelfHostedNodeClient({
3731
+ gatewayBaseUrl: trimTrailingSlash(relayGatewayBaseUrl),
3732
+ jobsPollPath,
3733
+ jobsStartPathTemplate,
3734
+ jobsEventsPathTemplate,
3735
+ jobsResultPathTemplate,
3736
+ fetchImpl: deps?.fetchImpl,
3737
+ timeoutMs: setupConfig.requestTimeoutMs
3738
+ });
3528
3739
  const runtime = new SelfHostedNodeRuntime({
3529
- gatewayBaseUrl: setupConfig.gatewayBaseUrl,
3740
+ gatewayBaseUrl: trimTrailingSlash(relayGatewayBaseUrl),
3741
+ jobsPollPath,
3742
+ jobsStartPathTemplate,
3743
+ jobsEventsPathTemplate,
3744
+ jobsResultPathTemplate,
3530
3745
  nodeId,
3531
3746
  serverName: state.server_name,
3532
3747
  relayMode: state.relay_mode || setupConfig.relayMode,
@@ -3560,7 +3775,7 @@ export class SelfHostedNodeRuntime {
3560
3775
  exposeAllModels: setupConfig.exposeAllModels,
3561
3776
  modelAllowlist: setupConfig.modelAllowlist,
3562
3777
  modelBlocklist: setupConfig.modelBlocklist
3563
- }, { ...deps, gateway });
3778
+ }, { ...deps, gateway: runtimeGateway });
3564
3779
  const once = await runtime.runOnce();
3565
3780
  return {
3566
3781
  created: bootstrap.created === true,
@@ -3646,6 +3861,12 @@ export class SelfHostedNodeRuntime {
3646
3861
  if (!runtimeToken) {
3647
3862
  throw new Error("Enrollment response did not include runtime_token");
3648
3863
  }
3864
+ const relay = response.relay || {};
3865
+ const gatewayBaseUrl = trimTrailingSlash(optionalText(relay.gateway_base_url) || this.config.gatewayBaseUrl);
3866
+ const jobsPollPath = lifecyclePath(relay.jobs_poll_path || this.config.jobsPollPath, DEFAULT_SELF_HOSTED_JOBS_POLL_PATH);
3867
+ const jobsStartPathTemplate = lifecyclePath(relay.jobs_start_path_template || this.config.jobsStartPathTemplate, DEFAULT_SELF_HOSTED_JOBS_START_PATH_TEMPLATE);
3868
+ const jobsEventsPathTemplate = lifecyclePath(relay.jobs_events_path_template || this.config.jobsEventsPathTemplate, DEFAULT_SELF_HOSTED_JOBS_EVENTS_PATH_TEMPLATE);
3869
+ const jobsResultPathTemplate = lifecyclePath(relay.jobs_result_path_template || this.config.jobsResultPathTemplate, DEFAULT_SELF_HOSTED_JOBS_RESULT_PATH_TEMPLATE);
3649
3870
  const nextState = {
3650
3871
  ...currentState,
3651
3872
  node_id: this.config.nodeId,
@@ -3655,7 +3876,15 @@ export class SelfHostedNodeRuntime {
3655
3876
  heartbeat_timeout_seconds: response.heartbeat_timeout_seconds,
3656
3877
  enrolled_at: currentState.enrolled_at || new Date().toISOString(),
3657
3878
  updated_at: new Date().toISOString(),
3658
- gateway_base_url: this.config.gatewayBaseUrl,
3879
+ gateway_base_url: gatewayBaseUrl,
3880
+ jobs_poll_path: jobsPollPath,
3881
+ jobs_start_path_template: jobsStartPathTemplate,
3882
+ jobs_events_path_template: jobsEventsPathTemplate,
3883
+ jobs_result_path_template: jobsResultPathTemplate,
3884
+ lifecycle_health_status: "healthy",
3885
+ lifecycle_health_reason: undefined,
3886
+ lifecycle_health_message: undefined,
3887
+ lifecycle_health_updated_at: new Date().toISOString(),
3659
3888
  ollama_base_url: this.config.ollamaBaseUrl,
3660
3889
  discovery_mode: this.config.discoveryMode,
3661
3890
  mcoda_bin: this.config.mcodaBin,
@@ -3708,7 +3937,21 @@ export class SelfHostedNodeRuntime {
3708
3937
  if (mapped.health_status && mapped.health_status !== "healthy" && mapped.health_status !== "unknown") {
3709
3938
  throw new SelfHostedPreStartJobError("selected_agent_unhealthy", `selected local mcoda agent ${selected} is ${mapped.health_status}`);
3710
3939
  }
3711
- return mapMcodaAgentToCodaliAgent(agent, selected);
3940
+ const codaliAgent = mapMcodaAgentToCodaliAgent(agent, selected);
3941
+ if (!codaliAgent.apiKey && mcodaAgentRequiresApiKey(agent, codaliAgent)) {
3942
+ let apiKey;
3943
+ try {
3944
+ apiKey = optionalText(await this.mcodaAgentAuthResolver(agent)) || undefined;
3945
+ }
3946
+ catch {
3947
+ throw new SelfHostedPreStartJobError("selected_agent_auth_unavailable", `selected local mcoda agent ${selected} auth could not be loaded`);
3948
+ }
3949
+ if (!apiKey) {
3950
+ throw new SelfHostedPreStartJobError("selected_agent_auth_unavailable", `selected local mcoda agent ${selected} requires an API key; run "mcoda agent auth set ${selected}" on this node`);
3951
+ }
3952
+ codaliAgent.apiKey = apiKey;
3953
+ }
3954
+ return codaliAgent;
3712
3955
  }
3713
3956
  async executeGenericJob(envelope, options = {}) {
3714
3957
  const startedAt = Date.now();
@@ -3966,6 +4209,8 @@ export class SelfHostedNodeRuntime {
3966
4209
  ]
3967
4210
  });
3968
4211
  }
4212
+ const promptTokens = positiveInteger(ollamaResult.promptTokens);
4213
+ const completionTokens = positiveInteger(ollamaResult.completionTokens);
3969
4214
  const invocationResult = {
3970
4215
  job_id: job.job_id,
3971
4216
  request_id: job.request_id,
@@ -3974,10 +4219,11 @@ export class SelfHostedNodeRuntime {
3974
4219
  requestId: job.request_id,
3975
4220
  model: job.openai_request.model,
3976
4221
  content: ollamaResult.content,
3977
- promptTokens: ollamaResult.promptTokens,
3978
- completionTokens: ollamaResult.completionTokens,
4222
+ promptTokens,
4223
+ completionTokens,
3979
4224
  metadata: { provider: "ollama", raw: ollamaResult.raw }
3980
4225
  }),
4226
+ usage: usageFromTokenCounts(promptTokens, completionTokens),
3981
4227
  ...(streamEvents.length ? { stream_events: streamEvents } : {}),
3982
4228
  ...(progressEvents.length ? { progress_events: progressEvents } : {}),
3983
4229
  timing: { local_latency_ms: Date.now() - startedAt }
@@ -4058,6 +4304,7 @@ export class SelfHostedNodeRuntime {
4058
4304
  mode: response.metadata.mode
4059
4305
  }
4060
4306
  }),
4307
+ usage: usageFromTokenCounts(tokens.promptTokens, tokens.completionTokens),
4061
4308
  ...(streamEvents.length ? { stream_events: streamEvents } : {}),
4062
4309
  ...(progressEvents.length ? { progress_events: progressEvents } : {}),
4063
4310
  timing: { local_latency_ms: Date.now() - startedAt }
@@ -4124,6 +4371,13 @@ export class SelfHostedNodeRuntime {
4124
4371
  models = [];
4125
4372
  version = null;
4126
4373
  }
4374
+ const lifecycleState = await this.readLifecycleState();
4375
+ const lifecycleProtocolDegraded = this.isLifecycleProtocolDegradedState(lifecycleState);
4376
+ if (lifecycleProtocolDegraded) {
4377
+ status = "degraded";
4378
+ recentFailureCount = Math.max(recentFailureCount, 1);
4379
+ this.lifecyclePollingDisabled = true;
4380
+ }
4127
4381
  const discoveryLatencyMs = Date.now() - startedAt;
4128
4382
  const capabilityPayload = await this.buildCapabilityHeartbeatPayload(enrollment.runtimeToken);
4129
4383
  const loadTelemetry = this.buildLoadTelemetry({
@@ -4165,7 +4419,17 @@ export class SelfHostedNodeRuntime {
4165
4419
  relay_mode: this.config.relayMode || "outbound",
4166
4420
  load_reporting_enabled: loadReportingEnabled,
4167
4421
  hardware_telemetry_enabled: this.config.hardwareTelemetryEnabled === true,
4168
- drain_mode: this.config.drainMode === true
4422
+ drain_mode: this.config.drainMode === true,
4423
+ ...(lifecycleProtocolDegraded
4424
+ ? {
4425
+ lifecycle: {
4426
+ status: "degraded",
4427
+ reason: lifecycleState.lifecycle_health_reason,
4428
+ message: lifecycleState.lifecycle_health_message,
4429
+ updated_at: lifecycleState.lifecycle_health_updated_at
4430
+ }
4431
+ }
4432
+ : {})
4169
4433
  },
4170
4434
  discovery: {
4171
4435
  source: discoverySource,
@@ -4185,7 +4449,14 @@ export class SelfHostedNodeRuntime {
4185
4449
  avg_latency_ms: loadTelemetry.avg_latency_ms ?? discoveryLatencyMs,
4186
4450
  recent_failure_count: loadTelemetry.recent_failure_count,
4187
4451
  recent_failures: loadTelemetry.recent_failures,
4188
- last_success_at: status === "online" ? new Date().toISOString() : null
4452
+ last_success_at: status === "online" ? new Date().toISOString() : null,
4453
+ ...(lifecycleProtocolDegraded
4454
+ ? {
4455
+ lifecycle_status: "degraded",
4456
+ lifecycle_reason: lifecycleState.lifecycle_health_reason,
4457
+ lifecycle_message: lifecycleState.lifecycle_health_message
4458
+ }
4459
+ : {})
4189
4460
  },
4190
4461
  local_agent_catalog: {
4191
4462
  revision: loadTelemetry.catalog_fingerprint,
@@ -4239,20 +4510,36 @@ export class SelfHostedNodeRuntime {
4239
4510
  return { count: models.filter((model) => model.exposed !== false).length, response };
4240
4511
  }
4241
4512
  async pollAndExecuteJob(waitMs = DEFAULT_JOB_POLL_WAIT_MS) {
4513
+ const lifecycleState = await this.readLifecycleState();
4514
+ if (this.lifecyclePollingDisabled || this.isLifecycleProtocolDegradedState(lifecycleState)) {
4515
+ this.lifecyclePollingDisabled = true;
4516
+ return { executed: false, status: "failed" };
4517
+ }
4242
4518
  const enrollment = await this.ensureEnrolled();
4243
4519
  const pollCapacity = this.buildLoadTelemetry({ models: [] });
4244
- const response = await this.gateway.pollJob(enrollment.runtimeToken, {
4245
- node_id: this.config.nodeId,
4246
- capacity: {
4247
- active_jobs: pollCapacity.active_jobs,
4248
- queued_jobs: pollCapacity.queued_jobs,
4249
- max_jobs: pollCapacity.max_concurrency,
4250
- max_concurrency: pollCapacity.max_concurrency,
4251
- free_slots: pollCapacity.free_slots,
4252
- drain_mode: pollCapacity.drain_mode
4253
- },
4254
- wait_ms: waitMs
4255
- });
4520
+ let response;
4521
+ try {
4522
+ response = await this.gateway.pollJob(enrollment.runtimeToken, {
4523
+ node_id: this.config.nodeId,
4524
+ capacity: {
4525
+ active_jobs: pollCapacity.active_jobs,
4526
+ queued_jobs: pollCapacity.queued_jobs,
4527
+ max_jobs: pollCapacity.max_concurrency,
4528
+ max_concurrency: pollCapacity.max_concurrency,
4529
+ free_slots: pollCapacity.free_slots,
4530
+ drain_mode: pollCapacity.drain_mode
4531
+ },
4532
+ wait_ms: waitMs
4533
+ });
4534
+ }
4535
+ catch (error) {
4536
+ const mismatch = this.lifecycleProtocolMismatch("poll", error);
4537
+ if (mismatch) {
4538
+ await this.markLifecycleProtocolDegraded(mismatch);
4539
+ return { executed: false, status: "failed" };
4540
+ }
4541
+ throw error;
4542
+ }
4256
4543
  const job = response.job || null;
4257
4544
  if (!job) {
4258
4545
  return { executed: false };
@@ -4272,19 +4559,35 @@ export class SelfHostedNodeRuntime {
4272
4559
  });
4273
4560
  forwardedStreamEventCount += stream_events.length;
4274
4561
  }
4275
- catch {
4562
+ catch (error) {
4563
+ const mismatch = this.lifecycleProtocolMismatch("events", error);
4564
+ if (mismatch) {
4565
+ streamEventForwardingFailed = true;
4566
+ await this.markLifecycleProtocolDegraded(mismatch);
4567
+ throw mismatch;
4568
+ }
4276
4569
  streamEventForwardingFailed = true;
4277
4570
  }
4278
4571
  };
4279
- const result = await this.executeJob(job, {
4572
+ let result = await this.executeJob(job, {
4280
4573
  attachedMswarmApiKey: optionalText(response.attached_mswarm_api_key) || undefined,
4281
4574
  onStarted: async (event) => {
4282
- await this.gateway.postJobStart(enrollment.runtimeToken, job.job_id, {
4283
- node_id: this.config.nodeId,
4284
- agent_slug: event.agent_slug || job.agent_slug,
4285
- source_agent_slug: event.source_agent_slug || job.source_agent_slug || null,
4286
- model: event.model || job.model || job.openai_request.model
4287
- });
4575
+ try {
4576
+ await this.gateway.postJobStart(enrollment.runtimeToken, job.job_id, {
4577
+ node_id: this.config.nodeId,
4578
+ agent_slug: event.agent_slug || job.agent_slug,
4579
+ source_agent_slug: event.source_agent_slug || job.source_agent_slug || null,
4580
+ model: event.model || job.model || job.openai_request.model
4581
+ });
4582
+ }
4583
+ catch (error) {
4584
+ const mismatch = this.lifecycleProtocolMismatch("start", error);
4585
+ if (mismatch) {
4586
+ await this.markLifecycleProtocolDegraded(mismatch);
4587
+ throw mismatch;
4588
+ }
4589
+ throw error;
4590
+ }
4288
4591
  },
4289
4592
  onOpenAIChunk: async (chunk) => {
4290
4593
  if (job.openai_request.stream !== true || streamEventForwardingFailed) {
@@ -4296,7 +4599,23 @@ export class SelfHostedNodeRuntime {
4296
4599
  }
4297
4600
  },
4298
4601
  });
4299
- await flushStreamEvents();
4602
+ try {
4603
+ await flushStreamEvents();
4604
+ }
4605
+ catch (error) {
4606
+ if (!(error instanceof SelfHostedProtocolMismatchError)) {
4607
+ throw error;
4608
+ }
4609
+ result = {
4610
+ job_id: job.job_id,
4611
+ request_id: job.request_id,
4612
+ status: "failed",
4613
+ error: { code: SELF_HOSTED_PROTOCOL_MISMATCH_CODE, message: error.message },
4614
+ ...(result.stream_events?.length ? { stream_events: result.stream_events } : {}),
4615
+ ...(result.progress_events?.length ? { progress_events: result.progress_events } : {}),
4616
+ timing: result.timing
4617
+ };
4618
+ }
4300
4619
  const postedResult = job.openai_request.stream === true
4301
4620
  ? streamEventForwardingFailed
4302
4621
  ? {
@@ -4305,10 +4624,17 @@ export class SelfHostedNodeRuntime {
4305
4624
  }
4306
4625
  : (({ stream_events: _streamEvents, ...rest }) => rest)(result)
4307
4626
  : result;
4308
- await this.gateway.postJobResult(enrollment.runtimeToken, job.job_id, {
4309
- ...postedResult,
4310
- node_id: this.config.nodeId
4311
- });
4627
+ try {
4628
+ await this.gateway.postJobResult(enrollment.runtimeToken, job.job_id, this.jobResultPayload(postedResult));
4629
+ }
4630
+ catch (error) {
4631
+ const mismatch = this.lifecycleProtocolMismatch("result", error);
4632
+ if (mismatch) {
4633
+ await this.markLifecycleProtocolDegraded(mismatch);
4634
+ return { executed: true, job_id: job.job_id, status: "failed" };
4635
+ }
4636
+ throw error;
4637
+ }
4312
4638
  return { executed: true, job_id: job.job_id, status: result.status };
4313
4639
  }
4314
4640
  async doctor() {
@@ -4350,7 +4676,7 @@ export class SelfHostedNodeRuntime {
4350
4676
  let timer = null;
4351
4677
  let polling = false;
4352
4678
  const poll = () => {
4353
- if (stopped || polling || this.config.relayMode === "direct")
4679
+ if (stopped || polling || this.lifecyclePollingDisabled || this.config.relayMode === "direct")
4354
4680
  return;
4355
4681
  polling = true;
4356
4682
  void this.pollAndExecuteJob()