@lobu/gateway 3.0.5 → 3.0.6
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/package.json +2 -2
- package/src/__tests__/agent-config-routes.test.ts +254 -0
- package/src/__tests__/agent-history-routes.test.ts +72 -0
- package/src/__tests__/agent-routes.test.ts +68 -0
- package/src/__tests__/agent-schedules-routes.test.ts +59 -0
- package/src/__tests__/agent-settings-store.test.ts +323 -0
- package/src/__tests__/chat-instance-manager-slack.test.ts +204 -0
- package/src/__tests__/chat-response-bridge.test.ts +131 -0
- package/src/__tests__/config-memory-plugins.test.ts +92 -0
- package/src/__tests__/config-request-store.test.ts +127 -0
- package/src/__tests__/connection-routes.test.ts +144 -0
- package/src/__tests__/core-services-store-selection.test.ts +92 -0
- package/src/__tests__/docker-deployment.test.ts +1211 -0
- package/src/__tests__/embedded-deployment.test.ts +342 -0
- package/src/__tests__/grant-store.test.ts +148 -0
- package/src/__tests__/http-proxy.test.ts +281 -0
- package/src/__tests__/instruction-service.test.ts +37 -0
- package/src/__tests__/link-buttons.test.ts +112 -0
- package/src/__tests__/lobu.test.ts +32 -0
- package/src/__tests__/mcp-config-service.test.ts +347 -0
- package/src/__tests__/mcp-proxy.test.ts +696 -0
- package/src/__tests__/message-handler-bridge.test.ts +17 -0
- package/src/__tests__/model-selection.test.ts +172 -0
- package/src/__tests__/oauth-templates.test.ts +39 -0
- package/src/__tests__/platform-adapter-slack-send.test.ts +114 -0
- package/src/__tests__/platform-helpers-model-resolution.test.ts +253 -0
- package/src/__tests__/provider-inheritance.test.ts +212 -0
- package/src/__tests__/routes/cli-auth.test.ts +337 -0
- package/src/__tests__/routes/interactions.test.ts +121 -0
- package/src/__tests__/secret-proxy.test.ts +85 -0
- package/src/__tests__/session-manager.test.ts +572 -0
- package/src/__tests__/setup.ts +133 -0
- package/src/__tests__/skill-and-mcp-registry.test.ts +203 -0
- package/src/__tests__/slack-routes.test.ts +161 -0
- package/src/__tests__/system-config-resolver.test.ts +75 -0
- package/src/__tests__/system-message-limiter.test.ts +89 -0
- package/src/__tests__/system-skills-service.test.ts +362 -0
- package/src/__tests__/transcription-service.test.ts +222 -0
- package/src/__tests__/utils/rate-limiter.test.ts +102 -0
- package/src/__tests__/worker-connection-manager.test.ts +497 -0
- package/src/__tests__/worker-job-router.test.ts +722 -0
- package/src/api/index.ts +1 -0
- package/src/api/platform.ts +292 -0
- package/src/api/response-renderer.ts +157 -0
- package/src/auth/agent-metadata-store.ts +168 -0
- package/src/auth/api-auth-middleware.ts +69 -0
- package/src/auth/api-key-provider-module.ts +213 -0
- package/src/auth/base-provider-module.ts +201 -0
- package/src/auth/chatgpt/chatgpt-oauth-module.ts +185 -0
- package/src/auth/chatgpt/device-code-client.ts +218 -0
- package/src/auth/chatgpt/index.ts +1 -0
- package/src/auth/claude/oauth-module.ts +280 -0
- package/src/auth/cli/token-service.ts +249 -0
- package/src/auth/external/client.ts +560 -0
- package/src/auth/external/device-code-client.ts +225 -0
- package/src/auth/mcp/config-service.ts +392 -0
- package/src/auth/mcp/proxy.ts +1088 -0
- package/src/auth/mcp/string-substitution.ts +17 -0
- package/src/auth/mcp/tool-cache.ts +90 -0
- package/src/auth/oauth/base-client.ts +267 -0
- package/src/auth/oauth/client.ts +153 -0
- package/src/auth/oauth/credentials.ts +7 -0
- package/src/auth/oauth/providers.ts +69 -0
- package/src/auth/oauth/state-store.ts +150 -0
- package/src/auth/oauth-templates.ts +179 -0
- package/src/auth/provider-catalog.ts +220 -0
- package/src/auth/provider-model-options.ts +41 -0
- package/src/auth/settings/agent-settings-store.ts +565 -0
- package/src/auth/settings/auth-profiles-manager.ts +216 -0
- package/src/auth/settings/index.ts +12 -0
- package/src/auth/settings/model-preference-store.ts +52 -0
- package/src/auth/settings/model-selection.ts +135 -0
- package/src/auth/settings/resolved-settings-view.ts +298 -0
- package/src/auth/settings/template-utils.ts +44 -0
- package/src/auth/settings/token-service.ts +88 -0
- package/src/auth/system-env-store.ts +98 -0
- package/src/auth/user-agents-store.ts +68 -0
- package/src/channels/binding-service.ts +214 -0
- package/src/channels/index.ts +4 -0
- package/src/cli/gateway.ts +1304 -0
- package/src/cli/index.ts +74 -0
- package/src/commands/built-in-commands.ts +80 -0
- package/src/commands/command-dispatcher.ts +94 -0
- package/src/commands/command-reply-adapters.ts +27 -0
- package/src/config/file-loader.ts +618 -0
- package/src/config/index.ts +588 -0
- package/src/config/network-allowlist.ts +71 -0
- package/src/connections/chat-instance-manager.ts +1284 -0
- package/src/connections/chat-response-bridge.ts +618 -0
- package/src/connections/index.ts +7 -0
- package/src/connections/interaction-bridge.ts +831 -0
- package/src/connections/message-handler-bridge.ts +415 -0
- package/src/connections/platform-auth-methods.ts +15 -0
- package/src/connections/types.ts +84 -0
- package/src/gateway/connection-manager.ts +291 -0
- package/src/gateway/index.ts +700 -0
- package/src/gateway/job-router.ts +201 -0
- package/src/gateway-main.ts +200 -0
- package/src/index.ts +41 -0
- package/src/infrastructure/queue/index.ts +12 -0
- package/src/infrastructure/queue/queue-producer.ts +148 -0
- package/src/infrastructure/queue/redis-queue.ts +361 -0
- package/src/infrastructure/queue/types.ts +133 -0
- package/src/infrastructure/redis/system-message-limiter.ts +94 -0
- package/src/interactions/config-request-store.ts +198 -0
- package/src/interactions.ts +363 -0
- package/src/lobu.ts +311 -0
- package/src/metrics/prometheus.ts +159 -0
- package/src/modules/module-system.ts +179 -0
- package/src/orchestration/base-deployment-manager.ts +900 -0
- package/src/orchestration/deployment-utils.ts +98 -0
- package/src/orchestration/impl/docker-deployment.ts +620 -0
- package/src/orchestration/impl/embedded-deployment.ts +268 -0
- package/src/orchestration/impl/index.ts +8 -0
- package/src/orchestration/impl/k8s/deployment.ts +1061 -0
- package/src/orchestration/impl/k8s/helpers.ts +610 -0
- package/src/orchestration/impl/k8s/index.ts +1 -0
- package/src/orchestration/index.ts +333 -0
- package/src/orchestration/message-consumer.ts +584 -0
- package/src/orchestration/scheduled-wakeup.ts +704 -0
- package/src/permissions/approval-policy.ts +36 -0
- package/src/permissions/grant-store.ts +219 -0
- package/src/platform/file-handler.ts +66 -0
- package/src/platform/link-buttons.ts +57 -0
- package/src/platform/renderer-utils.ts +44 -0
- package/src/platform/response-renderer.ts +84 -0
- package/src/platform/unified-thread-consumer.ts +187 -0
- package/src/platform.ts +318 -0
- package/src/proxy/http-proxy.ts +752 -0
- package/src/proxy/proxy-manager.ts +81 -0
- package/src/proxy/secret-proxy.ts +402 -0
- package/src/proxy/token-refresh-job.ts +143 -0
- package/src/routes/internal/audio.ts +141 -0
- package/src/routes/internal/device-auth.ts +566 -0
- package/src/routes/internal/files.ts +226 -0
- package/src/routes/internal/history.ts +69 -0
- package/src/routes/internal/images.ts +127 -0
- package/src/routes/internal/interactions.ts +84 -0
- package/src/routes/internal/middleware.ts +23 -0
- package/src/routes/internal/schedule.ts +226 -0
- package/src/routes/internal/types.ts +22 -0
- package/src/routes/openapi-auto.ts +239 -0
- package/src/routes/public/agent-access.ts +23 -0
- package/src/routes/public/agent-config.ts +675 -0
- package/src/routes/public/agent-history.ts +422 -0
- package/src/routes/public/agent-schedules.ts +296 -0
- package/src/routes/public/agent.ts +1086 -0
- package/src/routes/public/agents.ts +373 -0
- package/src/routes/public/channels.ts +191 -0
- package/src/routes/public/cli-auth.ts +883 -0
- package/src/routes/public/connections.ts +574 -0
- package/src/routes/public/landing.ts +16 -0
- package/src/routes/public/oauth.ts +147 -0
- package/src/routes/public/settings-auth.ts +104 -0
- package/src/routes/public/slack.ts +173 -0
- package/src/routes/shared/agent-ownership.ts +101 -0
- package/src/routes/shared/token-verifier.ts +34 -0
- package/src/services/core-services.ts +1053 -0
- package/src/services/image-generation-service.ts +257 -0
- package/src/services/instruction-service.ts +318 -0
- package/src/services/mcp-registry.ts +94 -0
- package/src/services/platform-helpers.ts +287 -0
- package/src/services/session-manager.ts +262 -0
- package/src/services/settings-resolver.ts +74 -0
- package/src/services/system-config-resolver.ts +90 -0
- package/src/services/system-skills-service.ts +229 -0
- package/src/services/transcription-service.ts +684 -0
- package/src/session.ts +110 -0
- package/src/spaces/index.ts +1 -0
- package/src/spaces/space-resolver.ts +17 -0
- package/src/stores/in-memory-agent-store.ts +403 -0
- package/src/stores/redis-agent-store.ts +279 -0
- package/src/utils/public-url.ts +44 -0
- package/src/utils/rate-limiter.ts +94 -0
- package/tsconfig.json +33 -0
|
@@ -0,0 +1,900 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
createLogger,
|
|
4
|
+
ErrorCode,
|
|
5
|
+
extractTraceId,
|
|
6
|
+
generateWorkerToken,
|
|
7
|
+
OrchestratorError,
|
|
8
|
+
} from "@lobu/core";
|
|
9
|
+
import type Redis from "ioredis";
|
|
10
|
+
import type { MessagePayload } from "../infrastructure/queue/queue-producer";
|
|
11
|
+
import type { ModelProviderModule } from "../modules/module-system";
|
|
12
|
+
import type { GrantStore } from "../permissions/grant-store";
|
|
13
|
+
import {
|
|
14
|
+
deleteSecretMappings,
|
|
15
|
+
generatePlaceholder,
|
|
16
|
+
} from "../proxy/secret-proxy";
|
|
17
|
+
import { getScheduledWakeupService } from "./scheduled-wakeup";
|
|
18
|
+
|
|
19
|
+
// Re-export MessagePayload for use by deployment implementations
|
|
20
|
+
export type { MessagePayload };
|
|
21
|
+
|
|
22
|
+
const logger = createLogger("orchestrator");
|
|
23
|
+
|
|
24
|
+
export interface DeploymentIdentity {
|
|
25
|
+
conversationId: string;
|
|
26
|
+
channelId?: string;
|
|
27
|
+
platform?: string;
|
|
28
|
+
userId?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build a canonical conversation identity key for runtime routing.
|
|
33
|
+
* Preferred format: platform:channelId:conversationId
|
|
34
|
+
*/
|
|
35
|
+
export function buildCanonicalConversationKey(
|
|
36
|
+
identity: DeploymentIdentity
|
|
37
|
+
): string {
|
|
38
|
+
const { conversationId, channelId, platform } = identity;
|
|
39
|
+
if (platform && channelId) {
|
|
40
|
+
return `${platform}:${channelId}:${conversationId}`;
|
|
41
|
+
}
|
|
42
|
+
if (channelId) {
|
|
43
|
+
return `${channelId}:${conversationId}`;
|
|
44
|
+
}
|
|
45
|
+
return conversationId;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sanitizeNameHint(value: string | undefined, fallback: string): string {
|
|
49
|
+
const sanitized = (value || "").replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
|
|
50
|
+
return (sanitized.slice(0, 8) || fallback).toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Generate a consistent worker runtime ID from canonical conversation identity.
|
|
55
|
+
* Overload preserved for compatibility with older callers.
|
|
56
|
+
* K8s names must be lowercase alphanumeric with hyphens only.
|
|
57
|
+
*/
|
|
58
|
+
export function generateDeploymentName(
|
|
59
|
+
userId: string,
|
|
60
|
+
conversationId: string
|
|
61
|
+
): string;
|
|
62
|
+
export function generateDeploymentName(identity: DeploymentIdentity): string;
|
|
63
|
+
export function generateDeploymentName(
|
|
64
|
+
arg1: string | DeploymentIdentity,
|
|
65
|
+
arg2?: string
|
|
66
|
+
): string {
|
|
67
|
+
if (typeof arg1 === "string") {
|
|
68
|
+
const userId = arg1;
|
|
69
|
+
const conversationId = arg2 || "";
|
|
70
|
+
const shortHint = sanitizeNameHint(userId, "user");
|
|
71
|
+
const hash = createHash("sha256")
|
|
72
|
+
.update(`${userId}:${conversationId}`)
|
|
73
|
+
.digest("hex")
|
|
74
|
+
.slice(0, 12);
|
|
75
|
+
return `lobu-worker-${shortHint}-${hash}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const identity = arg1;
|
|
79
|
+
const canonicalKey = buildCanonicalConversationKey(identity);
|
|
80
|
+
const hint = sanitizeNameHint(identity.platform || identity.userId, "ctx");
|
|
81
|
+
const hash = createHash("sha256")
|
|
82
|
+
.update(canonicalKey)
|
|
83
|
+
.digest("hex")
|
|
84
|
+
.slice(0, 12);
|
|
85
|
+
return `lobu-worker-${hint}-${hash}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Type for module environment variable builder function
|
|
89
|
+
export type ModuleEnvVarsBuilder = (
|
|
90
|
+
agentId: string,
|
|
91
|
+
envVars: Record<string, string>
|
|
92
|
+
) => Promise<Record<string, string>>;
|
|
93
|
+
|
|
94
|
+
// Orchestrator configuration
|
|
95
|
+
export interface OrchestratorConfig {
|
|
96
|
+
deploymentMode?: "embedded" | "docker" | "kubernetes";
|
|
97
|
+
queues: {
|
|
98
|
+
connectionString: string;
|
|
99
|
+
retryLimit: number;
|
|
100
|
+
retryDelay: number;
|
|
101
|
+
expireInSeconds: number;
|
|
102
|
+
};
|
|
103
|
+
worker: {
|
|
104
|
+
image: {
|
|
105
|
+
repository: string;
|
|
106
|
+
tag: string;
|
|
107
|
+
digest?: string;
|
|
108
|
+
pullPolicy: string;
|
|
109
|
+
};
|
|
110
|
+
serviceAccountName?: string;
|
|
111
|
+
imagePullSecrets?: string[];
|
|
112
|
+
runtimeClassName?: string; // Optional - if not set or unavailable, uses default container runtime
|
|
113
|
+
startupTimeoutSeconds?: number;
|
|
114
|
+
resources: {
|
|
115
|
+
requests: { cpu: string; memory: string };
|
|
116
|
+
limits: { cpu: string; memory: string };
|
|
117
|
+
};
|
|
118
|
+
idleCleanupMinutes: number;
|
|
119
|
+
maxDeployments: number;
|
|
120
|
+
env?: Record<string, string | number | boolean>;
|
|
121
|
+
persistence?: {
|
|
122
|
+
size?: string;
|
|
123
|
+
storageClass?: string;
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
kubernetes: {
|
|
127
|
+
namespace: string;
|
|
128
|
+
};
|
|
129
|
+
cleanup: {
|
|
130
|
+
initialDelayMs: number;
|
|
131
|
+
intervalMs: number;
|
|
132
|
+
veryOldDays: number;
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface DeploymentInfo {
|
|
137
|
+
deploymentName: string;
|
|
138
|
+
lastActivity: Date;
|
|
139
|
+
minutesIdle: number;
|
|
140
|
+
daysSinceActivity: number;
|
|
141
|
+
replicas: number;
|
|
142
|
+
isIdle: boolean;
|
|
143
|
+
isVeryOld: boolean;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Check if an env var name looks like a secret (API key / token / secret / password). */
|
|
147
|
+
function isSecretEnvVar(
|
|
148
|
+
name: string,
|
|
149
|
+
providerModules: ModelProviderModule[]
|
|
150
|
+
): boolean {
|
|
151
|
+
for (const provider of providerModules) {
|
|
152
|
+
if (provider.getSecretEnvVarNames().includes(name)) return true;
|
|
153
|
+
}
|
|
154
|
+
const upper = name.toUpperCase();
|
|
155
|
+
return (
|
|
156
|
+
upper.includes("_KEY") ||
|
|
157
|
+
upper.includes("_TOKEN") ||
|
|
158
|
+
upper.includes("_SECRET") ||
|
|
159
|
+
upper.includes("_PASSWORD")
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export abstract class BaseDeploymentManager {
|
|
164
|
+
protected config: OrchestratorConfig;
|
|
165
|
+
protected moduleEnvVarsBuilder?: ModuleEnvVarsBuilder;
|
|
166
|
+
protected providerModules: ModelProviderModule[];
|
|
167
|
+
protected providerCatalogService?: import("../auth/provider-catalog").ProviderCatalogService;
|
|
168
|
+
protected redisClient?: Redis;
|
|
169
|
+
protected grantStore?: GrantStore;
|
|
170
|
+
|
|
171
|
+
constructor(
|
|
172
|
+
config: OrchestratorConfig,
|
|
173
|
+
moduleEnvVarsBuilder?: ModuleEnvVarsBuilder,
|
|
174
|
+
providerModules: ModelProviderModule[] = []
|
|
175
|
+
) {
|
|
176
|
+
this.config = config;
|
|
177
|
+
this.moduleEnvVarsBuilder = moduleEnvVarsBuilder;
|
|
178
|
+
this.providerModules = providerModules;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Inject Redis client for secret placeholder generation.
|
|
183
|
+
* Called after core services are initialized.
|
|
184
|
+
*/
|
|
185
|
+
setRedisClient(redis: Redis): void {
|
|
186
|
+
this.redisClient = redis;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Refresh provider modules after module registry initialization.
|
|
191
|
+
*/
|
|
192
|
+
setProviderModules(providerModules: ModelProviderModule[]): void {
|
|
193
|
+
this.providerModules = providerModules;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
setProviderCatalogService(
|
|
197
|
+
service: import("../auth/provider-catalog").ProviderCatalogService
|
|
198
|
+
): void {
|
|
199
|
+
this.providerCatalogService = service;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Inject grant store for auto-adding domain grants at deployment time.
|
|
204
|
+
*/
|
|
205
|
+
setGrantStore(store: GrantStore): void {
|
|
206
|
+
this.grantStore = store;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Get the dispatcher URL for the worker gateway service (port 8080)
|
|
211
|
+
*/
|
|
212
|
+
protected getDispatcherUrl(): string {
|
|
213
|
+
return `http://${this.getDispatcherHost()}:8080`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Abstract methods that must be implemented by concrete classes
|
|
217
|
+
abstract listDeployments(): Promise<DeploymentInfo[]>;
|
|
218
|
+
abstract createDeployment(
|
|
219
|
+
deploymentName: string,
|
|
220
|
+
username: string,
|
|
221
|
+
userId: string,
|
|
222
|
+
messageData?: MessagePayload
|
|
223
|
+
): Promise<void>;
|
|
224
|
+
abstract scaleDeployment(
|
|
225
|
+
deploymentName: string,
|
|
226
|
+
replicas: number
|
|
227
|
+
): Promise<void>;
|
|
228
|
+
abstract deleteDeployment(deploymentName: string): Promise<void>;
|
|
229
|
+
abstract updateDeploymentActivity(deploymentName: string): Promise<void>;
|
|
230
|
+
abstract validateWorkerImage(): Promise<void>;
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Get the dispatcher service host (without port)
|
|
234
|
+
* Implementations return the appropriate host for their deployment mode
|
|
235
|
+
*/
|
|
236
|
+
protected abstract getDispatcherHost(): string;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Resolve worker image reference.
|
|
240
|
+
* If digest is configured, prefer immutable digest reference (repo@sha256:...).
|
|
241
|
+
*/
|
|
242
|
+
protected getWorkerImageReference(): string {
|
|
243
|
+
const { repository, tag, digest } = this.config.worker.image;
|
|
244
|
+
const normalizedDigest = digest?.trim();
|
|
245
|
+
|
|
246
|
+
if (normalizedDigest) {
|
|
247
|
+
const digestWithAlgo = normalizedDigest.startsWith("sha256:")
|
|
248
|
+
? normalizedDigest
|
|
249
|
+
: `sha256:${normalizedDigest}`;
|
|
250
|
+
return `${repository}@${digestWithAlgo}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return `${repository}:${tag}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Create worker deployment for handling messages.
|
|
258
|
+
* @param existingDeployments - Optional pre-fetched deployment list to avoid redundant API calls
|
|
259
|
+
*/
|
|
260
|
+
async createWorkerDeployment(
|
|
261
|
+
userId: string,
|
|
262
|
+
conversationId: string,
|
|
263
|
+
messageData?: MessagePayload,
|
|
264
|
+
existingDeployments?: DeploymentInfo[]
|
|
265
|
+
): Promise<void> {
|
|
266
|
+
const deploymentIdentity: DeploymentIdentity = {
|
|
267
|
+
userId,
|
|
268
|
+
conversationId,
|
|
269
|
+
channelId: messageData?.channelId,
|
|
270
|
+
platform: messageData?.platform,
|
|
271
|
+
};
|
|
272
|
+
const deploymentName = generateDeploymentName(deploymentIdentity);
|
|
273
|
+
const canonicalConversationKey =
|
|
274
|
+
buildCanonicalConversationKey(deploymentIdentity);
|
|
275
|
+
|
|
276
|
+
logger.info(
|
|
277
|
+
`Worker deployment - conversationId: ${conversationId}, canonicalKey: ${canonicalConversationKey}, deploymentName: ${deploymentName}`
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
// Use pre-fetched list or fetch fresh
|
|
282
|
+
const deployments = existingDeployments ?? (await this.listDeployments());
|
|
283
|
+
const existingDeployment = deployments.find(
|
|
284
|
+
(d) => d.deploymentName === deploymentName
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
if (existingDeployment) {
|
|
288
|
+
// Scale up the existing deployment. Provider config is now delivered
|
|
289
|
+
// dynamically via session context, so no need to recreate.
|
|
290
|
+
await this.scaleDeployment(deploymentName, 1);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Check if we would exceed max deployments limit
|
|
295
|
+
const maxDeployments = this.config.worker.maxDeployments;
|
|
296
|
+
if (maxDeployments > 0 && deployments.length >= maxDeployments) {
|
|
297
|
+
logger.warn(
|
|
298
|
+
`⚠️ Maximum deployments limit reached (${deployments.length}/${maxDeployments}). Running cleanup before creating new deployment.`
|
|
299
|
+
);
|
|
300
|
+
await this.reconcileDeployments();
|
|
301
|
+
|
|
302
|
+
// Check again after cleanup
|
|
303
|
+
const deploymentsAfterCleanup = await this.listDeployments();
|
|
304
|
+
if (deploymentsAfterCleanup.length >= maxDeployments) {
|
|
305
|
+
throw new OrchestratorError(
|
|
306
|
+
ErrorCode.DEPLOYMENT_CREATE_FAILED,
|
|
307
|
+
`Cannot create new deployment: Maximum deployments limit (${maxDeployments}) reached. Current active deployments: ${deploymentsAfterCleanup.length}`,
|
|
308
|
+
{
|
|
309
|
+
maxDeployments,
|
|
310
|
+
currentCount: deploymentsAfterCleanup.length,
|
|
311
|
+
},
|
|
312
|
+
true
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
await this.createDeployment(deploymentName, userId, userId, messageData);
|
|
318
|
+
} catch (error) {
|
|
319
|
+
throw new OrchestratorError(
|
|
320
|
+
ErrorCode.DEPLOYMENT_CREATE_FAILED,
|
|
321
|
+
`Failed to create worker deployment: ${error instanceof Error ? error.message : String(error)}`,
|
|
322
|
+
{ userId, conversationId, error },
|
|
323
|
+
true
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Validate that messageData has all required fields for deployment.
|
|
330
|
+
*/
|
|
331
|
+
private validateMessageData(
|
|
332
|
+
deploymentName: string,
|
|
333
|
+
messageData?: MessagePayload
|
|
334
|
+
): MessagePayload {
|
|
335
|
+
if (!messageData) {
|
|
336
|
+
throw new OrchestratorError(
|
|
337
|
+
ErrorCode.DEPLOYMENT_CREATE_FAILED,
|
|
338
|
+
"Message data is required for worker deployment",
|
|
339
|
+
{ deploymentName },
|
|
340
|
+
true
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const { conversationId, channelId } = messageData;
|
|
345
|
+
if (!conversationId || !channelId) {
|
|
346
|
+
throw new OrchestratorError(
|
|
347
|
+
ErrorCode.DEPLOYMENT_CREATE_FAILED,
|
|
348
|
+
"conversationId and channelId are required in message data",
|
|
349
|
+
{
|
|
350
|
+
deploymentName,
|
|
351
|
+
hasConversationId: !!conversationId,
|
|
352
|
+
hasChannelId: !!channelId,
|
|
353
|
+
},
|
|
354
|
+
true
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return messageData;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Auto-add Nix cache domains as grants and persist MCP configs for the deployment.
|
|
363
|
+
*/
|
|
364
|
+
private async storeDeploymentConfigs(
|
|
365
|
+
deploymentName: string,
|
|
366
|
+
messageData: MessagePayload
|
|
367
|
+
): Promise<void> {
|
|
368
|
+
const agentId = messageData.agentId;
|
|
369
|
+
|
|
370
|
+
// Sync networkConfig.allowedDomains to grant store
|
|
371
|
+
if (
|
|
372
|
+
this.grantStore &&
|
|
373
|
+
agentId &&
|
|
374
|
+
messageData.networkConfig?.allowedDomains?.length
|
|
375
|
+
) {
|
|
376
|
+
for (const domain of messageData.networkConfig.allowedDomains) {
|
|
377
|
+
await this.grantStore.grant(agentId, domain, null);
|
|
378
|
+
}
|
|
379
|
+
logger.info(
|
|
380
|
+
`Synced network config domains as grants for ${deploymentName}: ${messageData.networkConfig.allowedDomains.join(", ")}`
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Auto-add Nix cache domains as permanent grants when Nix packages are configured
|
|
385
|
+
if (
|
|
386
|
+
this.grantStore &&
|
|
387
|
+
agentId &&
|
|
388
|
+
(messageData.nixConfig?.packages?.length ||
|
|
389
|
+
messageData.nixConfig?.flakeUrl)
|
|
390
|
+
) {
|
|
391
|
+
const NIX_DOMAINS = [
|
|
392
|
+
"cache.nixos.org",
|
|
393
|
+
"channels.nixos.org",
|
|
394
|
+
"releases.nixos.org",
|
|
395
|
+
];
|
|
396
|
+
for (const domain of NIX_DOMAINS) {
|
|
397
|
+
await this.grantStore.grant(agentId, domain, null);
|
|
398
|
+
}
|
|
399
|
+
logger.info(
|
|
400
|
+
`Added Nix cache domains as grants for ${deploymentName}: ${NIX_DOMAINS.join(", ")}`
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Sync networkConfig.allowedDomains to the grant store for a running worker.
|
|
407
|
+
* Called on every message to pick up domains added via configuration APIs.
|
|
408
|
+
*/
|
|
409
|
+
async syncNetworkConfigGrants(messageData: MessagePayload): Promise<void> {
|
|
410
|
+
const agentId = messageData.agentId;
|
|
411
|
+
if (!this.grantStore || !agentId) return;
|
|
412
|
+
|
|
413
|
+
if (messageData.networkConfig?.allowedDomains?.length) {
|
|
414
|
+
for (const domain of messageData.networkConfig.allowedDomains) {
|
|
415
|
+
await this.grantStore.grant(agentId, domain, null);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Build proxy URL with deployment identification via Basic auth.
|
|
422
|
+
*/
|
|
423
|
+
private buildProxyUrl(
|
|
424
|
+
deploymentName: string,
|
|
425
|
+
workerToken: string,
|
|
426
|
+
dispatcherHost: string
|
|
427
|
+
): string {
|
|
428
|
+
const parsedProxyPort = Number.parseInt(
|
|
429
|
+
process.env.WORKER_PROXY_PORT || "8118",
|
|
430
|
+
10
|
|
431
|
+
);
|
|
432
|
+
const proxyPort = Number.isFinite(parsedProxyPort) ? parsedProxyPort : 8118;
|
|
433
|
+
return `http://${deploymentName}:${workerToken}@${dispatcherHost}:${proxyPort}`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Assemble the base environment variables map for a worker deployment.
|
|
438
|
+
*/
|
|
439
|
+
private assembleBaseEnv(
|
|
440
|
+
username: string,
|
|
441
|
+
userId: string,
|
|
442
|
+
deploymentName: string,
|
|
443
|
+
workerToken: string,
|
|
444
|
+
messageData: MessagePayload,
|
|
445
|
+
traceId: string | undefined,
|
|
446
|
+
proxyUrl: string,
|
|
447
|
+
dispatcherHost: string
|
|
448
|
+
): Record<string, string> {
|
|
449
|
+
const { conversationId, channelId, platformMetadata } = messageData;
|
|
450
|
+
|
|
451
|
+
const envVars: Record<string, string> = {
|
|
452
|
+
USER_ID: userId,
|
|
453
|
+
USERNAME: username,
|
|
454
|
+
DEPLOYMENT_NAME: deploymentName,
|
|
455
|
+
CHANNEL_ID: channelId,
|
|
456
|
+
ORIGINAL_MESSAGE_TS:
|
|
457
|
+
platformMetadata?.originalMessageTs || messageData.messageId || "",
|
|
458
|
+
LOG_LEVEL: "info",
|
|
459
|
+
WORKSPACE_DIR: "/workspace",
|
|
460
|
+
CONVERSATION_ID: conversationId,
|
|
461
|
+
WORKER_TOKEN: workerToken,
|
|
462
|
+
DISPATCHER_URL: this.getDispatcherUrl(),
|
|
463
|
+
NODE_ENV: process.env.NODE_ENV || "production",
|
|
464
|
+
DEBUG: "1",
|
|
465
|
+
HTTP_PROXY: proxyUrl,
|
|
466
|
+
HTTPS_PROXY: proxyUrl,
|
|
467
|
+
NO_PROXY: `${dispatcherHost},gateway,redis,localhost,127.0.0.1`,
|
|
468
|
+
// Route temporary files and cache to persistent workspace storage.
|
|
469
|
+
TMPDIR: "/workspace/.tmp",
|
|
470
|
+
TMP: "/workspace/.tmp",
|
|
471
|
+
TEMP: "/workspace/.tmp",
|
|
472
|
+
XDG_CACHE_HOME: "/workspace/.cache",
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
if (platformMetadata?.botResponseTs) {
|
|
476
|
+
envVars.BOT_RESPONSE_TS = platformMetadata.botResponseTs;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (traceId) {
|
|
480
|
+
envVars.TRACE_ID = traceId;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Add Tempo endpoint for distributed tracing
|
|
484
|
+
const tempoEndpoint = process.env.TEMPO_ENDPOINT;
|
|
485
|
+
if (tempoEndpoint) {
|
|
486
|
+
envVars.TEMPO_ENDPOINT = tempoEndpoint;
|
|
487
|
+
try {
|
|
488
|
+
const tempoUrl = new URL(tempoEndpoint);
|
|
489
|
+
envVars.NO_PROXY = `${envVars.NO_PROXY},${tempoUrl.hostname}`;
|
|
490
|
+
} catch {
|
|
491
|
+
envVars.NO_PROXY = `${envVars.NO_PROXY},lobu-tempo`;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Forward WORKER_ENV_* vars to workers with prefix stripped
|
|
496
|
+
const WORKER_ENV_PREFIX = "WORKER_ENV_";
|
|
497
|
+
for (const key of Object.keys(process.env)) {
|
|
498
|
+
if (key.startsWith(WORKER_ENV_PREFIX)) {
|
|
499
|
+
const stripped = key.slice(WORKER_ENV_PREFIX.length);
|
|
500
|
+
if (stripped) {
|
|
501
|
+
envVars[stripped] = process.env[key]!;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Nix config
|
|
507
|
+
if (messageData.nixConfig) {
|
|
508
|
+
const { flakeUrl, packages } = messageData.nixConfig;
|
|
509
|
+
if (flakeUrl) envVars.NIX_FLAKE_URL = flakeUrl;
|
|
510
|
+
if (packages && packages.length > 0)
|
|
511
|
+
envVars.NIX_PACKAGES = packages.join(",");
|
|
512
|
+
logger.debug(
|
|
513
|
+
`Nix config for ${deploymentName}: flakeUrl=${flakeUrl || "none"}, packages=${packages?.length || 0}`
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return envVars;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Replace secret env var values with opaque placeholders before passing to workers.
|
|
522
|
+
*
|
|
523
|
+
* Provider credential env vars are set to `"lobu-proxy"` — the proxy resolves
|
|
524
|
+
* the real credential at request time using agentId from the URL path
|
|
525
|
+
* (`/a/{agentId}`) and the provider slug.
|
|
526
|
+
*
|
|
527
|
+
* Non-provider secrets use UUID placeholders stored in Redis.
|
|
528
|
+
*/
|
|
529
|
+
private async injectSecretPlaceholders(
|
|
530
|
+
envVars: Record<string, string>,
|
|
531
|
+
agentId: string,
|
|
532
|
+
deploymentName: string
|
|
533
|
+
): Promise<Record<string, string>> {
|
|
534
|
+
if (!this.redisClient) return envVars;
|
|
535
|
+
|
|
536
|
+
// Collect credential env var names from all providers
|
|
537
|
+
const providerCredentialVars = new Set<string>();
|
|
538
|
+
for (const provider of this.providerModules) {
|
|
539
|
+
providerCredentialVars.add(provider.getCredentialEnvVarName());
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
let hasSecrets = false;
|
|
543
|
+
for (const [key, value] of Object.entries(envVars)) {
|
|
544
|
+
if (!value || !isSecretEnvVar(key, this.providerModules)) continue;
|
|
545
|
+
if (key === "WORKER_TOKEN") continue;
|
|
546
|
+
|
|
547
|
+
if (providerCredentialVars.has(key)) {
|
|
548
|
+
// Provider credentials use a proxy placeholder. The worker never
|
|
549
|
+
// sees real credentials. The proxy resolves the real credential
|
|
550
|
+
// using agentId from the URL path (/a/{agentId}) and the provider
|
|
551
|
+
// slug, then overrides the Authorization header before forwarding.
|
|
552
|
+
const ownerProvider = this.providerModules.find(
|
|
553
|
+
(p) => p.getCredentialEnvVarName() === key
|
|
554
|
+
);
|
|
555
|
+
if (ownerProvider?.buildCredentialPlaceholder) {
|
|
556
|
+
envVars[key] =
|
|
557
|
+
await ownerProvider.buildCredentialPlaceholder(agentId);
|
|
558
|
+
} else {
|
|
559
|
+
envVars[key] = "lobu-proxy";
|
|
560
|
+
}
|
|
561
|
+
hasSecrets = true;
|
|
562
|
+
} else {
|
|
563
|
+
// Use UUID placeholder for non-provider secrets (legacy path)
|
|
564
|
+
try {
|
|
565
|
+
const placeholder = await generatePlaceholder(
|
|
566
|
+
this.redisClient,
|
|
567
|
+
agentId,
|
|
568
|
+
key,
|
|
569
|
+
value,
|
|
570
|
+
deploymentName
|
|
571
|
+
);
|
|
572
|
+
envVars[key] = placeholder;
|
|
573
|
+
hasSecrets = true;
|
|
574
|
+
} catch (error) {
|
|
575
|
+
logger.warn(`Failed to generate placeholder for ${key}:`, error);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (hasSecrets) {
|
|
581
|
+
const proxyUrl = `${this.getDispatcherUrl()}/api/proxy`;
|
|
582
|
+
for (const provider of this.providerModules) {
|
|
583
|
+
Object.assign(
|
|
584
|
+
envVars,
|
|
585
|
+
provider.getProxyBaseUrlMappings(proxyUrl, agentId)
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
logger.info(
|
|
589
|
+
`🔐 Generated secret placeholders for ${deploymentName}, routing through proxy`
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return envVars;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Generate environment variables common to all deployment types.
|
|
598
|
+
* Orchestrates the focused helpers above.
|
|
599
|
+
*/
|
|
600
|
+
protected async generateEnvironmentVariables(
|
|
601
|
+
username: string,
|
|
602
|
+
userId: string,
|
|
603
|
+
deploymentName: string,
|
|
604
|
+
messageData?: MessagePayload,
|
|
605
|
+
includeSecrets: boolean = true
|
|
606
|
+
): Promise<Record<string, string>> {
|
|
607
|
+
const validated = this.validateMessageData(deploymentName, messageData);
|
|
608
|
+
const { conversationId, channelId, platformMetadata, agentId, platform } =
|
|
609
|
+
validated;
|
|
610
|
+
const teamId = validated.teamId || platformMetadata?.teamId;
|
|
611
|
+
const traceId = extractTraceId(validated);
|
|
612
|
+
|
|
613
|
+
const workerToken = generateWorkerToken(
|
|
614
|
+
userId,
|
|
615
|
+
conversationId,
|
|
616
|
+
deploymentName,
|
|
617
|
+
{
|
|
618
|
+
channelId,
|
|
619
|
+
teamId,
|
|
620
|
+
platform,
|
|
621
|
+
agentId,
|
|
622
|
+
connectionId:
|
|
623
|
+
typeof platformMetadata?.connectionId === "string"
|
|
624
|
+
? platformMetadata.connectionId
|
|
625
|
+
: undefined,
|
|
626
|
+
traceId,
|
|
627
|
+
}
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
const dispatcherHost = this.getDispatcherHost();
|
|
631
|
+
await this.storeDeploymentConfigs(deploymentName, validated);
|
|
632
|
+
|
|
633
|
+
const proxyUrl = this.buildProxyUrl(
|
|
634
|
+
deploymentName,
|
|
635
|
+
workerToken,
|
|
636
|
+
dispatcherHost
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
let envVars = this.assembleBaseEnv(
|
|
640
|
+
username,
|
|
641
|
+
userId,
|
|
642
|
+
deploymentName,
|
|
643
|
+
workerToken,
|
|
644
|
+
validated,
|
|
645
|
+
traceId,
|
|
646
|
+
proxyUrl,
|
|
647
|
+
dispatcherHost
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
// Include secrets from process.env for Docker deployments
|
|
651
|
+
if (includeSecrets && this.moduleEnvVarsBuilder) {
|
|
652
|
+
try {
|
|
653
|
+
envVars = await this.moduleEnvVarsBuilder(agentId, envVars);
|
|
654
|
+
} catch (error) {
|
|
655
|
+
logger.warn("Failed to build module environment variables:", error);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Add worker environment variables from configuration
|
|
660
|
+
if (this.config.worker.env) {
|
|
661
|
+
for (const [key, value] of Object.entries(this.config.worker.env)) {
|
|
662
|
+
envVars[key] = String(value);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Resolve per-agent installed providers (catalog-only when active, no global fallback)
|
|
667
|
+
const effectiveProviders = this.providerCatalogService
|
|
668
|
+
? await this.providerCatalogService.getInstalledModules(agentId)
|
|
669
|
+
: this.providerModules;
|
|
670
|
+
|
|
671
|
+
for (const provider of effectiveProviders) {
|
|
672
|
+
envVars = provider.injectSystemKeyFallback(envVars);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
envVars = await this.injectSecretPlaceholders(
|
|
676
|
+
envVars,
|
|
677
|
+
agentId,
|
|
678
|
+
deploymentName
|
|
679
|
+
);
|
|
680
|
+
|
|
681
|
+
// Inject provider metadata into agentOptions so the worker can configure
|
|
682
|
+
// the SDK generically without hardcoded provider checks.
|
|
683
|
+
// Determine primary provider from the model in agentOptions.
|
|
684
|
+
const agentModel = validated.agentOptions?.model as string | undefined;
|
|
685
|
+
let primaryProvider: ModelProviderModule | undefined;
|
|
686
|
+
|
|
687
|
+
if (
|
|
688
|
+
agentModel &&
|
|
689
|
+
effectiveProviders.length > 0 &&
|
|
690
|
+
this.providerCatalogService
|
|
691
|
+
) {
|
|
692
|
+
primaryProvider = await this.providerCatalogService.findProviderForModel(
|
|
693
|
+
agentModel,
|
|
694
|
+
effectiveProviders
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// When no explicit model is set (auto mode), detect the primary provider
|
|
699
|
+
// from installed providers order (first with credentials = primary).
|
|
700
|
+
if (!primaryProvider && effectiveProviders.length > 0) {
|
|
701
|
+
for (const candidate of effectiveProviders) {
|
|
702
|
+
if (
|
|
703
|
+
candidate.hasSystemKey() ||
|
|
704
|
+
(await candidate.hasCredentials(agentId))
|
|
705
|
+
) {
|
|
706
|
+
primaryProvider = candidate;
|
|
707
|
+
break;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (primaryProvider) {
|
|
713
|
+
logger.info(
|
|
714
|
+
{
|
|
715
|
+
agentId,
|
|
716
|
+
primaryProviderId: primaryProvider.providerId,
|
|
717
|
+
slug: primaryProvider.getUpstreamConfig?.()?.slug,
|
|
718
|
+
},
|
|
719
|
+
"Selected primary provider"
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
const proxyBaseUrl = `${this.getDispatcherUrl()}/api/proxy`;
|
|
723
|
+
const mappings = primaryProvider.getProxyBaseUrlMappings(
|
|
724
|
+
proxyBaseUrl,
|
|
725
|
+
agentId
|
|
726
|
+
);
|
|
727
|
+
const providerBaseUrl = Object.values(mappings)[0];
|
|
728
|
+
if (providerBaseUrl) {
|
|
729
|
+
validated.agentOptions = {
|
|
730
|
+
...validated.agentOptions,
|
|
731
|
+
providerBaseUrl,
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// CREDENTIAL_ENV_VAR_NAME and AGENT_DEFAULT_PROVIDER are now
|
|
736
|
+
// delivered dynamically via session context endpoint. No longer
|
|
737
|
+
// set as static container env vars.
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Build full provider base URL mappings for all installed providers
|
|
741
|
+
const proxyBaseUrl = `${this.getDispatcherUrl()}/api/proxy`;
|
|
742
|
+
const providerBaseUrlMappings: Record<string, string> = {};
|
|
743
|
+
for (const provider of effectiveProviders) {
|
|
744
|
+
const mappings = provider.getProxyBaseUrlMappings(proxyBaseUrl, agentId);
|
|
745
|
+
Object.assign(providerBaseUrlMappings, mappings);
|
|
746
|
+
}
|
|
747
|
+
if (Object.keys(providerBaseUrlMappings).length > 0) {
|
|
748
|
+
validated.agentOptions = {
|
|
749
|
+
...validated.agentOptions,
|
|
750
|
+
providerBaseUrlMappings,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// CLI_BACKENDS is now delivered dynamically via session context.
|
|
755
|
+
// Still need to auto-add npm registry domains for npx at deploy time.
|
|
756
|
+
const hasCliBackendProviders = effectiveProviders.some((p) =>
|
|
757
|
+
p.getCliBackendConfig?.()
|
|
758
|
+
);
|
|
759
|
+
if (hasCliBackendProviders && this.grantStore && agentId) {
|
|
760
|
+
const NPM_DOMAINS = ["registry.npmjs.org", "registry.npmmirror.com"];
|
|
761
|
+
for (const domain of NPM_DOMAINS) {
|
|
762
|
+
await this.grantStore.grant(agentId, domain, null);
|
|
763
|
+
}
|
|
764
|
+
logger.info(
|
|
765
|
+
`Added npm registry domains as grants for ${deploymentName}: ${NPM_DOMAINS.join(", ")}`
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
return envVars;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Delete a worker deployment and associated resources
|
|
774
|
+
*/
|
|
775
|
+
async deleteWorkerDeployment(deploymentName: string): Promise<void> {
|
|
776
|
+
try {
|
|
777
|
+
// Clean up secret placeholder mappings
|
|
778
|
+
if (this.redisClient) {
|
|
779
|
+
await deleteSecretMappings(this.redisClient, deploymentName);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Clean up any scheduled wakeups for this deployment
|
|
783
|
+
const scheduledWakeupService = getScheduledWakeupService();
|
|
784
|
+
if (scheduledWakeupService) {
|
|
785
|
+
await scheduledWakeupService.cleanupForDeployment(deploymentName);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
await this.deleteDeployment(deploymentName);
|
|
789
|
+
} catch (error) {
|
|
790
|
+
throw new OrchestratorError(
|
|
791
|
+
ErrorCode.DEPLOYMENT_DELETE_FAILED,
|
|
792
|
+
`Failed to delete deployment for ${deploymentName}: ${error instanceof Error ? error.message : String(error)}`,
|
|
793
|
+
{ deploymentName, error },
|
|
794
|
+
true
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Reconcile deployments: unified method for cleanup and resource management
|
|
801
|
+
* This method uses the abstract methods to work with any deployment backend
|
|
802
|
+
*/
|
|
803
|
+
async reconcileDeployments(): Promise<void> {
|
|
804
|
+
try {
|
|
805
|
+
const maxDeployments = this.config.worker.maxDeployments;
|
|
806
|
+
|
|
807
|
+
logger.debug("Running deployment cleanup...");
|
|
808
|
+
|
|
809
|
+
// Get all worker deployments from the backend
|
|
810
|
+
const activeDeployments = await this.listDeployments();
|
|
811
|
+
|
|
812
|
+
if (activeDeployments.length === 0) {
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Sort deployments by last activity (oldest first)
|
|
817
|
+
const sortedDeployments = [...activeDeployments].sort(
|
|
818
|
+
(a, b) => a.lastActivity.getTime() - b.lastActivity.getTime()
|
|
819
|
+
);
|
|
820
|
+
|
|
821
|
+
let processedCount = 0;
|
|
822
|
+
const BATCH_SIZE = 10; // Process up to 10 deletions in parallel
|
|
823
|
+
|
|
824
|
+
// Collect actions to perform
|
|
825
|
+
const toDelete: string[] = [];
|
|
826
|
+
const toScaleDown: string[] = [];
|
|
827
|
+
|
|
828
|
+
for (const analysis of sortedDeployments) {
|
|
829
|
+
const { deploymentName, replicas, isIdle, isVeryOld } = analysis;
|
|
830
|
+
|
|
831
|
+
if (isVeryOld) {
|
|
832
|
+
toDelete.push(deploymentName);
|
|
833
|
+
} else if (isIdle && replicas > 0) {
|
|
834
|
+
toScaleDown.push(deploymentName);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Check if we exceed max deployments
|
|
839
|
+
const remainingDeployments = sortedDeployments.filter(
|
|
840
|
+
(d) => !d.isVeryOld
|
|
841
|
+
);
|
|
842
|
+
if (remainingDeployments.length > maxDeployments) {
|
|
843
|
+
const excessCount = remainingDeployments.length - maxDeployments;
|
|
844
|
+
const deploymentsToDelete = remainingDeployments.slice(0, excessCount);
|
|
845
|
+
for (const { deploymentName } of deploymentsToDelete) {
|
|
846
|
+
if (!toDelete.includes(deploymentName)) {
|
|
847
|
+
toDelete.push(deploymentName);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Process deletions in parallel batches
|
|
853
|
+
for (let i = 0; i < toDelete.length; i += BATCH_SIZE) {
|
|
854
|
+
const batch = toDelete.slice(i, i + BATCH_SIZE);
|
|
855
|
+
const results = await Promise.allSettled(
|
|
856
|
+
batch.map((name) => this.deleteWorkerDeployment(name))
|
|
857
|
+
);
|
|
858
|
+
for (let j = 0; j < results.length; j++) {
|
|
859
|
+
if (results[j]?.status === "fulfilled") {
|
|
860
|
+
processedCount++;
|
|
861
|
+
} else {
|
|
862
|
+
logger.error(
|
|
863
|
+
`❌ Failed to delete deployment ${batch[j]}:`,
|
|
864
|
+
(results[j] as PromiseRejectedResult).reason
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Process scale-downs in parallel batches
|
|
871
|
+
for (let i = 0; i < toScaleDown.length; i += BATCH_SIZE) {
|
|
872
|
+
const batch = toScaleDown.slice(i, i + BATCH_SIZE);
|
|
873
|
+
const results = await Promise.allSettled(
|
|
874
|
+
batch.map((name) => this.scaleDeployment(name, 0))
|
|
875
|
+
);
|
|
876
|
+
for (let j = 0; j < results.length; j++) {
|
|
877
|
+
if (results[j]?.status === "fulfilled") {
|
|
878
|
+
processedCount++;
|
|
879
|
+
} else {
|
|
880
|
+
logger.error(
|
|
881
|
+
`❌ Failed to scale down deployment ${batch[j]}:`,
|
|
882
|
+
(results[j] as PromiseRejectedResult).reason
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
if (processedCount > 0) {
|
|
889
|
+
logger.info(
|
|
890
|
+
`✅ Cleanup completed: processed ${processedCount} deployment(s)`
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
} catch (error) {
|
|
894
|
+
logger.error(
|
|
895
|
+
"Error during deployment reconciliation:",
|
|
896
|
+
error instanceof Error ? error.message : String(error)
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|