@alfe.ai/gateway 0.7.3 → 0.8.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/bin/gateway.js +1 -1
- package/dist/health.js +521 -462
- package/dist/runtime-upgrade.js +1 -1
- package/dist/sentry.js +386 -0
- package/dist/src/index.d.ts +0 -6
- package/dist/src/index.js +2 -1
- package/dist/upgrade.js +154 -5
- package/package.json +4 -4
package/dist/health.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { n as logger$1 } from "./logger.js";
|
|
2
|
+
import { a as captureFatal, c as captureRuntimeCrash, d as initAgentSentry, f as setAgentContext, l as captureRuntimeErrorOutput, o as captureIntegrationFailure, s as captureMcpFailure, u as flushSentry } from "./sentry.js";
|
|
2
3
|
import { createRequire } from "node:module";
|
|
3
4
|
import { mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
5
|
import { execFile, execSync, spawn } from "node:child_process";
|
|
@@ -7,12 +8,12 @@ import { dirname, join } from "node:path";
|
|
|
7
8
|
import { homedir } from "node:os";
|
|
8
9
|
import pino from "pino";
|
|
9
10
|
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
10
|
-
import { getEndpointFromToken, readConfig
|
|
11
|
+
import { getEndpointFromToken, readConfig } from "@alfe.ai/config";
|
|
11
12
|
import crypto from "crypto";
|
|
12
13
|
import { parse } from "smol-toml";
|
|
13
14
|
import WebSocket from "ws";
|
|
14
15
|
import { createConnection, createServer } from "node:net";
|
|
15
|
-
import { HermesApplier, HermesMcpSync, IntegrationManager, IntegrationManagerAdapter, McpApplier, NoopOpenClawCliLock, OpenClawApplier, SerialOpenClawCliLock } from "@alfe.ai/integrations";
|
|
16
|
+
import { ClaudeCodeApplier, ClaudeCodeMcpSync, HermesApplier, HermesMcpSync, IntegrationManager, IntegrationManagerAdapter, McpApplier, NoopOpenClawCliLock, OpenClawApplier, SerialOpenClawCliLock } from "@alfe.ai/integrations";
|
|
16
17
|
import { AgentApiClient } from "@alfe.ai/agent-api-client";
|
|
17
18
|
import { Manager, McpBundler, defaultConnect } from "@alfe.ai/mcp-bundler";
|
|
18
19
|
import stream, { Readable } from "stream";
|
|
@@ -551,6 +552,29 @@ var AuthService = class {
|
|
|
551
552
|
body: JSON.stringify(input)
|
|
552
553
|
});
|
|
553
554
|
}
|
|
555
|
+
/**
|
|
556
|
+
* Change a personal (individual-tier) subscriber's platform plan in place on
|
|
557
|
+
* their existing Stripe subscription — no second Checkout, no duplicate sub,
|
|
558
|
+
* no double charge. Upgrades prorate immediately; downgrades apply with no
|
|
559
|
+
* immediate charge/refund. Returns the updated subscription.
|
|
560
|
+
*/
|
|
561
|
+
changePlatformPlan(input) {
|
|
562
|
+
return this.client.request(`${this.prefix}/subscription/change`, {
|
|
563
|
+
method: "POST",
|
|
564
|
+
body: JSON.stringify(input)
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Preview the proration cost for changing a personal (individual-tier)
|
|
569
|
+
* subscriber's platform plan — read-only companion to `changePlatformPlan`.
|
|
570
|
+
* Nothing is charged.
|
|
571
|
+
*/
|
|
572
|
+
previewPlatformPlanChange(input) {
|
|
573
|
+
return this.client.request(`${this.prefix}/subscription/change/preview`, {
|
|
574
|
+
method: "POST",
|
|
575
|
+
body: JSON.stringify(input)
|
|
576
|
+
});
|
|
577
|
+
}
|
|
554
578
|
updateSeats(seats) {
|
|
555
579
|
return this.client.request(`${this.prefix}/subscription/seats`, {
|
|
556
580
|
method: "POST",
|
|
@@ -687,6 +711,15 @@ var IntegrationsService = class {
|
|
|
687
711
|
reinstallIntegration(agentId, integrationId) {
|
|
688
712
|
return this.client.request(`/integrations/agents/${agentId}/${integrationId}/reinstall`, { method: "POST" });
|
|
689
713
|
}
|
|
714
|
+
/**
|
|
715
|
+
* Upgrade an integration to the registry's latest version via the fast,
|
|
716
|
+
* diff-based daemon path (bumps `version` without `reinstallRequestedAt`).
|
|
717
|
+
* The agent stays online — no destructive teardown. Distinct from
|
|
718
|
+
* {@link reinstallIntegration}, which is the destructive repair path.
|
|
719
|
+
*/
|
|
720
|
+
upgradeIntegration(agentId, integrationId) {
|
|
721
|
+
return this.client.request(`/integrations/agents/${agentId}/${integrationId}/upgrade`, { method: "POST" });
|
|
722
|
+
}
|
|
690
723
|
getRegistry() {
|
|
691
724
|
return this.client.request("/integrations/registry");
|
|
692
725
|
}
|
|
@@ -4487,6 +4520,7 @@ enumValues({
|
|
|
4487
4520
|
IndividualPro: "individual_pro",
|
|
4488
4521
|
IndividualMax: "individual_max",
|
|
4489
4522
|
OrgFree: "org_free",
|
|
4523
|
+
OrgStartup: "org_startup",
|
|
4490
4524
|
OrgProfessional: "org_professional",
|
|
4491
4525
|
OrgEnterprise: "org_enterprise"
|
|
4492
4526
|
});
|
|
@@ -4523,7 +4557,8 @@ enumValues({
|
|
|
4523
4557
|
enumValues({
|
|
4524
4558
|
OpenClaw: "openclaw",
|
|
4525
4559
|
NanoClaw: "nanoclaw",
|
|
4526
|
-
Hermes: "hermes"
|
|
4560
|
+
Hermes: "hermes",
|
|
4561
|
+
ClaudeCode: "claude-code"
|
|
4527
4562
|
});
|
|
4528
4563
|
const TTS_MODELS = enumValues({
|
|
4529
4564
|
ElevenLabsTurbo: "elevenlabs-turbo",
|
|
@@ -4664,9 +4699,10 @@ enumValues({
|
|
|
4664
4699
|
const AnthropicModel = {
|
|
4665
4700
|
Opus48: "claude-opus-4-8",
|
|
4666
4701
|
Opus47: "claude-opus-4-7",
|
|
4667
|
-
|
|
4668
|
-
|
|
4669
|
-
|
|
4702
|
+
Opus46: "claude-opus-4-6",
|
|
4703
|
+
Sonnet5: "claude-sonnet-5",
|
|
4704
|
+
Sonnet46: "claude-sonnet-4-6",
|
|
4705
|
+
Haiku45: "claude-haiku-4-5"
|
|
4670
4706
|
};
|
|
4671
4707
|
const ANTHROPIC_MODELS = enumValues(AnthropicModel);
|
|
4672
4708
|
const OpenAIModel = {
|
|
@@ -4681,9 +4717,14 @@ const OpenAIModel = {
|
|
|
4681
4717
|
GPT54Nano: "gpt-5.4-nano",
|
|
4682
4718
|
GPT54Pro: "gpt-5.4-pro",
|
|
4683
4719
|
GPT55: "gpt-5.5",
|
|
4720
|
+
GPT55Pro: "gpt-5.5-pro",
|
|
4721
|
+
GPT56Sol: "gpt-5.6-sol",
|
|
4722
|
+
GPT56Terra: "gpt-5.6-terra",
|
|
4723
|
+
GPT56Luna: "gpt-5.6-luna",
|
|
4684
4724
|
O3Mini: "o3-mini",
|
|
4685
4725
|
O4Mini: "o4-mini",
|
|
4686
|
-
TextEmbedding3Small: "text-embedding-3-small"
|
|
4726
|
+
TextEmbedding3Small: "text-embedding-3-small",
|
|
4727
|
+
TextEmbedding3Large: "text-embedding-3-large"
|
|
4687
4728
|
};
|
|
4688
4729
|
const OPENAI_MODELS = enumValues(OpenAIModel);
|
|
4689
4730
|
const DeepSeekModel = {
|
|
@@ -4707,16 +4748,25 @@ const MiniMaxModel = {
|
|
|
4707
4748
|
M3: "MiniMax-M3",
|
|
4708
4749
|
M27: "MiniMax-M2.7",
|
|
4709
4750
|
M27HighSpeed: "MiniMax-M2.7-highspeed",
|
|
4710
|
-
M25: "MiniMax-M2.5"
|
|
4751
|
+
M25: "MiniMax-M2.5",
|
|
4752
|
+
M21: "MiniMax-M2.1",
|
|
4753
|
+
M2: "MiniMax-M2"
|
|
4711
4754
|
};
|
|
4712
4755
|
const MINIMAX_MODELS = enumValues(MiniMaxModel);
|
|
4713
4756
|
const MistralModel = {
|
|
4714
4757
|
Large: "mistral-large-latest",
|
|
4758
|
+
Medium: "mistral-medium-latest",
|
|
4715
4759
|
Small: "mistral-small-latest",
|
|
4716
|
-
Codestral: "codestral-latest"
|
|
4760
|
+
Codestral: "codestral-latest",
|
|
4761
|
+
Ministral8b: "ministral-8b-latest",
|
|
4762
|
+
Ministral3b: "ministral-3b-latest",
|
|
4763
|
+
MagistralMedium: "magistral-medium-latest",
|
|
4764
|
+
MagistralSmall: "magistral-small-latest",
|
|
4765
|
+
DevstralMedium: "devstral-medium-latest"
|
|
4717
4766
|
};
|
|
4718
4767
|
const MISTRAL_MODELS = enumValues(MistralModel);
|
|
4719
4768
|
const XAIModel = {
|
|
4769
|
+
Grok45: "grok-4.5",
|
|
4720
4770
|
Grok43: "grok-4.3",
|
|
4721
4771
|
Grok4: "grok-4",
|
|
4722
4772
|
Grok41Fast: "grok-4.1-fast"
|
|
@@ -4725,10 +4775,12 @@ const XAI_MODELS = enumValues(XAIModel);
|
|
|
4725
4775
|
const ZhipuModel = {
|
|
4726
4776
|
GLM52: "glm-5.2",
|
|
4727
4777
|
GLM51: "glm-5.1",
|
|
4728
|
-
|
|
4778
|
+
GLM46: "glm-4.6",
|
|
4779
|
+
GLM45: "glm-4.5",
|
|
4780
|
+
GLM45Air: "glm-4.5-air"
|
|
4729
4781
|
};
|
|
4730
4782
|
const ZHIPU_MODELS = enumValues(ZhipuModel);
|
|
4731
|
-
AnthropicModel.
|
|
4783
|
+
AnthropicModel.Sonnet5;
|
|
4732
4784
|
[
|
|
4733
4785
|
...ANTHROPIC_MODELS,
|
|
4734
4786
|
...OPENAI_MODELS,
|
|
@@ -4748,8 +4800,8 @@ _enum(MISTRAL_MODELS);
|
|
|
4748
4800
|
_enum(XAI_MODELS);
|
|
4749
4801
|
_enum(ZHIPU_MODELS);
|
|
4750
4802
|
string().min(1);
|
|
4751
|
-
AnthropicModel.Opus48, AnthropicModel.Opus47, AnthropicModel.
|
|
4752
|
-
AnthropicModel.Opus48, AnthropicModel.Opus47, AnthropicModel.
|
|
4803
|
+
AnthropicModel.Opus48, AnthropicModel.Opus47, AnthropicModel.Opus46, AnthropicModel.Sonnet5, AnthropicModel.Sonnet46, AnthropicModel.Haiku45, OpenAIModel.GPT4o, OpenAIModel.GPT4oMini, OpenAIModel.O3, OpenAIModel.GPT41, OpenAIModel.GPT41Mini, OpenAIModel.GPT41Nano, OpenAIModel.GPT54, OpenAIModel.GPT54Mini, OpenAIModel.GPT54Nano, OpenAIModel.GPT54Pro, OpenAIModel.GPT55, OpenAIModel.GPT55Pro, OpenAIModel.GPT56Sol, OpenAIModel.GPT56Terra, OpenAIModel.GPT56Luna, OpenAIModel.O3Mini, OpenAIModel.O4Mini, OpenAIModel.TextEmbedding3Small, OpenAIModel.TextEmbedding3Large, DeepSeekModel.Chat, DeepSeekModel.Reasoner, DeepSeekModel.V4Flash, DeepSeekModel.V4Pro, GoogleModel.Gemini35Flash, GoogleModel.Gemini31Pro, GoogleModel.Gemini31FlashLite, GoogleModel.Gemini25Pro, GoogleModel.Gemini25Flash, GoogleModel.Gemini25FlashLite, GoogleModel.Gemini20Flash, MiniMaxModel.M3, MiniMaxModel.M27, MiniMaxModel.M27HighSpeed, MiniMaxModel.M25, MiniMaxModel.M21, MiniMaxModel.M2, MistralModel.Large, MistralModel.Medium, MistralModel.Small, MistralModel.Codestral, MistralModel.Ministral8b, MistralModel.Ministral3b, MistralModel.MagistralMedium, MistralModel.MagistralSmall, MistralModel.DevstralMedium, XAIModel.Grok45, XAIModel.Grok43, XAIModel.Grok4, XAIModel.Grok41Fast, ZhipuModel.GLM52, ZhipuModel.GLM51, ZhipuModel.GLM46, ZhipuModel.GLM45, ZhipuModel.GLM45Air;
|
|
4804
|
+
AnthropicModel.Opus48, AnthropicModel.Opus47, AnthropicModel.Opus46, AnthropicModel.Sonnet5, AnthropicModel.Sonnet46, AnthropicModel.Haiku45, OpenAIModel.GPT4o, OpenAIModel.GPT4oMini, OpenAIModel.O3, OpenAIModel.GPT41, OpenAIModel.GPT41Mini, OpenAIModel.GPT41Nano, OpenAIModel.GPT54, OpenAIModel.GPT54Mini, OpenAIModel.GPT54Nano, OpenAIModel.GPT54Pro, OpenAIModel.GPT55, OpenAIModel.GPT55Pro, OpenAIModel.GPT56Sol, OpenAIModel.GPT56Terra, OpenAIModel.GPT56Luna, OpenAIModel.O3Mini, OpenAIModel.O4Mini, OpenAIModel.TextEmbedding3Small, OpenAIModel.TextEmbedding3Large, DeepSeekModel.Chat, DeepSeekModel.Reasoner, DeepSeekModel.V4Flash, DeepSeekModel.V4Pro, GoogleModel.Gemini35Flash, GoogleModel.Gemini31Pro, GoogleModel.Gemini31FlashLite, GoogleModel.Gemini25Pro, GoogleModel.Gemini25Flash, GoogleModel.Gemini25FlashLite, GoogleModel.Gemini20Flash, MiniMaxModel.M3, MiniMaxModel.M27, MiniMaxModel.M27HighSpeed, MiniMaxModel.M25, MiniMaxModel.M21, MiniMaxModel.M2, MistralModel.Large, MistralModel.Medium, MistralModel.Small, MistralModel.Codestral, MistralModel.Ministral8b, MistralModel.Ministral3b, MistralModel.MagistralMedium, MistralModel.MagistralSmall, MistralModel.DevstralMedium, XAIModel.Grok45, XAIModel.Grok43, XAIModel.Grok4, XAIModel.Grok41Fast, ZhipuModel.GLM52, ZhipuModel.GLM51, ZhipuModel.GLM46, ZhipuModel.GLM45, ZhipuModel.GLM45Air;
|
|
4753
4805
|
enumValues({
|
|
4754
4806
|
PendingChallenge: "pending_challenge",
|
|
4755
4807
|
Creating: "creating",
|
|
@@ -4825,6 +4877,7 @@ const NotificationType = {
|
|
|
4825
4877
|
AutoRechargeFailed: "auto-recharge.failed",
|
|
4826
4878
|
SubscriptionCreated: "subscription.created",
|
|
4827
4879
|
SubscriptionCancelled: "subscription.cancelled",
|
|
4880
|
+
StartupGrantEnded: "startup.grant_ended",
|
|
4828
4881
|
SubscriptionPastDue: "subscription.past_due",
|
|
4829
4882
|
BalanceThresholdWarning: "balance.threshold.warning",
|
|
4830
4883
|
PlatformTierPriceIncrease: "platform.tier_price_increase",
|
|
@@ -4849,7 +4902,7 @@ const RecipientStrategy = {
|
|
|
4849
4902
|
TenantAdmins: "tenant_admins",
|
|
4850
4903
|
SpecificUser: "specific_user"
|
|
4851
4904
|
};
|
|
4852
|
-
NotificationType.PaymentSucceeded, NotificationCategory.Billing, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationChannel.Push, NotificationType.PaymentFailed, NotificationCategory.Billing, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationChannel.Push, NotificationChannel.Sms, NotificationType.TopUpCompleted, NotificationCategory.Billing, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationChannel.Push, NotificationType.AutoRechargeCompleted, NotificationCategory.Billing, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationType.AutoRechargeFailed, NotificationCategory.Billing, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationChannel.Push, NotificationChannel.Sms, NotificationType.SubscriptionCreated, NotificationCategory.Billing, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationType.SubscriptionCancelled, NotificationCategory.Billing, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationChannel.Push, NotificationType.SubscriptionPastDue, NotificationCategory.Billing, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationChannel.Push, NotificationChannel.Sms, NotificationType.BalanceThresholdWarning, NotificationCategory.Billing, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationChannel.Push, NotificationType.PlatformTierPriceIncrease, NotificationCategory.Billing, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationType.AgentCreated, NotificationCategory.Agents, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationChannel.Push, NotificationType.AgentProvisionFailed, NotificationCategory.Agents, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationChannel.Push, NotificationType.AgentBillingSuspended, NotificationCategory.Agents, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationChannel.Push, NotificationType.AgentDisconnectedExtended, NotificationCategory.Agents, RecipientStrategy.TenantAdmins, NotificationChannel.Push, NotificationType.BrowserTakeoverRequested, NotificationCategory.Agents, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationChannel.Push, NotificationType.InviteCreated, NotificationCategory.System, RecipientStrategy.SpecificUser, NotificationChannel.Email, NotificationType.OrgClaimed, NotificationCategory.System, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationChannel.Push, NotificationType.TeamMemberAdded, NotificationCategory.Team, RecipientStrategy.SpecificUser, NotificationChannel.Email, NotificationChannel.Push, NotificationType.ProjectMemberAdded, NotificationCategory.Team, RecipientStrategy.SpecificUser, NotificationChannel.Email, NotificationChannel.Push, NotificationType.IntegrationInstalled, NotificationCategory.Integrations, RecipientStrategy.TenantAdmins, NotificationChannel.Push, NotificationType.IntegrationRemoved, NotificationCategory.Integrations, RecipientStrategy.TenantAdmins, NotificationChannel.Push;
|
|
4905
|
+
NotificationType.PaymentSucceeded, NotificationCategory.Billing, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationChannel.Push, NotificationType.PaymentFailed, NotificationCategory.Billing, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationChannel.Push, NotificationChannel.Sms, NotificationType.TopUpCompleted, NotificationCategory.Billing, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationChannel.Push, NotificationType.AutoRechargeCompleted, NotificationCategory.Billing, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationType.AutoRechargeFailed, NotificationCategory.Billing, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationChannel.Push, NotificationChannel.Sms, NotificationType.SubscriptionCreated, NotificationCategory.Billing, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationType.SubscriptionCancelled, NotificationCategory.Billing, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationChannel.Push, NotificationType.StartupGrantEnded, NotificationCategory.Billing, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationChannel.Push, NotificationType.SubscriptionPastDue, NotificationCategory.Billing, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationChannel.Push, NotificationChannel.Sms, NotificationType.BalanceThresholdWarning, NotificationCategory.Billing, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationChannel.Push, NotificationType.PlatformTierPriceIncrease, NotificationCategory.Billing, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationType.AgentCreated, NotificationCategory.Agents, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationChannel.Push, NotificationType.AgentProvisionFailed, NotificationCategory.Agents, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationChannel.Push, NotificationType.AgentBillingSuspended, NotificationCategory.Agents, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationChannel.Push, NotificationType.AgentDisconnectedExtended, NotificationCategory.Agents, RecipientStrategy.TenantAdmins, NotificationChannel.Push, NotificationType.BrowserTakeoverRequested, NotificationCategory.Agents, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationChannel.Push, NotificationType.InviteCreated, NotificationCategory.System, RecipientStrategy.SpecificUser, NotificationChannel.Email, NotificationType.OrgClaimed, NotificationCategory.System, RecipientStrategy.TenantAdmins, NotificationChannel.Email, NotificationChannel.Push, NotificationType.TeamMemberAdded, NotificationCategory.Team, RecipientStrategy.SpecificUser, NotificationChannel.Email, NotificationChannel.Push, NotificationType.ProjectMemberAdded, NotificationCategory.Team, RecipientStrategy.SpecificUser, NotificationChannel.Email, NotificationChannel.Push, NotificationType.IntegrationInstalled, NotificationCategory.Integrations, RecipientStrategy.TenantAdmins, NotificationChannel.Push, NotificationType.IntegrationRemoved, NotificationCategory.Integrations, RecipientStrategy.TenantAdmins, NotificationChannel.Push;
|
|
4853
4906
|
//#endregion
|
|
4854
4907
|
//#region src/config.ts
|
|
4855
4908
|
/**
|
|
@@ -4965,6 +5018,7 @@ function deriveGatewayWsUrl(apiEndpoint) {
|
|
|
4965
5018
|
*/
|
|
4966
5019
|
function deriveAgentWorkspace(runtime, home) {
|
|
4967
5020
|
if (runtime === "openclaw") return join(home, "workspace");
|
|
5021
|
+
if (runtime === "claude-code") return join(home, "workspace");
|
|
4968
5022
|
return home;
|
|
4969
5023
|
}
|
|
4970
5024
|
async function loadRuntimeConfigs() {
|
|
@@ -5313,7 +5367,7 @@ function isIPCResponse(msg) {
|
|
|
5313
5367
|
const PROTOCOL_VERSION = 1;
|
|
5314
5368
|
//#endregion
|
|
5315
5369
|
//#region src/runtime-gate.ts
|
|
5316
|
-
const log$
|
|
5370
|
+
const log$6 = logger$1.child({ component: "RuntimeGate" });
|
|
5317
5371
|
var RuntimeGate = class {
|
|
5318
5372
|
depth = 0;
|
|
5319
5373
|
wasRunning = false;
|
|
@@ -5331,8 +5385,8 @@ var RuntimeGate = class {
|
|
|
5331
5385
|
const rp = this.getRuntime();
|
|
5332
5386
|
this.wasRunning = rp?.isRunning ?? false;
|
|
5333
5387
|
if (rp && this.wasRunning) {
|
|
5334
|
-
log$
|
|
5335
|
-
await rp.
|
|
5388
|
+
log$6.info("Suspending runtime for mutating reconcile (avoid concurrent SQLite writers)");
|
|
5389
|
+
await rp.stopWhenIdle();
|
|
5336
5390
|
}
|
|
5337
5391
|
}
|
|
5338
5392
|
/**
|
|
@@ -5346,7 +5400,7 @@ var RuntimeGate = class {
|
|
|
5346
5400
|
if (this.depth < 0) this.depth = 0;
|
|
5347
5401
|
const rp = this.getRuntime();
|
|
5348
5402
|
if (rp && this.wasRunning) {
|
|
5349
|
-
log$
|
|
5403
|
+
log$6.info("Resuming runtime after mutating reconcile");
|
|
5350
5404
|
rp.resume();
|
|
5351
5405
|
}
|
|
5352
5406
|
this.wasRunning = false;
|
|
@@ -5361,12 +5415,12 @@ var RuntimeGate = class {
|
|
|
5361
5415
|
*/
|
|
5362
5416
|
requestRestart() {
|
|
5363
5417
|
if (this.depth > 0) {
|
|
5364
|
-
log$
|
|
5418
|
+
log$6.info("Runtime restart requested while suspended — deferring to pending resume");
|
|
5365
5419
|
return;
|
|
5366
5420
|
}
|
|
5367
5421
|
const rp = this.getRuntime();
|
|
5368
5422
|
if (rp) rp.restart().catch((err) => {
|
|
5369
|
-
log$
|
|
5423
|
+
log$6.error({ err: err instanceof Error ? err.message : String(err) }, "Failed to restart runtime");
|
|
5370
5424
|
});
|
|
5371
5425
|
}
|
|
5372
5426
|
};
|
|
@@ -5385,392 +5439,8 @@ var NoopRuntimeGate = class {
|
|
|
5385
5439
|
requestRestart() {}
|
|
5386
5440
|
};
|
|
5387
5441
|
//#endregion
|
|
5388
|
-
//#region src/sentry.ts
|
|
5389
|
-
/**
|
|
5390
|
-
* Agent-side error reporting (Sentry) for the Alfe CLI + gateway daemon.
|
|
5391
|
-
*
|
|
5392
|
-
* This module is the single Sentry bootstrap shared by both published
|
|
5393
|
-
* agent-side packages — `@alfe.ai/gateway` (the daemon) and `@alfe.ai/cli`
|
|
5394
|
-
* (which re-imports these helpers from `@alfe.ai/gateway`). It exists so
|
|
5395
|
-
* integration-installation failures on customer/managed VMs surface centrally
|
|
5396
|
-
* instead of silently rotting until someone SSHes into the box.
|
|
5397
|
-
*
|
|
5398
|
-
* Design constraints (see packages/gateway/DEVELOPING.md → "Error reporting"):
|
|
5399
|
-
* - Errors only. No tracing/profiling (`tracesSampleRate: 0`, `sampleRate: 1`).
|
|
5400
|
-
* - `@sentry/node` is lazy-imported so `alfe --version` doesn't pay for it.
|
|
5401
|
-
* - Sentry can NEVER break the CLI/daemon — every entry point is try/catch'd.
|
|
5402
|
-
* - The DSNs are baked as constants: published tarballs run on agent VMs and
|
|
5403
|
-
* cannot read the repo's `config/config.ts`. That file's `SENTRY_DSNS`
|
|
5404
|
-
* registry (keys `agentDaemon` + `cli`) mirrors them as the human reference.
|
|
5405
|
-
* - Secrets are scrubbed in `beforeSend`; request/env payloads are dropped.
|
|
5406
|
-
* - Opt-out via `ALFE_ERROR_REPORTING=0|false` or `errorReporting = false`
|
|
5407
|
-
* in `~/.alfe/config.toml`. Default ON.
|
|
5408
|
-
*/
|
|
5409
|
-
/**
|
|
5410
|
-
* Baked per-surface Sentry DSNs (org `alfe-ai`). DSNs are public by design
|
|
5411
|
-
* (they only permit event ingestion). Mirrors of `config/config.ts` →
|
|
5412
|
-
* `SENTRY_DSNS.agentDaemon` / `SENTRY_DSNS.cli`. If you rotate a DSN, update
|
|
5413
|
-
* both places.
|
|
5414
|
-
*/
|
|
5415
|
-
const AGENT_DAEMON_SENTRY_DSN = "https://a47f010d8d39fb4350f913492189f283@o4511008239452160.ingest.us.sentry.io/4511679448547328";
|
|
5416
|
-
const CLI_SENTRY_DSN = "https://82dde4631336561f2fcc89d7531623de@o4511008239452160.ingest.us.sentry.io/4511679411191808";
|
|
5417
|
-
/**
|
|
5418
|
-
* Dedicated project for errors emitted BY the agent runtime child process
|
|
5419
|
-
* (OpenClaw/Hermes) — crashes, spawn failures, and error-looking output. Kept
|
|
5420
|
-
* separate from `agent-daemon` so runtime noise never drowns daemon issues.
|
|
5421
|
-
* Mirrors `SENTRY_DSNS.agentRuntime` in `config/config.ts`.
|
|
5422
|
-
*/
|
|
5423
|
-
const AGENT_RUNTIME_SENTRY_DSN = "https://feb7fb2e3b8e723aec518e74715bfa6d@o4511008239452160.ingest.us.sentry.io/4511683667558400";
|
|
5424
|
-
/**
|
|
5425
|
-
* MCP tool/server failures on agent VMs — reported by the daemon-hosted MCP
|
|
5426
|
-
* bundler (tool errors, child crashes, stderr output). Dedicated `local-mcp`
|
|
5427
|
-
* project for the locally-installed MCP surface: the cloud MCP Fly service
|
|
5428
|
-
* keeps the `mcp` project (`SENTRY_DSNS.mcp`), so planned daemon restarts
|
|
5429
|
-
* tearing down MCP children never pollute the remote service's signal.
|
|
5430
|
-
* The `source: agent-daemon` + `agentId` tags are kept for continuity.
|
|
5431
|
-
*/
|
|
5432
|
-
const AGENT_MCP_SENTRY_DSN = "https://b4c4b4e19135692206bacabcd12a7fd3@o4511008239452160.ingest.us.sentry.io/4511697094377472";
|
|
5433
|
-
/** Surface → Sentry project: `cli` and `agent-daemon` respectively. */
|
|
5434
|
-
const SURFACE_DSNS = {
|
|
5435
|
-
cli: CLI_SENTRY_DSN,
|
|
5436
|
-
daemon: AGENT_DAEMON_SENTRY_DSN
|
|
5437
|
-
};
|
|
5438
|
-
/** Held after a successful init so the capture helpers can run synchronously. */
|
|
5439
|
-
let sentry = null;
|
|
5440
|
-
/**
|
|
5441
|
-
* Additional bound clients (daemon surface only). The default client keeps the
|
|
5442
|
-
* daemon's own DSN; runtime child-process events and MCP failures are routed
|
|
5443
|
-
* to their own projects via explicitly-bound Scopes — the documented
|
|
5444
|
-
* multi-client pattern. `null` until `initAgentSentry({ surface: "daemon" })`
|
|
5445
|
-
* succeeds.
|
|
5446
|
-
*/
|
|
5447
|
-
let runtimeClient = null;
|
|
5448
|
-
let runtimeScope = null;
|
|
5449
|
-
let mcpClient = null;
|
|
5450
|
-
let mcpScope = null;
|
|
5451
|
-
/**
|
|
5452
|
-
* Map the configured API URL to a coarse environment tag. Best-effort — returns
|
|
5453
|
-
* `"unknown"` when no config is present yet (e.g. `alfe login` pre-setup).
|
|
5454
|
-
*/
|
|
5455
|
-
function deriveEnvironment() {
|
|
5456
|
-
try {
|
|
5457
|
-
const { apiUrl } = resolveConfig();
|
|
5458
|
-
if (apiUrl.includes("api.dev.alfe.ai") || apiUrl.includes("dev.alfe.ai")) return "dev";
|
|
5459
|
-
if (apiUrl.includes("api.test.alfe.ai")) return "test";
|
|
5460
|
-
if (apiUrl.includes("api.demo.alfe.ai")) return "demo";
|
|
5461
|
-
if (apiUrl.includes("api.alfe.ai")) return "prod";
|
|
5462
|
-
} catch {}
|
|
5463
|
-
return "unknown";
|
|
5464
|
-
}
|
|
5465
|
-
/** Active runtime (`openclaw`/`hermes`) from config, or undefined if unknown. */
|
|
5466
|
-
function deriveRuntime() {
|
|
5467
|
-
try {
|
|
5468
|
-
return resolveConfig().runtime;
|
|
5469
|
-
} catch {
|
|
5470
|
-
return;
|
|
5471
|
-
}
|
|
5472
|
-
}
|
|
5473
|
-
/** True when the operator has opted out of error reporting. */
|
|
5474
|
-
function errorReportingDisabled() {
|
|
5475
|
-
const env = process.env.ALFE_ERROR_REPORTING?.trim().toLowerCase();
|
|
5476
|
-
if (env === "0" || env === "false") return true;
|
|
5477
|
-
try {
|
|
5478
|
-
if (readConfig().errorReporting === false) return true;
|
|
5479
|
-
} catch {}
|
|
5480
|
-
return false;
|
|
5481
|
-
}
|
|
5482
|
-
const KV_SECRET_RE = /(?<![A-Za-z0-9])([A-Za-z0-9_-]*(?:authorization|api[_-]?key|apikey|token|secret|password|passwd|bearer|credential)s?)(\s*[:=]\s*|\s+)("?)([^\s"']+)\3/gi;
|
|
5483
|
-
const URL_QUERY_SECRET_RE = /([?&](?:[A-Za-z0-9_-]*(?:token|secret|key|signature|credential|password)|sig|code|x-amz-[a-z-]+)=)[^&\s"']+/gi;
|
|
5484
|
-
const MIN_BARE_SECRET_LEN = 16;
|
|
5485
|
-
const ALFE_TOKEN_RE = /alfe_(?:dev|test|demo|live)_[A-Za-z0-9._-]+/g;
|
|
5486
|
-
const BEARER_RE = /\bbearer\s+[A-Za-z0-9._\-+/=]+/gi;
|
|
5487
|
-
function scrubString(value) {
|
|
5488
|
-
return value.replace(BEARER_RE, "Bearer [REDACTED]").replace(URL_QUERY_SECRET_RE, "$1[REDACTED]").replace(KV_SECRET_RE, (match, label, sep, _q, secret) => {
|
|
5489
|
-
if (!/[:=]/.test(sep) && secret.length < MIN_BARE_SECRET_LEN) return match;
|
|
5490
|
-
return `${label}${sep}[REDACTED]`;
|
|
5491
|
-
}).replace(ALFE_TOKEN_RE, "alfe_[REDACTED]");
|
|
5492
|
-
}
|
|
5493
|
-
/** Scrub secrets from a breadcrumb's message + string data values in place. */
|
|
5494
|
-
function scrubBreadcrumb(b) {
|
|
5495
|
-
if (typeof b.message === "string") b.message = scrubString(b.message);
|
|
5496
|
-
const data = b.data;
|
|
5497
|
-
if (data) for (const k of Object.keys(data)) {
|
|
5498
|
-
const dv = data[k];
|
|
5499
|
-
if (typeof dv === "string") data[k] = scrubString(dv);
|
|
5500
|
-
}
|
|
5501
|
-
return b;
|
|
5502
|
-
}
|
|
5503
|
-
/**
|
|
5504
|
-
* Scrub secrets and drop request/env payloads before an event is sent.
|
|
5505
|
-
* Generic so it preserves the caller's exact event subtype (`beforeSend`
|
|
5506
|
-
* receives — and must return — an `ErrorEvent`, not a widened `Event`).
|
|
5507
|
-
*/
|
|
5508
|
-
function scrubEvent(event) {
|
|
5509
|
-
delete event.request;
|
|
5510
|
-
if (event.contexts) delete event.contexts.runtime;
|
|
5511
|
-
delete event.extra;
|
|
5512
|
-
delete event.server_name;
|
|
5513
|
-
if (typeof event.message === "string") event.message = scrubString(event.message);
|
|
5514
|
-
const values = event.exception?.values;
|
|
5515
|
-
if (values) {
|
|
5516
|
-
for (const v of values) if (typeof v.value === "string") v.value = scrubString(v.value);
|
|
5517
|
-
}
|
|
5518
|
-
if (event.breadcrumbs) for (const b of event.breadcrumbs) scrubBreadcrumb(b);
|
|
5519
|
-
return event;
|
|
5520
|
-
}
|
|
5521
|
-
/**
|
|
5522
|
-
* Initialise Sentry for the agent-side CLI/daemon. Idempotent, best-effort, and
|
|
5523
|
-
* a no-op when opted out or when the baked DSN is empty. Never throws.
|
|
5524
|
-
*/
|
|
5525
|
-
async function initAgentSentry(options) {
|
|
5526
|
-
try {
|
|
5527
|
-
if (sentry) return;
|
|
5528
|
-
if (errorReportingDisabled()) return;
|
|
5529
|
-
const dsn = SURFACE_DSNS[options.surface].trim();
|
|
5530
|
-
if (!dsn) return;
|
|
5531
|
-
const mod = await import("@sentry/node");
|
|
5532
|
-
if (options.surface === "daemon") {
|
|
5533
|
-
const buildBoundScope = (dsn, extraTags) => {
|
|
5534
|
-
try {
|
|
5535
|
-
if (!dsn.trim()) return null;
|
|
5536
|
-
const client = new mod.NodeClient({
|
|
5537
|
-
dsn: dsn.trim(),
|
|
5538
|
-
environment: deriveEnvironment(),
|
|
5539
|
-
...options.release ? { release: options.release } : {},
|
|
5540
|
-
sampleRate: 1,
|
|
5541
|
-
tracesSampleRate: 0,
|
|
5542
|
-
sendDefaultPii: false,
|
|
5543
|
-
transport: mod.makeNodeTransport,
|
|
5544
|
-
stackParser: mod.defaultStackParser,
|
|
5545
|
-
integrations: [],
|
|
5546
|
-
beforeSend: (event) => scrubEvent(event)
|
|
5547
|
-
});
|
|
5548
|
-
client.init();
|
|
5549
|
-
const scope = new mod.Scope();
|
|
5550
|
-
scope.setClient(client);
|
|
5551
|
-
const runtime = deriveRuntime();
|
|
5552
|
-
if (runtime) scope.setTag("runtime", runtime);
|
|
5553
|
-
for (const [k, v] of Object.entries(extraTags)) scope.setTag(k, v);
|
|
5554
|
-
return {
|
|
5555
|
-
client,
|
|
5556
|
-
scope
|
|
5557
|
-
};
|
|
5558
|
-
} catch {
|
|
5559
|
-
return null;
|
|
5560
|
-
}
|
|
5561
|
-
};
|
|
5562
|
-
if (!runtimeScope) {
|
|
5563
|
-
const bound = buildBoundScope(AGENT_RUNTIME_SENTRY_DSN, {});
|
|
5564
|
-
runtimeClient = bound?.client ?? null;
|
|
5565
|
-
runtimeScope = bound?.scope ?? null;
|
|
5566
|
-
}
|
|
5567
|
-
if (!mcpScope) {
|
|
5568
|
-
const bound = buildBoundScope(AGENT_MCP_SENTRY_DSN, { source: "agent-daemon" });
|
|
5569
|
-
mcpClient = bound?.client ?? null;
|
|
5570
|
-
mcpScope = bound?.scope ?? null;
|
|
5571
|
-
}
|
|
5572
|
-
}
|
|
5573
|
-
if (mod.getClient()) {
|
|
5574
|
-
sentry = mod;
|
|
5575
|
-
return;
|
|
5576
|
-
}
|
|
5577
|
-
mod.init({
|
|
5578
|
-
dsn,
|
|
5579
|
-
environment: deriveEnvironment(),
|
|
5580
|
-
...options.release ? { release: options.release } : {},
|
|
5581
|
-
sampleRate: 1,
|
|
5582
|
-
tracesSampleRate: 0,
|
|
5583
|
-
sendDefaultPii: false,
|
|
5584
|
-
...options.surface === "daemon" ? { integrations: (defaults) => defaults.filter((i) => i.name !== "OnUncaughtException" && i.name !== "OnUnhandledRejection") } : {},
|
|
5585
|
-
beforeSend: (event) => scrubEvent(event),
|
|
5586
|
-
beforeBreadcrumb: (breadcrumb) => scrubBreadcrumb(breadcrumb)
|
|
5587
|
-
});
|
|
5588
|
-
sentry = mod;
|
|
5589
|
-
mod.setTag("surface", options.surface);
|
|
5590
|
-
const runtime = deriveRuntime();
|
|
5591
|
-
if (runtime) mod.setTag("runtime", runtime);
|
|
5592
|
-
} catch {
|
|
5593
|
-
sentry = null;
|
|
5594
|
-
}
|
|
5595
|
-
}
|
|
5596
|
-
/**
|
|
5597
|
-
* Attach the resolved agent identity to all subsequent events. Called by the
|
|
5598
|
-
* daemon once `loadDaemonConfig()` has resolved `agentId`/`runtime`.
|
|
5599
|
-
*/
|
|
5600
|
-
function setAgentContext(context) {
|
|
5601
|
-
if (!sentry) return;
|
|
5602
|
-
try {
|
|
5603
|
-
for (const scope of [
|
|
5604
|
-
sentry,
|
|
5605
|
-
runtimeScope,
|
|
5606
|
-
mcpScope
|
|
5607
|
-
]) {
|
|
5608
|
-
if (!scope) continue;
|
|
5609
|
-
if (context.agentId) scope.setTag("agentId", context.agentId);
|
|
5610
|
-
if (context.runtime) scope.setTag("runtime", context.runtime);
|
|
5611
|
-
}
|
|
5612
|
-
} catch {}
|
|
5613
|
-
}
|
|
5614
|
-
/**
|
|
5615
|
-
* Capture an integration lifecycle failure (install/activate/reinstall/remove)
|
|
5616
|
-
* with `{ integration, phase }` tags. Accepts an `Error` (captured with stack)
|
|
5617
|
-
* or a plain message string (captured as an error-level message). No-op when
|
|
5618
|
-
* Sentry is not initialised.
|
|
5619
|
-
*/
|
|
5620
|
-
function captureIntegrationFailure(integration, phase, cause) {
|
|
5621
|
-
if (!sentry) return;
|
|
5622
|
-
try {
|
|
5623
|
-
if (cause instanceof Error) sentry.captureException(cause, { tags: {
|
|
5624
|
-
integration,
|
|
5625
|
-
phase
|
|
5626
|
-
} });
|
|
5627
|
-
else sentry.captureMessage(String(cause), {
|
|
5628
|
-
level: "error",
|
|
5629
|
-
tags: {
|
|
5630
|
-
integration,
|
|
5631
|
-
phase
|
|
5632
|
-
}
|
|
5633
|
-
});
|
|
5634
|
-
} catch {}
|
|
5635
|
-
}
|
|
5636
|
-
/**
|
|
5637
|
-
* Capture a generic CLI failure with a `{ context }` tag. The CLI counterpart
|
|
5638
|
-
* to `captureIntegrationFailure` — used by `exitWithError` at the handled-error
|
|
5639
|
-
* `process.exit()` sites in `@alfe.ai/cli`'s commands, which otherwise report
|
|
5640
|
-
* nothing (the error was caught, never thrown). Accepts an `Error` (captured
|
|
5641
|
-
* with stack), a defined non-Error value (captured as an error-level message),
|
|
5642
|
-
* or nothing (the `context` string itself becomes the message). No-op when
|
|
5643
|
-
* Sentry is not initialised. Never throws.
|
|
5644
|
-
*/
|
|
5645
|
-
function captureCliFailure(context, cause) {
|
|
5646
|
-
if (!sentry) return;
|
|
5647
|
-
try {
|
|
5648
|
-
if (cause instanceof Error) sentry.captureException(cause, { tags: { context } });
|
|
5649
|
-
else if (cause === void 0) sentry.captureMessage(context, { level: "error" });
|
|
5650
|
-
else sentry.captureMessage(String(cause), {
|
|
5651
|
-
level: "error",
|
|
5652
|
-
tags: { context }
|
|
5653
|
-
});
|
|
5654
|
-
} catch {}
|
|
5655
|
-
}
|
|
5656
|
-
/**
|
|
5657
|
-
* Capture a runtime child-process crash (non-graceful exit) or spawn failure
|
|
5658
|
-
* into the dedicated `agent-runtime` project. Recent output travels as
|
|
5659
|
-
* breadcrumbs — NEVER `extra`, which `scrubEvent` (the runtime client's
|
|
5660
|
-
* `beforeSend`) deletes. Throttling is the caller's job (`CaptureThrottle` in
|
|
5661
|
-
* `runtime-output-monitor.ts`). No-op when the runtime client is not
|
|
5662
|
-
* initialised. Never throws.
|
|
5663
|
-
*/
|
|
5664
|
-
function captureRuntimeCrash(opts) {
|
|
5665
|
-
if (!runtimeScope) return;
|
|
5666
|
-
try {
|
|
5667
|
-
const scope = runtimeScope.clone();
|
|
5668
|
-
for (const { stream, line } of opts.recentOutput) scope.addBreadcrumb({
|
|
5669
|
-
category: `runtime.${stream}`,
|
|
5670
|
-
level: stream === "stderr" ? "warning" : "info",
|
|
5671
|
-
message: line
|
|
5672
|
-
});
|
|
5673
|
-
const crashKey = opts.spawnError ? `spawn:${opts.spawnError.code ?? "error"}` : String(opts.code ?? opts.signal ?? "unknown");
|
|
5674
|
-
scope.setFingerprint([
|
|
5675
|
-
"runtime-crash",
|
|
5676
|
-
opts.runtime,
|
|
5677
|
-
crashKey
|
|
5678
|
-
]);
|
|
5679
|
-
scope.setTags({
|
|
5680
|
-
runtime: opts.runtime,
|
|
5681
|
-
exitCode: String(opts.code),
|
|
5682
|
-
signal: String(opts.signal),
|
|
5683
|
-
crashesSuppressed: String(opts.crashesSuppressed ?? 0)
|
|
5684
|
-
});
|
|
5685
|
-
if (opts.spawnError) scope.captureException(opts.spawnError);
|
|
5686
|
-
else scope.captureMessage(`Runtime ${opts.runtime} crashed (code=${String(opts.code)}, signal=${String(opts.signal)})`, "error");
|
|
5687
|
-
} catch {}
|
|
5688
|
-
}
|
|
5689
|
-
/**
|
|
5690
|
-
* Capture an error-looking block of runtime output (stack trace, Python
|
|
5691
|
-
* traceback, or ERROR-level log line) detected while the child is running.
|
|
5692
|
-
* The block lines travel as breadcrumbs; the head line is the message.
|
|
5693
|
-
* Throttling/dedupe is the caller's job. No-op when Sentry is not initialised.
|
|
5694
|
-
* Never throws.
|
|
5695
|
-
*/
|
|
5696
|
-
function captureRuntimeErrorOutput(opts) {
|
|
5697
|
-
if (!runtimeScope) return;
|
|
5698
|
-
try {
|
|
5699
|
-
const scope = runtimeScope.clone();
|
|
5700
|
-
for (const line of opts.lines) scope.addBreadcrumb({
|
|
5701
|
-
category: `runtime.${opts.stream}`,
|
|
5702
|
-
level: "error",
|
|
5703
|
-
message: line
|
|
5704
|
-
});
|
|
5705
|
-
scope.setFingerprint([
|
|
5706
|
-
"runtime-output",
|
|
5707
|
-
opts.runtime,
|
|
5708
|
-
opts.kind,
|
|
5709
|
-
opts.fingerprintKey
|
|
5710
|
-
]);
|
|
5711
|
-
scope.setTags({
|
|
5712
|
-
runtime: opts.runtime,
|
|
5713
|
-
stream: opts.stream,
|
|
5714
|
-
kind: opts.kind,
|
|
5715
|
-
suppressedCount: String(opts.suppressedCount ?? 0)
|
|
5716
|
-
});
|
|
5717
|
-
scope.captureMessage(opts.lines[0].slice(0, 300), "error");
|
|
5718
|
-
} catch {}
|
|
5719
|
-
}
|
|
5720
|
-
/**
|
|
5721
|
-
* Capture an MCP failure (tool error, server crash, or error-looking stderr
|
|
5722
|
-
* output) into the `mcp` project via the daemon's bound MCP client. Throttling
|
|
5723
|
-
* is the caller's job (`mcp-error-capture.ts`). No-op when the MCP client is
|
|
5724
|
-
* not initialised. Never throws.
|
|
5725
|
-
*/
|
|
5726
|
-
function captureMcpFailure(opts) {
|
|
5727
|
-
if (!mcpScope) return;
|
|
5728
|
-
try {
|
|
5729
|
-
const scope = mcpScope.clone();
|
|
5730
|
-
for (const line of opts.lines ?? []) scope.addBreadcrumb({
|
|
5731
|
-
category: `mcp.${opts.server}`,
|
|
5732
|
-
level: "error",
|
|
5733
|
-
message: line
|
|
5734
|
-
});
|
|
5735
|
-
scope.setFingerprint([
|
|
5736
|
-
"agent-mcp",
|
|
5737
|
-
opts.server,
|
|
5738
|
-
opts.kind,
|
|
5739
|
-
...opts.tool ? [opts.tool] : []
|
|
5740
|
-
]);
|
|
5741
|
-
scope.setTags({
|
|
5742
|
-
server: opts.server,
|
|
5743
|
-
kind: opts.kind,
|
|
5744
|
-
...opts.tool ? { tool: opts.tool } : {},
|
|
5745
|
-
suppressedCount: String(opts.suppressedCount ?? 0)
|
|
5746
|
-
});
|
|
5747
|
-
scope.captureMessage(`MCP ${opts.server}${opts.tool ? `.${opts.tool}` : ""} ${opts.kind}: ${opts.message.slice(0, 300)}`, "error");
|
|
5748
|
-
} catch {}
|
|
5749
|
-
}
|
|
5750
|
-
/** Flush queued events (small timeout) so short-lived commands deliver them. */
|
|
5751
|
-
async function flushSentry(timeoutMs = 2e3) {
|
|
5752
|
-
for (const client of [runtimeClient, mcpClient]) try {
|
|
5753
|
-
if (client) await client.flush(timeoutMs);
|
|
5754
|
-
} catch {}
|
|
5755
|
-
if (!sentry) return;
|
|
5756
|
-
try {
|
|
5757
|
-
await sentry.flush(timeoutMs);
|
|
5758
|
-
} catch {}
|
|
5759
|
-
}
|
|
5760
|
-
/**
|
|
5761
|
-
* Capture a fatal error — for the CLI's top-level catch. Does NOT flush: the
|
|
5762
|
-
* caller's `finally { await flushSentry() }` delivers it (flushing here too
|
|
5763
|
-
* doubled the worst-case exit delay on a failing command).
|
|
5764
|
-
*/
|
|
5765
|
-
function captureFatal(cause) {
|
|
5766
|
-
if (!sentry) return;
|
|
5767
|
-
try {
|
|
5768
|
-
sentry.captureException(cause);
|
|
5769
|
-
} catch {}
|
|
5770
|
-
}
|
|
5771
|
-
//#endregion
|
|
5772
5442
|
//#region src/reconciliation.ts
|
|
5773
|
-
const log$
|
|
5443
|
+
const log$5 = logger$1.child({ component: "Reconciliation" });
|
|
5774
5444
|
/**
|
|
5775
5445
|
* How many times reconcile will re-attempt activation of an intact integration
|
|
5776
5446
|
* stuck in `error` (with no reinstall requested) before giving up and waiting
|
|
@@ -5840,7 +5510,7 @@ var ReconciliationEngine = class {
|
|
|
5840
5510
|
try {
|
|
5841
5511
|
localIntegrations = await this.manager.getInstalledIntegrations();
|
|
5842
5512
|
} catch (err) {
|
|
5843
|
-
log$
|
|
5513
|
+
log$5.error({ err }, "Failed to get local integrations");
|
|
5844
5514
|
localIntegrations = [];
|
|
5845
5515
|
}
|
|
5846
5516
|
const localMap = new Map(localIntegrations.map((i) => [i.id, i]));
|
|
@@ -5859,10 +5529,10 @@ var ReconciliationEngine = class {
|
|
|
5859
5529
|
try {
|
|
5860
5530
|
if (!local) {
|
|
5861
5531
|
await this.ensureSuspended();
|
|
5862
|
-
log$
|
|
5532
|
+
log$5.info(`Installing ${id}@${desired.version}`);
|
|
5863
5533
|
await this.manager.install(id, desired.version, desired.config, desired.customSource);
|
|
5864
5534
|
report.installed.push(id);
|
|
5865
|
-
log$
|
|
5535
|
+
log$5.info(`Activating ${id}`);
|
|
5866
5536
|
if ((await this.manager.activate(id)).configApplied) report.configApplied = true;
|
|
5867
5537
|
report.activated.push(id);
|
|
5868
5538
|
report.results.push({
|
|
@@ -5875,7 +5545,7 @@ var ReconciliationEngine = class {
|
|
|
5875
5545
|
if (local.version !== desired.version && desired.version !== "" && local.version !== "unknown") {
|
|
5876
5546
|
if (desired.reinstallRequestedAt && local.installedAt && desired.reinstallRequestedAt > local.installedAt) {
|
|
5877
5547
|
await this.ensureSuspended();
|
|
5878
|
-
log$
|
|
5548
|
+
log$5.info(`Reinstalling ${id} on version change (reinstall requested: ${desired.reinstallRequestedAt}, installed: ${local.installedAt}): ${local.version} → ${desired.version}`);
|
|
5879
5549
|
this.manager.resetReinstallAttempts(id);
|
|
5880
5550
|
this.activateAttempts.delete(id);
|
|
5881
5551
|
if ((await this.manager.reinstall(id, desired.version, desired.config, desired.customSource)).configApplied) report.configApplied = true;
|
|
@@ -5888,7 +5558,7 @@ var ReconciliationEngine = class {
|
|
|
5888
5558
|
});
|
|
5889
5559
|
return;
|
|
5890
5560
|
}
|
|
5891
|
-
log$
|
|
5561
|
+
log$5.info(`Upgrading ${id} in place: ${local.version} → ${desired.version}`);
|
|
5892
5562
|
try {
|
|
5893
5563
|
if ((await this.manager.upgrade(id, desired.version, desired.config, desired.customSource, { onBeforeRuntimeMutation: () => this.ensureSuspended() })).configApplied) report.configApplied = true;
|
|
5894
5564
|
this.manager.resetReinstallAttempts(id);
|
|
@@ -5902,7 +5572,7 @@ var ReconciliationEngine = class {
|
|
|
5902
5572
|
});
|
|
5903
5573
|
} catch (upgradeErr) {
|
|
5904
5574
|
const upgradeMsg = upgradeErr instanceof Error ? upgradeErr.message : String(upgradeErr);
|
|
5905
|
-
log$
|
|
5575
|
+
log$5.error({ err: upgradeErr }, `Upgrade failed for ${id}`);
|
|
5906
5576
|
captureIntegrationFailure(id, "upgrade", upgradeErr);
|
|
5907
5577
|
report.errors.push({
|
|
5908
5578
|
integrationId: id,
|
|
@@ -5920,7 +5590,7 @@ var ReconciliationEngine = class {
|
|
|
5920
5590
|
if (local.status === "error") {
|
|
5921
5591
|
if (desired.reinstallRequestedAt && local.installedAt && desired.reinstallRequestedAt > local.installedAt) {
|
|
5922
5592
|
await this.ensureSuspended();
|
|
5923
|
-
log$
|
|
5593
|
+
log$5.info(`Reinstalling ${id} from error state (requested: ${desired.reinstallRequestedAt}, installed: ${local.installedAt})`);
|
|
5924
5594
|
this.manager.resetReinstallAttempts(id);
|
|
5925
5595
|
this.activateAttempts.delete(id);
|
|
5926
5596
|
if ((await this.manager.reinstall(id, desired.version, desired.config, desired.customSource)).configApplied) report.configApplied = true;
|
|
@@ -5936,7 +5606,7 @@ var ReconciliationEngine = class {
|
|
|
5936
5606
|
if (!await this.manager.isInstallIntact(id)) {
|
|
5937
5607
|
const attempts = this.manager.getReinstallAttempts(id);
|
|
5938
5608
|
if (attempts >= 3) {
|
|
5939
|
-
log$
|
|
5609
|
+
log$5.error(`Auto-reinstall blocked for ${id} — ${String(attempts)} consecutive failures. Manual reinstall required.`);
|
|
5940
5610
|
captureIntegrationFailure(id, "reinstall", `Max auto-reinstall attempts (${String(attempts)}) reached — manual reinstall required`);
|
|
5941
5611
|
report.errors.push({
|
|
5942
5612
|
integrationId: id,
|
|
@@ -5951,7 +5621,7 @@ var ReconciliationEngine = class {
|
|
|
5951
5621
|
return;
|
|
5952
5622
|
}
|
|
5953
5623
|
await this.ensureSuspended();
|
|
5954
|
-
log$
|
|
5624
|
+
log$5.info(`Auto-reinstalling ${id} — install directory is corrupted or missing (attempt ${String(attempts + 1)}/3)`);
|
|
5955
5625
|
this.manager.incrementReinstallAttempts(id);
|
|
5956
5626
|
try {
|
|
5957
5627
|
if ((await this.manager.reinstall(id, desired.version, desired.config, desired.customSource)).configApplied) report.configApplied = true;
|
|
@@ -5966,7 +5636,7 @@ var ReconciliationEngine = class {
|
|
|
5966
5636
|
});
|
|
5967
5637
|
} catch (reinstallErr) {
|
|
5968
5638
|
const reinstallMsg = reinstallErr instanceof Error ? reinstallErr.message : String(reinstallErr);
|
|
5969
|
-
log$
|
|
5639
|
+
log$5.error({ err: reinstallErr }, `Auto-reinstall failed for ${id}`);
|
|
5970
5640
|
captureIntegrationFailure(id, "reinstall", reinstallErr);
|
|
5971
5641
|
report.errors.push({
|
|
5972
5642
|
integrationId: id,
|
|
@@ -5985,7 +5655,7 @@ var ReconciliationEngine = class {
|
|
|
5985
5655
|
if (activateAttempts < MAX_ERROR_ACTIVATE_ATTEMPTS) {
|
|
5986
5656
|
this.activateAttempts.set(id, activateAttempts + 1);
|
|
5987
5657
|
await this.ensureSuspended();
|
|
5988
|
-
log$
|
|
5658
|
+
log$5.info(`Re-activating ${id} from error state (attempt ${String(activateAttempts + 1)}/${String(MAX_ERROR_ACTIVATE_ATTEMPTS)})`);
|
|
5989
5659
|
try {
|
|
5990
5660
|
if ((await this.manager.activate(id, { forcePlugins: true })).configApplied) report.configApplied = true;
|
|
5991
5661
|
this.activateAttempts.delete(id);
|
|
@@ -5998,7 +5668,7 @@ var ReconciliationEngine = class {
|
|
|
5998
5668
|
return;
|
|
5999
5669
|
} catch (reactivateErr) {
|
|
6000
5670
|
const reactivateMsg = reactivateErr instanceof Error ? reactivateErr.message : String(reactivateErr);
|
|
6001
|
-
log$
|
|
5671
|
+
log$5.warn(`Re-activation of ${id} from error state failed (attempt ${String(activateAttempts + 1)}/${String(MAX_ERROR_ACTIVATE_ATTEMPTS)}): ${reactivateMsg}`);
|
|
6002
5672
|
captureIntegrationFailure(id, "reactivate", reactivateErr);
|
|
6003
5673
|
report.errors.push({
|
|
6004
5674
|
integrationId: id,
|
|
@@ -6013,7 +5683,7 @@ var ReconciliationEngine = class {
|
|
|
6013
5683
|
return;
|
|
6014
5684
|
}
|
|
6015
5685
|
}
|
|
6016
|
-
log$
|
|
5686
|
+
log$5.warn(`Integration ${id} is in error state — re-activation exhausted, waiting for reinstall request`);
|
|
6017
5687
|
captureIntegrationFailure(id, "reactivate", `Integration ${id} stuck in error state — re-activation attempts exhausted`);
|
|
6018
5688
|
report.errors.push({
|
|
6019
5689
|
integrationId: id,
|
|
@@ -6030,7 +5700,7 @@ var ReconciliationEngine = class {
|
|
|
6030
5700
|
if (local.status === "installing") {
|
|
6031
5701
|
if (desired.reinstallRequestedAt && local.installedAt && desired.reinstallRequestedAt > local.installedAt) {
|
|
6032
5702
|
await this.ensureSuspended();
|
|
6033
|
-
log$
|
|
5703
|
+
log$5.info(`Reinstalling ${id} from stale installing state (requested: ${desired.reinstallRequestedAt}, installed: ${local.installedAt})`);
|
|
6034
5704
|
this.manager.resetReinstallAttempts(id);
|
|
6035
5705
|
this.activateAttempts.delete(id);
|
|
6036
5706
|
if ((await this.manager.reinstall(id, desired.version, desired.config, desired.customSource)).configApplied) report.configApplied = true;
|
|
@@ -6046,7 +5716,7 @@ var ReconciliationEngine = class {
|
|
|
6046
5716
|
const attempts = this.manager.getReinstallAttempts(id);
|
|
6047
5717
|
if (attempts >= 3) {
|
|
6048
5718
|
const errorMessage = `Stale installing state — max auto-reinstall attempts (${String(attempts)}) reached — manual reinstall required`;
|
|
6049
|
-
log$
|
|
5719
|
+
log$5.error(`Auto-reinstall blocked for ${id} — stuck in "installing", ${String(attempts)} consecutive failures. Manual reinstall required.`);
|
|
6050
5720
|
captureIntegrationFailure(id, "reinstall", errorMessage);
|
|
6051
5721
|
report.errors.push({
|
|
6052
5722
|
integrationId: id,
|
|
@@ -6061,7 +5731,7 @@ var ReconciliationEngine = class {
|
|
|
6061
5731
|
return;
|
|
6062
5732
|
}
|
|
6063
5733
|
await this.ensureSuspended();
|
|
6064
|
-
log$
|
|
5734
|
+
log$5.info(`Recovering ${id} from stale installing state via reinstall (attempt ${String(attempts + 1)}/3)`);
|
|
6065
5735
|
this.manager.incrementReinstallAttempts(id);
|
|
6066
5736
|
try {
|
|
6067
5737
|
if ((await this.manager.reinstall(id, desired.version, desired.config, desired.customSource)).configApplied) report.configApplied = true;
|
|
@@ -6076,7 +5746,7 @@ var ReconciliationEngine = class {
|
|
|
6076
5746
|
});
|
|
6077
5747
|
} catch (reinstallErr) {
|
|
6078
5748
|
const reinstallMsg = reinstallErr instanceof Error ? reinstallErr.message : String(reinstallErr);
|
|
6079
|
-
log$
|
|
5749
|
+
log$5.error({ err: reinstallErr }, `Reinstall from stale installing state failed for ${id}`);
|
|
6080
5750
|
captureIntegrationFailure(id, "reinstall", reinstallErr);
|
|
6081
5751
|
report.errors.push({
|
|
6082
5752
|
integrationId: id,
|
|
@@ -6093,7 +5763,7 @@ var ReconciliationEngine = class {
|
|
|
6093
5763
|
}
|
|
6094
5764
|
if (local.status !== "active") {
|
|
6095
5765
|
await this.ensureSuspended();
|
|
6096
|
-
log$
|
|
5766
|
+
log$5.info(`Activating ${id}`);
|
|
6097
5767
|
if ((await this.manager.activate(id, { forcePlugins: true })).configApplied) report.configApplied = true;
|
|
6098
5768
|
report.activated.push(id);
|
|
6099
5769
|
report.results.push({
|
|
@@ -6105,7 +5775,7 @@ var ReconciliationEngine = class {
|
|
|
6105
5775
|
}
|
|
6106
5776
|
if (desired.reinstallRequestedAt && local.installedAt && desired.reinstallRequestedAt > local.installedAt) {
|
|
6107
5777
|
await this.ensureSuspended();
|
|
6108
|
-
log$
|
|
5778
|
+
log$5.info(`Reinstall requested for ${id} (requested: ${desired.reinstallRequestedAt}, installed: ${local.installedAt})`);
|
|
6109
5779
|
this.manager.resetReinstallAttempts(id);
|
|
6110
5780
|
this.activateAttempts.delete(id);
|
|
6111
5781
|
if ((await this.manager.reinstall(id, desired.version, desired.config, desired.customSource)).configApplied) report.configApplied = true;
|
|
@@ -6126,7 +5796,7 @@ var ReconciliationEngine = class {
|
|
|
6126
5796
|
});
|
|
6127
5797
|
} catch (err) {
|
|
6128
5798
|
const message = err instanceof Error ? err.message : String(err);
|
|
6129
|
-
log$
|
|
5799
|
+
log$5.error({ err }, `Error reconciling ${id}`);
|
|
6130
5800
|
captureIntegrationFailure(id, "reconcile_active", err);
|
|
6131
5801
|
report.errors.push({
|
|
6132
5802
|
integrationId: id,
|
|
@@ -6151,7 +5821,7 @@ var ReconciliationEngine = class {
|
|
|
6151
5821
|
}
|
|
6152
5822
|
try {
|
|
6153
5823
|
await this.ensureSuspended();
|
|
6154
|
-
log$
|
|
5824
|
+
log$5.info(`Removing ${id}`);
|
|
6155
5825
|
if (local.status === "active") {
|
|
6156
5826
|
await this.manager.deactivate(id);
|
|
6157
5827
|
report.deactivated.push(id);
|
|
@@ -6165,7 +5835,7 @@ var ReconciliationEngine = class {
|
|
|
6165
5835
|
});
|
|
6166
5836
|
} catch (err) {
|
|
6167
5837
|
const message = err instanceof Error ? err.message : String(err);
|
|
6168
|
-
log$
|
|
5838
|
+
log$5.error({ err }, `Error removing ${id}`);
|
|
6169
5839
|
captureIntegrationFailure(id, "reconcile_removed", err);
|
|
6170
5840
|
report.errors.push({
|
|
6171
5841
|
integrationId: id,
|
|
@@ -6548,7 +6218,7 @@ var CloudClient = class {
|
|
|
6548
6218
|
};
|
|
6549
6219
|
//#endregion
|
|
6550
6220
|
//#region src/config-reconciler.ts
|
|
6551
|
-
const log$
|
|
6221
|
+
const log$4 = logger$1.child({ component: "ConfigReconciler" });
|
|
6552
6222
|
const DEFAULT_VERIFY_RETRIES = 3;
|
|
6553
6223
|
const DEFAULT_VERIFY_RETRY_DELAY_MS = 750;
|
|
6554
6224
|
const delay = (ms) => new Promise((resolve) => {
|
|
@@ -6570,7 +6240,7 @@ var ConfigReconciler = class {
|
|
|
6570
6240
|
async reconcile(desired) {
|
|
6571
6241
|
const applier = this.getApplier();
|
|
6572
6242
|
if (!applier?.setConfigRaw) {
|
|
6573
|
-
log$
|
|
6243
|
+
log$4.debug("No applier with setConfigRaw — skipping config reconcile");
|
|
6574
6244
|
return null;
|
|
6575
6245
|
}
|
|
6576
6246
|
const setConfigRaw = applier.setConfigRaw.bind(applier);
|
|
@@ -6584,7 +6254,7 @@ var ConfigReconciler = class {
|
|
|
6584
6254
|
for (const [key, want] of entries) try {
|
|
6585
6255
|
if (getConfigRaw) {
|
|
6586
6256
|
if (await getConfigRaw(key) === want) {
|
|
6587
|
-
log$
|
|
6257
|
+
log$4.debug({ key }, "Config already at desired value — no-op");
|
|
6588
6258
|
continue;
|
|
6589
6259
|
}
|
|
6590
6260
|
}
|
|
@@ -6594,12 +6264,19 @@ var ConfigReconciler = class {
|
|
|
6594
6264
|
}
|
|
6595
6265
|
} catch (err) {
|
|
6596
6266
|
const msg = err instanceof Error ? err.message : String(err);
|
|
6267
|
+
if (getConfigRaw && await this.verify(getConfigRaw, key, want)) {
|
|
6268
|
+
log$4.warn({
|
|
6269
|
+
key,
|
|
6270
|
+
err: msg
|
|
6271
|
+
}, "config set errored but value landed — treating as applied");
|
|
6272
|
+
continue;
|
|
6273
|
+
}
|
|
6597
6274
|
failures.push(`${key}: ${msg}`);
|
|
6598
6275
|
}
|
|
6599
6276
|
if (failures.length > 0) {
|
|
6600
6277
|
const joined = failures.join("; ");
|
|
6601
6278
|
const reason = joined.length > 480 ? `${joined.slice(0, 480)}… (truncated)` : joined;
|
|
6602
|
-
log$
|
|
6279
|
+
log$4.warn({
|
|
6603
6280
|
version: desired.version,
|
|
6604
6281
|
reason
|
|
6605
6282
|
}, "Config reconcile failed");
|
|
@@ -6609,7 +6286,7 @@ var ConfigReconciler = class {
|
|
|
6609
6286
|
reason
|
|
6610
6287
|
};
|
|
6611
6288
|
}
|
|
6612
|
-
log$
|
|
6289
|
+
log$4.info({
|
|
6613
6290
|
version: desired.version,
|
|
6614
6291
|
keys: entries.length
|
|
6615
6292
|
}, "Config reconcile applied");
|
|
@@ -7080,6 +6757,14 @@ var CommandQueue = class {
|
|
|
7080
6757
|
*/
|
|
7081
6758
|
const LAUNCHD_LABEL = "ai.alfe.gateway";
|
|
7082
6759
|
const SYSTEMD_SERVICE = "alfe-gateway";
|
|
6760
|
+
/**
|
|
6761
|
+
* On-disk path for the boot-time self-heal guard script (Linux only).
|
|
6762
|
+
* Written next to the systemd unit at setup time and invoked via
|
|
6763
|
+
* `ExecStartPre=` — see `writeGuardScript` and `generateSystemdUnit`.
|
|
6764
|
+
*/
|
|
6765
|
+
function getGuardScriptPath() {
|
|
6766
|
+
return isRootUser() ? "/usr/local/lib/alfe/alfe-cli-guard.sh" : join(homedir(), ".alfe", "alfe-cli-guard.sh");
|
|
6767
|
+
}
|
|
7083
6768
|
function isRootUser() {
|
|
7084
6769
|
return process.getuid?.() === 0;
|
|
7085
6770
|
}
|
|
@@ -7155,16 +6840,108 @@ function generateLaunchdPlist() {
|
|
|
7155
6840
|
</plist>`;
|
|
7156
6841
|
}
|
|
7157
6842
|
/**
|
|
6843
|
+
* Boot-time self-heal guard (Linux only).
|
|
6844
|
+
*
|
|
6845
|
+
* Runs as an `ExecStartPre=` BEFORE the daemon launches, OUTSIDE the
|
|
6846
|
+
* (possibly-broken) `alfe` binary. It exists for the case defense #1
|
|
6847
|
+
* (verify-before-exit in `upgrade.ts`) structurally cannot cover: the daemon
|
|
6848
|
+
* is KILLED during `npm install -g @alfe.ai/cli` (reboot / OOM), leaving the
|
|
6849
|
+
* package with deps but no `dist/` and a dangling `/usr/bin/alfe` symlink.
|
|
6850
|
+
* systemd then execs a dangling binary forever → 203/EXEC crash-loop, a
|
|
6851
|
+
* permanent brick with no automatic recovery (real prod incident,
|
|
6852
|
+
* ~13,570 restarts over 2 days).
|
|
6853
|
+
*
|
|
6854
|
+
* The guard:
|
|
6855
|
+
* - checks the CLI is intact: the symlink target (`dist/index.js`) exists AND
|
|
6856
|
+
* `alfe --version` exits 0;
|
|
6857
|
+
* - if broken, reinstalls `@alfe.ai/cli@$ALFE_CLI_VERSION` (the exact version
|
|
6858
|
+
* baked into the unit's `Environment=`; falls back to `@latest` when
|
|
6859
|
+
* unset), so the daemon then execs a working binary;
|
|
6860
|
+
* - is idempotent + fast (no-op when healthy) and NEVER wedges boot: any of
|
|
6861
|
+
* its own failures log + `exit 0` so a human can still SSH in.
|
|
6862
|
+
*
|
|
6863
|
+
* POSIX sh (dash-safe): the box may not have bash. Keep the two intactness
|
|
6864
|
+
* checks in lockstep with `verifyCliInstall` in `verify-cli-install.ts`.
|
|
6865
|
+
*/
|
|
6866
|
+
/** @internal exported for unit tests; not re-exported from the barrel. */
|
|
6867
|
+
function generateGuardScript() {
|
|
6868
|
+
const version = process.env.ALFE_CLI_VERSION;
|
|
6869
|
+
return `#!/bin/sh
|
|
6870
|
+
# Alfe CLI boot-time self-heal guard. Auto-generated by 'alfe setup' — do not edit.
|
|
6871
|
+
# Repairs an interrupted 'npm install -g @alfe.ai/cli' before the daemon starts,
|
|
6872
|
+
# so a mid-install reboot can't brick the agent with a 203/EXEC crash-loop.
|
|
6873
|
+
# Must never wedge boot: every failure path logs and exits 0.
|
|
6874
|
+
set -u
|
|
6875
|
+
|
|
6876
|
+
TARGET='${version && version.length > 0 ? `@alfe.ai/cli@${version}` : "@alfe.ai/cli@latest"}'
|
|
6877
|
+
log() { echo "[alfe-cli-guard] $*" >&2; }
|
|
6878
|
+
|
|
6879
|
+
# Resolve the alfe bin and its real target (dist/index.js). readlink -f follows
|
|
6880
|
+
# the whole symlink chain; fall back to the bin path itself if unavailable.
|
|
6881
|
+
BIN="$(command -v alfe 2>/dev/null || true)"
|
|
6882
|
+
if [ -n "$BIN" ]; then
|
|
6883
|
+
ENTRY="$(readlink -f "$BIN" 2>/dev/null || echo "$BIN")"
|
|
6884
|
+
else
|
|
6885
|
+
ENTRY=""
|
|
6886
|
+
fi
|
|
6887
|
+
|
|
6888
|
+
healthy=1
|
|
6889
|
+
if [ -z "$ENTRY" ] || [ ! -f "$ENTRY" ]; then
|
|
6890
|
+
healthy=0
|
|
6891
|
+
log "CLI entry missing (bin=\${BIN:-<none>} entry=\${ENTRY:-<none>})"
|
|
6892
|
+
elif ! alfe --version >/dev/null 2>&1; then
|
|
6893
|
+
healthy=0
|
|
6894
|
+
log "alfe --version failed"
|
|
6895
|
+
fi
|
|
6896
|
+
|
|
6897
|
+
if [ "$healthy" -eq 1 ]; then
|
|
6898
|
+
exit 0
|
|
6899
|
+
fi
|
|
6900
|
+
|
|
6901
|
+
log "CLI install looks broken — reinstalling $TARGET"
|
|
6902
|
+
# Blow away a partial tree first so npm re-materialises dist/ cleanly. Best-effort.
|
|
6903
|
+
GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"
|
|
6904
|
+
if [ -n "$GLOBAL_ROOT" ] && [ -d "$GLOBAL_ROOT/@alfe.ai/cli" ]; then
|
|
6905
|
+
rm -rf "$GLOBAL_ROOT/@alfe.ai/cli" 2>/dev/null || true
|
|
6906
|
+
fi
|
|
6907
|
+
|
|
6908
|
+
# Bound the reinstall so a slow/unreachable registry at boot can't stall the
|
|
6909
|
+
# ExecStartPre up to systemd's DefaultTimeoutStartSec (~90s). Two layers:
|
|
6910
|
+
# - npm --fetch-timeout/--fetch-retries cap per-request/hung-socket time;
|
|
6911
|
+
# - a wall-clock 'timeout' wrapper caps the whole install when present
|
|
6912
|
+
# ('timeout' isn't guaranteed, so fall back to a bare install).
|
|
6913
|
+
# stdout->stderr ('>&2') so npm's chatter lands in the journal, not on fd1.
|
|
6914
|
+
if command -v timeout >/dev/null 2>&1; then
|
|
6915
|
+
timeout 120 npm install -g "$TARGET" --fetch-timeout=60000 --fetch-retries=2 >&2
|
|
6916
|
+
rc=$?
|
|
6917
|
+
else
|
|
6918
|
+
npm install -g "$TARGET" --fetch-timeout=60000 --fetch-retries=2 >&2
|
|
6919
|
+
rc=$?
|
|
6920
|
+
fi
|
|
6921
|
+
if [ "$rc" -eq 0 ]; then
|
|
6922
|
+
log "reinstall succeeded"
|
|
6923
|
+
else
|
|
6924
|
+
log "reinstall FAILED (rc=$rc) — daemon start may still fail; a human can SSH in to repair"
|
|
6925
|
+
fi
|
|
6926
|
+
|
|
6927
|
+
# Always exit 0: even a failed repair must not block the unit from attempting
|
|
6928
|
+
# ExecStart (which then fails visibly) rather than wedging silently in pre-start.
|
|
6929
|
+
exit 0
|
|
6930
|
+
`;
|
|
6931
|
+
}
|
|
6932
|
+
/**
|
|
7158
6933
|
* Generate a systemd unit for Linux.
|
|
7159
6934
|
* Root users get a system-level unit; non-root get a user-level unit.
|
|
7160
6935
|
*/
|
|
6936
|
+
/** @internal exported for unit tests; not re-exported from the barrel. */
|
|
7161
6937
|
function generateSystemdUnit() {
|
|
7162
6938
|
const alfeBin = getAlfeBinPath();
|
|
7163
6939
|
const root = isRootUser();
|
|
7164
6940
|
const envLines = [
|
|
7165
6941
|
"ALFE_MANAGED",
|
|
7166
6942
|
"ALFE_API_KEY",
|
|
7167
|
-
"LOG_LEVEL"
|
|
6943
|
+
"LOG_LEVEL",
|
|
6944
|
+
"ALFE_CLI_VERSION"
|
|
7168
6945
|
].filter((key) => process.env[key]).map((key) => `Environment=${key}=${process.env[key] ?? ""}`).join("\n");
|
|
7169
6946
|
return `[Unit]
|
|
7170
6947
|
Description=Alfe Gateway Daemon
|
|
@@ -7173,6 +6950,7 @@ Wants=network-online.target
|
|
|
7173
6950
|
|
|
7174
6951
|
[Service]
|
|
7175
6952
|
Type=simple
|
|
6953
|
+
ExecStartPre=-/bin/sh ${getGuardScriptPath()}
|
|
7176
6954
|
ExecStart=${alfeBin} gateway daemon
|
|
7177
6955
|
Restart=always
|
|
7178
6956
|
RestartSec=10
|
|
@@ -7189,6 +6967,19 @@ ${envLines}
|
|
|
7189
6967
|
WantedBy=${root ? "multi-user.target" : "default.target"}`;
|
|
7190
6968
|
}
|
|
7191
6969
|
/**
|
|
6970
|
+
* Write the boot-time self-heal guard script to disk (Linux only) and make it
|
|
6971
|
+
* executable. Called from `installSystemd` before the unit is written.
|
|
6972
|
+
*/
|
|
6973
|
+
async function writeGuardScript() {
|
|
6974
|
+
const guardPath = getGuardScriptPath();
|
|
6975
|
+
await mkdir(dirname(guardPath), { recursive: true });
|
|
6976
|
+
await writeFile(guardPath, generateGuardScript(), {
|
|
6977
|
+
encoding: "utf-8",
|
|
6978
|
+
mode: 493
|
|
6979
|
+
});
|
|
6980
|
+
logger$1.info({ path: guardPath }, "Wrote CLI self-heal guard script");
|
|
6981
|
+
}
|
|
6982
|
+
/**
|
|
7192
6983
|
* Install the service unit for the current platform.
|
|
7193
6984
|
*/
|
|
7194
6985
|
async function installService() {
|
|
@@ -7250,6 +7041,7 @@ async function installSystemd() {
|
|
|
7250
7041
|
const dir = root ? "/etc/systemd/system" : join(homedir(), ".config", "systemd", "user");
|
|
7251
7042
|
const ctl = root ? "systemctl" : "systemctl --user";
|
|
7252
7043
|
if (!root) await mkdir(dir, { recursive: true });
|
|
7044
|
+
await writeGuardScript();
|
|
7253
7045
|
await writeFile(unitPath, generateSystemdUnit(), "utf-8");
|
|
7254
7046
|
logger$1.info({
|
|
7255
7047
|
path: unitPath,
|
|
@@ -7272,6 +7064,9 @@ async function uninstallSystemd() {
|
|
|
7272
7064
|
try {
|
|
7273
7065
|
await unlink(unitPath);
|
|
7274
7066
|
} catch {}
|
|
7067
|
+
try {
|
|
7068
|
+
await unlink(getGuardScriptPath());
|
|
7069
|
+
} catch {}
|
|
7275
7070
|
try {
|
|
7276
7071
|
execSync(`${ctl} daemon-reload`, { stdio: "pipe" });
|
|
7277
7072
|
} catch {}
|
|
@@ -22585,13 +22380,21 @@ var CaptureThrottle = class {
|
|
|
22585
22380
|
* Spawns the runtime binary, pipes stdout/stderr to the daemon logger,
|
|
22586
22381
|
* and restarts on crash with exponential backoff.
|
|
22587
22382
|
*/
|
|
22588
|
-
const log$
|
|
22383
|
+
const log$3 = createLogger("RuntimeProcess");
|
|
22589
22384
|
/** Quiet-period flush for a pending multi-line error block (e.g. a traceback
|
|
22590
22385
|
* followed by silence — no closing line ever arrives to complete it). */
|
|
22591
22386
|
const DETECTOR_FLUSH_DEBOUNCE_MS = 1500;
|
|
22592
22387
|
const BACKOFF_INITIAL_MS = 1e3;
|
|
22593
22388
|
const BACKOFF_MAX_MS = 3e4;
|
|
22594
22389
|
const STABLE_UPTIME_MS = 6e4;
|
|
22390
|
+
/**
|
|
22391
|
+
* Max time a NON-crash restart waits for an in-flight chat turn to finish
|
|
22392
|
+
* before it forces the stop anyway. A turn that outlives this is almost
|
|
22393
|
+
* certainly wedged; a real reply streams in far less. Long enough to cover a
|
|
22394
|
+
* normal multi-tool turn, short enough that a stuck turn doesn't block a
|
|
22395
|
+
* platform-pushed `daemon.update` indefinitely.
|
|
22396
|
+
*/
|
|
22397
|
+
const DRAIN_GRACE_MS = 45e3;
|
|
22595
22398
|
var RuntimeProcess = class {
|
|
22596
22399
|
child = null;
|
|
22597
22400
|
stopped = false;
|
|
@@ -22602,10 +22405,20 @@ var RuntimeProcess = class {
|
|
|
22602
22405
|
detector = new ErrorLineDetector();
|
|
22603
22406
|
throttle = new CaptureThrottle(STABLE_UPTIME_MS);
|
|
22604
22407
|
detectorFlushTimer = null;
|
|
22408
|
+
turnActivityProbe = null;
|
|
22605
22409
|
constructor(options) {
|
|
22606
22410
|
this.options = options;
|
|
22607
22411
|
}
|
|
22608
22412
|
/**
|
|
22413
|
+
* Attach the turn-activity probe used by drained restarts. The daemon calls
|
|
22414
|
+
* this after the IPC server is up (plugins register there). Without it,
|
|
22415
|
+
* {@link stopWhenIdle} and drained {@link restart} fall back to an immediate
|
|
22416
|
+
* stop — safe, just not turn-aware.
|
|
22417
|
+
*/
|
|
22418
|
+
setTurnActivityProbe(probe) {
|
|
22419
|
+
this.turnActivityProbe = probe;
|
|
22420
|
+
}
|
|
22421
|
+
/**
|
|
22609
22422
|
* Resolve the binary command and args for a given runtime type.
|
|
22610
22423
|
*/
|
|
22611
22424
|
resolveCommand() {
|
|
@@ -22622,6 +22435,10 @@ var RuntimeProcess = class {
|
|
|
22622
22435
|
command: "hermes",
|
|
22623
22436
|
args: ["gateway", "run"]
|
|
22624
22437
|
};
|
|
22438
|
+
case "claude-code": return {
|
|
22439
|
+
command: "alfe-claude-host",
|
|
22440
|
+
args: ["run"]
|
|
22441
|
+
};
|
|
22625
22442
|
default: throw new Error(`Unsupported runtime: ${this.options.runtime}`);
|
|
22626
22443
|
}
|
|
22627
22444
|
}
|
|
@@ -22632,7 +22449,7 @@ var RuntimeProcess = class {
|
|
|
22632
22449
|
if (this.stopped) return;
|
|
22633
22450
|
const { command, args } = this.resolveCommand();
|
|
22634
22451
|
this.lastStartTime = Date.now();
|
|
22635
|
-
log$
|
|
22452
|
+
log$3.info({
|
|
22636
22453
|
runtime: this.options.runtime,
|
|
22637
22454
|
command,
|
|
22638
22455
|
args,
|
|
@@ -22653,7 +22470,7 @@ var RuntimeProcess = class {
|
|
|
22653
22470
|
this.child.stdout?.on("data", (data) => {
|
|
22654
22471
|
const lines = data.toString().trim().split("\n");
|
|
22655
22472
|
for (const line of lines) {
|
|
22656
|
-
log$
|
|
22473
|
+
log$3.info({
|
|
22657
22474
|
runtime: this.options.runtime,
|
|
22658
22475
|
stream: "stdout"
|
|
22659
22476
|
}, line);
|
|
@@ -22663,7 +22480,7 @@ var RuntimeProcess = class {
|
|
|
22663
22480
|
this.child.stderr?.on("data", (data) => {
|
|
22664
22481
|
const lines = data.toString().trim().split("\n");
|
|
22665
22482
|
for (const line of lines) {
|
|
22666
|
-
log$
|
|
22483
|
+
log$3.warn({
|
|
22667
22484
|
runtime: this.options.runtime,
|
|
22668
22485
|
stream: "stderr"
|
|
22669
22486
|
}, line);
|
|
@@ -22673,7 +22490,7 @@ var RuntimeProcess = class {
|
|
|
22673
22490
|
this.child.on("exit", (code, signal) => {
|
|
22674
22491
|
this.child = null;
|
|
22675
22492
|
if (this.stopped) {
|
|
22676
|
-
log$
|
|
22493
|
+
log$3.info({
|
|
22677
22494
|
runtime: this.options.runtime,
|
|
22678
22495
|
code,
|
|
22679
22496
|
signal
|
|
@@ -22681,7 +22498,7 @@ var RuntimeProcess = class {
|
|
|
22681
22498
|
return;
|
|
22682
22499
|
}
|
|
22683
22500
|
if (code === 0 && signal == null) {
|
|
22684
|
-
log$
|
|
22501
|
+
log$3.info({
|
|
22685
22502
|
runtime: this.options.runtime,
|
|
22686
22503
|
code
|
|
22687
22504
|
}, "Runtime exited gracefully — restarting");
|
|
@@ -22691,7 +22508,7 @@ var RuntimeProcess = class {
|
|
|
22691
22508
|
}, 500);
|
|
22692
22509
|
return;
|
|
22693
22510
|
}
|
|
22694
|
-
log$
|
|
22511
|
+
log$3.warn({
|
|
22695
22512
|
runtime: this.options.runtime,
|
|
22696
22513
|
code,
|
|
22697
22514
|
signal,
|
|
@@ -22712,7 +22529,7 @@ var RuntimeProcess = class {
|
|
|
22712
22529
|
recentOutput: this.ringBuffer.snapshot(),
|
|
22713
22530
|
crashesSuppressed: crash.suppressedCount
|
|
22714
22531
|
});
|
|
22715
|
-
else log$
|
|
22532
|
+
else log$3.debug({
|
|
22716
22533
|
runtime: this.options.runtime,
|
|
22717
22534
|
suppressed: crash.suppressedCount
|
|
22718
22535
|
}, "Crash capture suppressed by throttle");
|
|
@@ -22724,7 +22541,7 @@ var RuntimeProcess = class {
|
|
|
22724
22541
|
this.backoffMs = Math.min(this.backoffMs * 2, BACKOFF_MAX_MS);
|
|
22725
22542
|
});
|
|
22726
22543
|
this.child.on("error", (err) => {
|
|
22727
|
-
log$
|
|
22544
|
+
log$3.error({
|
|
22728
22545
|
runtime: this.options.runtime,
|
|
22729
22546
|
err: err.message
|
|
22730
22547
|
}, "Runtime process error");
|
|
@@ -22763,7 +22580,7 @@ var RuntimeProcess = class {
|
|
|
22763
22580
|
this.emitErrorBlock(this.detector.flush());
|
|
22764
22581
|
}, DETECTOR_FLUSH_DEBOUNCE_MS);
|
|
22765
22582
|
} catch (err) {
|
|
22766
|
-
log$
|
|
22583
|
+
log$3.debug({
|
|
22767
22584
|
runtime: this.options.runtime,
|
|
22768
22585
|
err: err instanceof Error ? err.message : String(err)
|
|
22769
22586
|
}, "Runtime output observation failed");
|
|
@@ -22774,7 +22591,7 @@ var RuntimeProcess = class {
|
|
|
22774
22591
|
if (!block) return;
|
|
22775
22592
|
const { allow, suppressedCount } = this.throttle.allowErrorCapture(block.fingerprintKey);
|
|
22776
22593
|
if (!allow) {
|
|
22777
|
-
log$
|
|
22594
|
+
log$3.debug({
|
|
22778
22595
|
runtime: this.options.runtime,
|
|
22779
22596
|
kind: block.kind,
|
|
22780
22597
|
suppressed: suppressedCount
|
|
@@ -22808,7 +22625,7 @@ var RuntimeProcess = class {
|
|
|
22808
22625
|
if (!child) return;
|
|
22809
22626
|
return new Promise((resolve) => {
|
|
22810
22627
|
const killTimer = setTimeout(() => {
|
|
22811
|
-
log$
|
|
22628
|
+
log$3.warn({ runtime: this.options.runtime }, "Runtime did not exit in time — sending SIGKILL");
|
|
22812
22629
|
child.kill("SIGKILL");
|
|
22813
22630
|
}, 5e3);
|
|
22814
22631
|
child.on("exit", () => {
|
|
@@ -22819,6 +22636,54 @@ var RuntimeProcess = class {
|
|
|
22819
22636
|
});
|
|
22820
22637
|
}
|
|
22821
22638
|
/**
|
|
22639
|
+
* Stop the runtime, but first wait for any in-flight chat turn to finish
|
|
22640
|
+
* (up to a grace deadline). This is the entry point for NON-crash restarts —
|
|
22641
|
+
* a mid-turn SIGTERM turns a healthy reply into an "unknown error" for the
|
|
22642
|
+
* user, so a planned stop drains first.
|
|
22643
|
+
*
|
|
22644
|
+
* Semantics:
|
|
22645
|
+
* - No probe attached, or probe reports idle up-front → stops immediately
|
|
22646
|
+
* (no added latency for the common case).
|
|
22647
|
+
* - A turn is in flight → polls the probe every {@link DRAIN_POLL_INTERVAL_MS}
|
|
22648
|
+
* until it reports idle, then stops.
|
|
22649
|
+
* - The turn never ends within `graceMs` → logs and forces the stop anyway
|
|
22650
|
+
* (a wedged turn must not block a platform-pushed restart forever).
|
|
22651
|
+
*
|
|
22652
|
+
* Crash restarts must NOT call this — the child is already dead/broken and
|
|
22653
|
+
* the probe would just burn the grace window. They keep using {@link stop}.
|
|
22654
|
+
*/
|
|
22655
|
+
async stopWhenIdle(opts) {
|
|
22656
|
+
const probe = opts?.probe ?? this.turnActivityProbe;
|
|
22657
|
+
const graceMs = opts?.graceMs ?? 45e3;
|
|
22658
|
+
if (!this.child || !probe) {
|
|
22659
|
+
await this.stop();
|
|
22660
|
+
return;
|
|
22661
|
+
}
|
|
22662
|
+
const deadline = Date.now() + graceMs;
|
|
22663
|
+
let deferred = false;
|
|
22664
|
+
for (;;) {
|
|
22665
|
+
if (!await probe.isTurnActive()) break;
|
|
22666
|
+
if (Date.now() >= deadline) {
|
|
22667
|
+
log$3.warn({
|
|
22668
|
+
runtime: this.options.runtime,
|
|
22669
|
+
graceMs
|
|
22670
|
+
}, "Runtime still mid-turn at drain grace deadline — forcing stop");
|
|
22671
|
+
break;
|
|
22672
|
+
}
|
|
22673
|
+
if (!deferred) {
|
|
22674
|
+
deferred = true;
|
|
22675
|
+
log$3.info({
|
|
22676
|
+
runtime: this.options.runtime,
|
|
22677
|
+
graceMs
|
|
22678
|
+
}, "Runtime is mid-turn — deferring planned stop until idle");
|
|
22679
|
+
}
|
|
22680
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
22681
|
+
if (!this.isRunning) return;
|
|
22682
|
+
}
|
|
22683
|
+
if (deferred) log$3.info({ runtime: this.options.runtime }, "Runtime idle — proceeding with planned stop");
|
|
22684
|
+
await this.stop();
|
|
22685
|
+
}
|
|
22686
|
+
/**
|
|
22822
22687
|
* Resume after a suspend (stop). `start()` alone is a no-op while stopped
|
|
22823
22688
|
* because `stop()` latches `this.stopped = true`; resume clears that latch,
|
|
22824
22689
|
* resets the crash backoff, and re-spawns. Used by the RuntimeGate to bring
|
|
@@ -22831,10 +22696,20 @@ var RuntimeProcess = class {
|
|
|
22831
22696
|
}
|
|
22832
22697
|
/**
|
|
22833
22698
|
* Restart the runtime process (stop then start with fresh backoff).
|
|
22699
|
+
*
|
|
22700
|
+
* This is a PLANNED restart (`runtime.restart`, post-upgrade cycle, gate
|
|
22701
|
+
* request), so by default it drains any in-flight chat turn before stopping
|
|
22702
|
+
* — see {@link stopWhenIdle}. Pass `{ drain: false }` to force an immediate
|
|
22703
|
+
* stop (e.g. when the caller has already established the child is unhealthy).
|
|
22834
22704
|
*/
|
|
22835
|
-
async restart() {
|
|
22836
|
-
|
|
22837
|
-
|
|
22705
|
+
async restart(opts) {
|
|
22706
|
+
const drain = opts?.drain ?? true;
|
|
22707
|
+
log$3.info({
|
|
22708
|
+
runtime: this.options.runtime,
|
|
22709
|
+
drain
|
|
22710
|
+
}, "Restarting runtime...");
|
|
22711
|
+
if (drain) await this.stopWhenIdle();
|
|
22712
|
+
else await this.stop();
|
|
22838
22713
|
this.stopped = false;
|
|
22839
22714
|
this.backoffMs = BACKOFF_INITIAL_MS;
|
|
22840
22715
|
this.start();
|
|
@@ -22847,6 +22722,78 @@ var RuntimeProcess = class {
|
|
|
22847
22722
|
}
|
|
22848
22723
|
};
|
|
22849
22724
|
//#endregion
|
|
22725
|
+
//#region src/turn-activity.ts
|
|
22726
|
+
/**
|
|
22727
|
+
* Turn-activity probe — lets a NON-crash runtime restart wait for the runtime
|
|
22728
|
+
* to finish any in-flight chat turn before the child is SIGTERM'd.
|
|
22729
|
+
*
|
|
22730
|
+
* ## Why this exists
|
|
22731
|
+
*
|
|
22732
|
+
* Every non-crash restart path (`runtime.restart`, `daemon.update`,
|
|
22733
|
+
* `daemon.restart`, the RuntimeGate resume after a mutating reconcile) used to
|
|
22734
|
+
* route straight into {@link RuntimeProcess.stop}, which is SIGTERM →
|
|
22735
|
+
* SIGKILL-after-5s with zero turn-awareness. A platform-pushed `daemon.update`
|
|
22736
|
+
* that landed mid-turn killed the runtime child while it was streaming a reply,
|
|
22737
|
+
* and the user saw an "unknown error" for a turn that was otherwise healthy.
|
|
22738
|
+
*
|
|
22739
|
+
* The fix is a small "is a turn active?" signal that the daemon can consult
|
|
22740
|
+
* before a *planned* stop, deferring the SIGTERM until the runtime reports idle
|
|
22741
|
+
* (or a grace deadline elapses). Crash restarts do NOT consult it — the child is
|
|
22742
|
+
* already dead or wedged, so there is nothing to drain.
|
|
22743
|
+
*
|
|
22744
|
+
* ## The runtime-side contract (the boundary)
|
|
22745
|
+
*
|
|
22746
|
+
* The runtime's activity is a property of the chat plugin's active-turn state,
|
|
22747
|
+
* which lives INSIDE the runtime child (e.g. `openclaw-chat`'s per-turn run
|
|
22748
|
+
* gate). The daemon cannot see it directly. The daemon reaches it over the same
|
|
22749
|
+
* IPC socket the plugin already uses to register:
|
|
22750
|
+
*
|
|
22751
|
+
* daemon → plugin request: { method: 'runtime.activity' }
|
|
22752
|
+
* plugin → daemon response: { ok: true, payload: { active: boolean } }
|
|
22753
|
+
*
|
|
22754
|
+
* `runtime.activity` is on the daemon→plugin method allowlist (see
|
|
22755
|
+
* protocol.ts). A plugin answers `active: true` while it is dispatching a turn
|
|
22756
|
+
* and `active: false` otherwise. This is the ONE thing the runtime must provide;
|
|
22757
|
+
* everything else in the drain lives on the gateway side.
|
|
22758
|
+
*
|
|
22759
|
+
* ## Fail-open to idle
|
|
22760
|
+
*
|
|
22761
|
+
* If NO connected plugin answers `runtime.activity` (older plugin build, the
|
|
22762
|
+
* chat plugin isn't loaded, the request times out), the probe reports **idle**.
|
|
22763
|
+
* A planned restart must never hang forever waiting on a signal that will never
|
|
22764
|
+
* arrive — the grace deadline is a backstop, but fail-open-to-idle means the
|
|
22765
|
+
* common "nothing to drain" case restarts immediately with no added latency.
|
|
22766
|
+
*/
|
|
22767
|
+
const log$2 = logger$1.child({ component: "TurnActivity" });
|
|
22768
|
+
/** The IPC method the daemon calls on plugins to ask "is a turn active?". */
|
|
22769
|
+
const RUNTIME_ACTIVITY_METHOD = "runtime.activity";
|
|
22770
|
+
/**
|
|
22771
|
+
* IPC-backed probe. Broadcasts `runtime.activity` to every registered plugin
|
|
22772
|
+
* and reports active if ANY plugin claims an in-flight turn. Fails open to idle
|
|
22773
|
+
* on any error / timeout / non-answer so a planned restart is never wedged.
|
|
22774
|
+
*/
|
|
22775
|
+
var IpcTurnActivityProbe = class {
|
|
22776
|
+
constructor(getIpcServer, probeTimeoutMs = 2e3) {
|
|
22777
|
+
this.getIpcServer = getIpcServer;
|
|
22778
|
+
this.probeTimeoutMs = probeTimeoutMs;
|
|
22779
|
+
}
|
|
22780
|
+
async isTurnActive() {
|
|
22781
|
+
const ipc = this.getIpcServer();
|
|
22782
|
+
if (!ipc || ipc.registeredCount === 0) return false;
|
|
22783
|
+
try {
|
|
22784
|
+
const responses = await ipc.broadcastRequest(RUNTIME_ACTIVITY_METHOD, {}, this.probeTimeoutMs);
|
|
22785
|
+
for (const [pluginId, res] of responses) if (res.ok && res.payload != null && typeof res.payload === "object" && res.payload.active === true) {
|
|
22786
|
+
log$2.debug({ pluginId }, "Plugin reports an in-flight turn");
|
|
22787
|
+
return true;
|
|
22788
|
+
}
|
|
22789
|
+
return false;
|
|
22790
|
+
} catch (err) {
|
|
22791
|
+
log$2.debug({ err: err instanceof Error ? err.message : String(err) }, "Turn-activity probe failed — assuming idle");
|
|
22792
|
+
return false;
|
|
22793
|
+
}
|
|
22794
|
+
}
|
|
22795
|
+
};
|
|
22796
|
+
//#endregion
|
|
22850
22797
|
//#region src/command-registry.ts
|
|
22851
22798
|
/**
|
|
22852
22799
|
* Command Registry — maps integration command names to local handler files.
|
|
@@ -23475,8 +23422,16 @@ let mcpManagerRef = null;
|
|
|
23475
23422
|
* and for managed/self-hosted runtimes other than hermes.
|
|
23476
23423
|
*/
|
|
23477
23424
|
let hermesMcpSync = null;
|
|
23425
|
+
/**
|
|
23426
|
+
* claude-code-only MCP store consumer. Mirrors the runtime-agnostic MCP store
|
|
23427
|
+
* into the host's `--mcp-config` JSON (~/.claude-code/mcp-config.json), injecting
|
|
23428
|
+
* ALFE_API_KEY into every stdio server's env. Null for every other runtime
|
|
23429
|
+
* (never constructed) — same shape/role as {@link hermesMcpSync}.
|
|
23430
|
+
*/
|
|
23431
|
+
let claudeCodeMcpSync = null;
|
|
23478
23432
|
let aiProxyServer = null;
|
|
23479
23433
|
let runtimeProcess = null;
|
|
23434
|
+
let turnActivityProbe = null;
|
|
23480
23435
|
let aiProxyUrl = null;
|
|
23481
23436
|
let aiProxyRunning = false;
|
|
23482
23437
|
let cloudConnected = false;
|
|
@@ -23587,6 +23542,10 @@ const RUNTIME_VERSION_COMMANDS = {
|
|
|
23587
23542
|
hermes: {
|
|
23588
23543
|
command: "hermes",
|
|
23589
23544
|
args: ["version"]
|
|
23545
|
+
},
|
|
23546
|
+
"claude-code": {
|
|
23547
|
+
command: "alfe-claude-host",
|
|
23548
|
+
args: ["--version"]
|
|
23590
23549
|
}
|
|
23591
23550
|
};
|
|
23592
23551
|
/**
|
|
@@ -23770,6 +23729,7 @@ async function startDaemon() {
|
|
|
23770
23729
|
logger$1.debug({ socketPath: config.socketPath }, "Starting IPC server...");
|
|
23771
23730
|
ipcServer = new IPCServer(config.socketPath);
|
|
23772
23731
|
ipcServer.setRequestHandler(handlePluginRequest);
|
|
23732
|
+
turnActivityProbe = new IpcTurnActivityProbe(() => ipcServer);
|
|
23773
23733
|
try {
|
|
23774
23734
|
await ipcServer.start();
|
|
23775
23735
|
logger$1.debug("IPC server started");
|
|
@@ -23824,6 +23784,13 @@ async function startDaemon() {
|
|
|
23824
23784
|
runtime: name,
|
|
23825
23785
|
home: runtimeCfg.workspace
|
|
23826
23786
|
}, "Registered Hermes runtime applier");
|
|
23787
|
+
} else if (name === "claude-code") {
|
|
23788
|
+
runtimeAppliers.set(name, new ClaudeCodeApplier({ home: runtimeCfg.workspace }));
|
|
23789
|
+
logger$1.info({
|
|
23790
|
+
runtime: name,
|
|
23791
|
+
home: runtimeCfg.workspace,
|
|
23792
|
+
agentWorkspace: runtimeCfg.agentWorkspace
|
|
23793
|
+
}, "Registered Claude Code runtime applier");
|
|
23827
23794
|
} else logger$1.warn({ runtime: name }, "Unknown runtime type — skipping");
|
|
23828
23795
|
runtimeAppliersRef = runtimeAppliers;
|
|
23829
23796
|
const integrationsService = new IntegrationsService(new AlfeApiClient({
|
|
@@ -23842,7 +23809,7 @@ async function startDaemon() {
|
|
|
23842
23809
|
idleTtlMs: 0,
|
|
23843
23810
|
...mcpErrorHooks
|
|
23844
23811
|
}, { connect: defaultConnect });
|
|
23845
|
-
if (config.runtime === "hermes") logger$1.info(
|
|
23812
|
+
if (config.runtime === "hermes" || config.runtime === "claude-code") logger$1.info({ runtime: config.runtime }, "MCP store mirrored into runtime config by the runtime MCP sync — daemon MCP bundler left idle");
|
|
23846
23813
|
else {
|
|
23847
23814
|
await mcpManager.loadIntoBundler(mcpBundler);
|
|
23848
23815
|
const warmBundler = (reason) => {
|
|
@@ -23901,6 +23868,18 @@ async function startDaemon() {
|
|
|
23901
23868
|
hermesMcpSync.start();
|
|
23902
23869
|
}
|
|
23903
23870
|
}
|
|
23871
|
+
if (config.runtime === "claude-code") {
|
|
23872
|
+
const ccCfg = config.runtimes[config.runtime];
|
|
23873
|
+
if (ccCfg) {
|
|
23874
|
+
claudeCodeMcpSync = new ClaudeCodeMcpSync({
|
|
23875
|
+
manager: mcpManager,
|
|
23876
|
+
home: ccCfg.workspace,
|
|
23877
|
+
apiKey: config.apiKey,
|
|
23878
|
+
requestRestart: requestRuntimeRestart
|
|
23879
|
+
});
|
|
23880
|
+
claudeCodeMcpSync.start();
|
|
23881
|
+
}
|
|
23882
|
+
}
|
|
23904
23883
|
cloudClient.setOnReconciliationComplete(() => {
|
|
23905
23884
|
try {
|
|
23906
23885
|
const activeCommands = integrationManager.getActiveCommands();
|
|
@@ -23933,6 +23912,7 @@ async function startDaemon() {
|
|
|
23933
23912
|
workspace: runtimeCfg.workspace,
|
|
23934
23913
|
env: {}
|
|
23935
23914
|
});
|
|
23915
|
+
runtimeProcess.setTurnActivityProbe(turnActivityProbe);
|
|
23936
23916
|
runtimeProcess.start();
|
|
23937
23917
|
logger$1.debug("Runtime process started");
|
|
23938
23918
|
if (config.runtime === "openclaw") {
|
|
@@ -23963,6 +23943,10 @@ async function startDaemon() {
|
|
|
23963
23943
|
hermesMcpSync.stop();
|
|
23964
23944
|
hermesMcpSync = null;
|
|
23965
23945
|
}
|
|
23946
|
+
if (claudeCodeMcpSync) {
|
|
23947
|
+
claudeCodeMcpSync.stop();
|
|
23948
|
+
claudeCodeMcpSync = null;
|
|
23949
|
+
}
|
|
23966
23950
|
if (mcpBundler) {
|
|
23967
23951
|
logger$1.debug("Stopping MCP bundler...");
|
|
23968
23952
|
await mcpBundler.dispose();
|
|
@@ -24038,11 +24022,46 @@ async function handleCloudCommand(command) {
|
|
|
24038
24022
|
}
|
|
24039
24023
|
return ack;
|
|
24040
24024
|
}
|
|
24025
|
+
/**
|
|
24026
|
+
* Wait for any in-flight chat turn to finish before a daemon PROCESS exit
|
|
24027
|
+
* (daemon.update / daemon.restart). Those paths don't stop the runtime via
|
|
24028
|
+
* `RuntimeProcess.stop` — they exit the daemon, and systemd/launchd cgroup-kill
|
|
24029
|
+
* the runtime child underneath. That kill is just as turn-destroying as a
|
|
24030
|
+
* SIGTERM, so we drain here too. Grace-deadline backstopped; fails open to
|
|
24031
|
+
* "proceed" so a wedged turn can't strand a platform-pushed update.
|
|
24032
|
+
*/
|
|
24033
|
+
async function drainRuntimeBeforeExit(reason) {
|
|
24034
|
+
const probe = turnActivityProbe;
|
|
24035
|
+
if (!probe || !runtimeProcess?.isRunning) return;
|
|
24036
|
+
const deadline = Date.now() + DRAIN_GRACE_MS;
|
|
24037
|
+
let deferred = false;
|
|
24038
|
+
while (Date.now() < deadline) {
|
|
24039
|
+
let active;
|
|
24040
|
+
try {
|
|
24041
|
+
active = await probe.isTurnActive();
|
|
24042
|
+
} catch {
|
|
24043
|
+
return;
|
|
24044
|
+
}
|
|
24045
|
+
if (!active) {
|
|
24046
|
+
if (deferred) logger$1.info({ reason }, "Runtime idle — proceeding with planned daemon exit");
|
|
24047
|
+
return;
|
|
24048
|
+
}
|
|
24049
|
+
if (!deferred) {
|
|
24050
|
+
deferred = true;
|
|
24051
|
+
logger$1.info({ reason }, "Runtime is mid-turn — deferring planned daemon exit until idle");
|
|
24052
|
+
}
|
|
24053
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
24054
|
+
}
|
|
24055
|
+
logger$1.warn({
|
|
24056
|
+
reason,
|
|
24057
|
+
graceMs: DRAIN_GRACE_MS
|
|
24058
|
+
}, "Runtime still mid-turn at drain grace deadline — proceeding with daemon exit");
|
|
24059
|
+
}
|
|
24041
24060
|
async function executeCloudCommand(command) {
|
|
24042
24061
|
if (command.command === "daemon.update") {
|
|
24043
24062
|
const version = command.payload?.version ?? "latest";
|
|
24044
24063
|
setTimeout(() => {
|
|
24045
|
-
import("./upgrade.js").then(({ upgradeAndExit }) => upgradeAndExit(version)).catch((err) => {
|
|
24064
|
+
drainRuntimeBeforeExit("daemon.update").then(() => import("./upgrade.js")).then(({ upgradeAndExit }) => upgradeAndExit(version)).catch((err) => {
|
|
24046
24065
|
logger$1.error({ err: err instanceof Error ? err.message : String(err) }, "Upgrade failed");
|
|
24047
24066
|
process.exit(1);
|
|
24048
24067
|
});
|
|
@@ -24060,7 +24079,9 @@ async function executeCloudCommand(command) {
|
|
|
24060
24079
|
if (command.command === "daemon.restart") {
|
|
24061
24080
|
logger$1.info("Restart requested — exiting for systemd restart");
|
|
24062
24081
|
setTimeout(() => {
|
|
24063
|
-
|
|
24082
|
+
drainRuntimeBeforeExit("daemon.restart").then(() => {
|
|
24083
|
+
process.exit(0);
|
|
24084
|
+
});
|
|
24064
24085
|
}, 500);
|
|
24065
24086
|
return {
|
|
24066
24087
|
type: "COMMAND_ACK",
|
|
@@ -24437,17 +24458,41 @@ function handleMcpListServers(manager = mcpManagerRef) {
|
|
|
24437
24458
|
message: "MCP manager not initialized"
|
|
24438
24459
|
}
|
|
24439
24460
|
};
|
|
24461
|
+
const statuses = manager.serverStatuses ? manager.serverStatuses() : [];
|
|
24462
|
+
const byName = new Map(statuses.map((s) => [s.name, s]));
|
|
24440
24463
|
return {
|
|
24441
24464
|
ok: true,
|
|
24442
|
-
payload: { servers: manager.listServers() }
|
|
24465
|
+
payload: { servers: manager.listServers().map(({ id, entry }) => ({
|
|
24466
|
+
id,
|
|
24467
|
+
entry,
|
|
24468
|
+
status: byName.get(id) ?? {
|
|
24469
|
+
name: id,
|
|
24470
|
+
connected: false,
|
|
24471
|
+
toolCount: 0,
|
|
24472
|
+
consecutiveFailures: 0
|
|
24473
|
+
}
|
|
24474
|
+
})) }
|
|
24443
24475
|
};
|
|
24444
24476
|
}
|
|
24445
24477
|
/**
|
|
24446
|
-
*
|
|
24447
|
-
*
|
|
24448
|
-
*
|
|
24449
|
-
*
|
|
24450
|
-
*
|
|
24478
|
+
* How long the add-confirm probe waits for the freshly-added server to
|
|
24479
|
+
* connect before replying. This is effectively the whole budget for
|
|
24480
|
+
* `bundler.warmServer`: the `Manager.warmServer` pre-reconcile is cheap here
|
|
24481
|
+
* because `addServer` already queued the Connection object into the store +
|
|
24482
|
+
* bundler before we probe, so `reconcileBundler()` finds no diff and returns
|
|
24483
|
+
* fast. Kept well under the plugin caller's 30s IPC timeout. A slower server
|
|
24484
|
+
* still connects in the background and surfaces on the next
|
|
24485
|
+
* `alfe_mcp_list_tools` — the probe just reports what it saw within the window.
|
|
24486
|
+
*/
|
|
24487
|
+
const MCP_ADD_WARM_TIMEOUT_MS = 12e3;
|
|
24488
|
+
/**
|
|
24489
|
+
* Register a new MCP server in the alfe bundler store on behalf of the agent,
|
|
24490
|
+
* then CONFIRM the connect before replying so the agent learns whether the
|
|
24491
|
+
* server actually works. Owned as `'manual'` so the agent can later remove it
|
|
24492
|
+
* without an expectedOwner conflict — matches what `alfe mcp add` does from the
|
|
24493
|
+
* CLI. Registration is durable regardless of the probe outcome: a probe
|
|
24494
|
+
* failure (or a runtime with no daemon bundler, e.g. hermes) still returns
|
|
24495
|
+
* `ok` with `connected: false`; the store watcher / runtime picks the entry up.
|
|
24451
24496
|
*/
|
|
24452
24497
|
async function handleMcpAddServer(params, manager = mcpManagerRef) {
|
|
24453
24498
|
if (!manager) return {
|
|
@@ -24478,10 +24523,6 @@ async function handleMcpAddServer(params, manager = mcpManagerRef) {
|
|
|
24478
24523
|
id: p.id,
|
|
24479
24524
|
owner: "manual"
|
|
24480
24525
|
});
|
|
24481
|
-
return {
|
|
24482
|
-
ok: true,
|
|
24483
|
-
payload: { id: p.id }
|
|
24484
|
-
};
|
|
24485
24526
|
} catch (err) {
|
|
24486
24527
|
const message = err instanceof Error ? err.message : String(err);
|
|
24487
24528
|
logger$1.warn({
|
|
@@ -24496,6 +24537,24 @@ async function handleMcpAddServer(params, manager = mcpManagerRef) {
|
|
|
24496
24537
|
}
|
|
24497
24538
|
};
|
|
24498
24539
|
}
|
|
24540
|
+
let status = null;
|
|
24541
|
+
if (manager.warmServer) try {
|
|
24542
|
+
status = await manager.warmServer(p.id, MCP_ADD_WARM_TIMEOUT_MS);
|
|
24543
|
+
} catch (err) {
|
|
24544
|
+
logger$1.warn({
|
|
24545
|
+
id: p.id,
|
|
24546
|
+
err: err instanceof Error ? err.message : String(err)
|
|
24547
|
+
}, "mcp.add_server warm probe threw");
|
|
24548
|
+
}
|
|
24549
|
+
return {
|
|
24550
|
+
ok: true,
|
|
24551
|
+
payload: {
|
|
24552
|
+
id: p.id,
|
|
24553
|
+
connected: status?.connected ?? false,
|
|
24554
|
+
toolCount: status?.toolCount ?? 0,
|
|
24555
|
+
...status?.lastError !== void 0 ? { error: status.lastError } : {}
|
|
24556
|
+
}
|
|
24557
|
+
};
|
|
24499
24558
|
}
|
|
24500
24559
|
function buildServerConfig(p) {
|
|
24501
24560
|
if (typeof p.command === "string" && p.command.length > 0) {
|
|
@@ -24721,4 +24780,4 @@ function formatDuration(ms) {
|
|
|
24721
24780
|
return `${String(Math.round(seconds / 3600))}h`;
|
|
24722
24781
|
}
|
|
24723
24782
|
//#endregion
|
|
24724
|
-
export {
|
|
24783
|
+
export { installService as a, uninstallService as c, PID_PATH as d, SOCKET_PATH as f, PINNED_OPENCLAW_VERSION as g, resolveAgentIdentity as h, checkExistingDaemon as i, PROTOCOL_VERSION as l, loadDaemonConfig as m, queryDaemonHealth as n, startService as o, fetchAgentConfig as p, startDaemon as r, stopExistingDaemon as s, formatHealthReport as t, ALFE_DIR as u };
|