@agenticmail/enterprise 0.2.1
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/ARCHITECTURE.md +183 -0
- package/agenticmail-enterprise.db +0 -0
- package/dashboards/README.md +120 -0
- package/dashboards/dotnet/Program.cs +261 -0
- package/dashboards/express/app.js +146 -0
- package/dashboards/go/main.go +513 -0
- package/dashboards/html/index.html +535 -0
- package/dashboards/java/AgenticMailDashboard.java +376 -0
- package/dashboards/php/index.php +414 -0
- package/dashboards/python/app.py +273 -0
- package/dashboards/ruby/app.rb +195 -0
- package/dist/chunk-77IDQJL3.js +7 -0
- package/dist/chunk-7RGCCHIT.js +115 -0
- package/dist/chunk-DXNKR3TG.js +1355 -0
- package/dist/chunk-IQWA44WT.js +970 -0
- package/dist/chunk-LCUZGIDH.js +965 -0
- package/dist/chunk-N2JVTNNJ.js +2553 -0
- package/dist/chunk-O462UJBH.js +363 -0
- package/dist/chunk-PNKVD2UK.js +26 -0
- package/dist/cli.js +218 -0
- package/dist/dashboard/index.html +558 -0
- package/dist/db-adapter-DEWEFNIV.js +7 -0
- package/dist/dynamodb-CCGL2E77.js +426 -0
- package/dist/engine/index.js +1261 -0
- package/dist/index.js +522 -0
- package/dist/mongodb-ODTXIVPV.js +319 -0
- package/dist/mysql-RM3S2FV5.js +521 -0
- package/dist/postgres-LN7A6MGQ.js +518 -0
- package/dist/routes-2JEPIIKC.js +441 -0
- package/dist/routes-74ZLKJKP.js +399 -0
- package/dist/server.js +7 -0
- package/dist/sqlite-3K5YOZ4K.js +439 -0
- package/dist/turso-LDWODSDI.js +442 -0
- package/package.json +49 -0
- package/src/admin/routes.ts +331 -0
- package/src/auth/routes.ts +130 -0
- package/src/cli.ts +260 -0
- package/src/dashboard/index.html +558 -0
- package/src/db/adapter.ts +230 -0
- package/src/db/dynamodb.ts +456 -0
- package/src/db/factory.ts +51 -0
- package/src/db/mongodb.ts +360 -0
- package/src/db/mysql.ts +472 -0
- package/src/db/postgres.ts +479 -0
- package/src/db/sql-schema.ts +123 -0
- package/src/db/sqlite.ts +391 -0
- package/src/db/turso.ts +411 -0
- package/src/deploy/fly.ts +368 -0
- package/src/deploy/managed.ts +213 -0
- package/src/engine/activity.ts +474 -0
- package/src/engine/agent-config.ts +429 -0
- package/src/engine/agenticmail-bridge.ts +296 -0
- package/src/engine/approvals.ts +278 -0
- package/src/engine/db-adapter.ts +682 -0
- package/src/engine/db-schema.ts +335 -0
- package/src/engine/deployer.ts +595 -0
- package/src/engine/index.ts +134 -0
- package/src/engine/knowledge.ts +486 -0
- package/src/engine/lifecycle.ts +635 -0
- package/src/engine/openclaw-hook.ts +371 -0
- package/src/engine/routes.ts +528 -0
- package/src/engine/skills.ts +473 -0
- package/src/engine/tenant.ts +345 -0
- package/src/engine/tool-catalog.ts +189 -0
- package/src/index.ts +64 -0
- package/src/lib/resilience.ts +326 -0
- package/src/middleware/index.ts +286 -0
- package/src/server.ts +310 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deployment Engine
|
|
3
|
+
*
|
|
4
|
+
* Handles provisioning and deploying agents to any target:
|
|
5
|
+
* Docker containers, VPS via SSH, Fly.io, Railway, etc.
|
|
6
|
+
*
|
|
7
|
+
* The admin clicks "Deploy" in the dashboard → this engine does the rest.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { AgentConfig, DeploymentTarget, DeploymentStatus } from './agent-config.js';
|
|
11
|
+
import { AgentConfigGenerator } from './agent-config.js';
|
|
12
|
+
|
|
13
|
+
// ─── Types ──────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export interface DeploymentEvent {
|
|
16
|
+
timestamp: string;
|
|
17
|
+
phase: DeploymentPhase;
|
|
18
|
+
status: 'started' | 'completed' | 'failed';
|
|
19
|
+
message: string;
|
|
20
|
+
details?: any;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type DeploymentPhase =
|
|
24
|
+
| 'validate'
|
|
25
|
+
| 'provision'
|
|
26
|
+
| 'configure'
|
|
27
|
+
| 'upload'
|
|
28
|
+
| 'install'
|
|
29
|
+
| 'start'
|
|
30
|
+
| 'healthcheck'
|
|
31
|
+
| 'complete';
|
|
32
|
+
|
|
33
|
+
export interface DeploymentResult {
|
|
34
|
+
success: boolean;
|
|
35
|
+
url?: string; // Agent's accessible URL
|
|
36
|
+
sshCommand?: string; // For VPS: how to SSH in
|
|
37
|
+
containerId?: string; // For Docker
|
|
38
|
+
appId?: string; // For cloud platforms
|
|
39
|
+
events: DeploymentEvent[];
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface LiveAgentStatus {
|
|
44
|
+
agentId: string;
|
|
45
|
+
name: string;
|
|
46
|
+
status: DeploymentStatus;
|
|
47
|
+
uptime?: number; // Seconds
|
|
48
|
+
lastHealthCheck?: string;
|
|
49
|
+
healthStatus?: 'healthy' | 'degraded' | 'unhealthy' | 'unknown';
|
|
50
|
+
metrics?: {
|
|
51
|
+
cpuPercent: number;
|
|
52
|
+
memoryMb: number;
|
|
53
|
+
toolCallsToday: number;
|
|
54
|
+
activeSessionCount: number;
|
|
55
|
+
errorRate: number; // Last hour
|
|
56
|
+
};
|
|
57
|
+
endpoint?: string;
|
|
58
|
+
version?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Deployment Engine ──────────────────────────────────
|
|
62
|
+
|
|
63
|
+
export class DeploymentEngine {
|
|
64
|
+
private configGen = new AgentConfigGenerator();
|
|
65
|
+
private deployments = new Map<string, DeploymentResult>();
|
|
66
|
+
private liveStatus = new Map<string, LiveAgentStatus>();
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Deploy an agent to its configured target
|
|
70
|
+
*/
|
|
71
|
+
async deploy(config: AgentConfig, onEvent?: (event: DeploymentEvent) => void): Promise<DeploymentResult> {
|
|
72
|
+
const events: DeploymentEvent[] = [];
|
|
73
|
+
const emit = (phase: DeploymentPhase, status: DeploymentEvent['status'], message: string, details?: any) => {
|
|
74
|
+
const event: DeploymentEvent = { timestamp: new Date().toISOString(), phase, status, message, details };
|
|
75
|
+
events.push(event);
|
|
76
|
+
onEvent?.(event);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
// 1. Validate
|
|
81
|
+
emit('validate', 'started', 'Validating agent configuration...');
|
|
82
|
+
this.validateConfig(config);
|
|
83
|
+
emit('validate', 'completed', 'Configuration valid');
|
|
84
|
+
|
|
85
|
+
// 2. Route to target-specific deployer
|
|
86
|
+
let result: DeploymentResult;
|
|
87
|
+
switch (config.deployment.target) {
|
|
88
|
+
case 'docker':
|
|
89
|
+
result = await this.deployDocker(config, emit);
|
|
90
|
+
break;
|
|
91
|
+
case 'vps':
|
|
92
|
+
result = await this.deployVPS(config, emit);
|
|
93
|
+
break;
|
|
94
|
+
case 'fly':
|
|
95
|
+
result = await this.deployFly(config, emit);
|
|
96
|
+
break;
|
|
97
|
+
case 'railway':
|
|
98
|
+
result = await this.deployRailway(config, emit);
|
|
99
|
+
break;
|
|
100
|
+
default:
|
|
101
|
+
throw new Error(`Unsupported deployment target: ${config.deployment.target}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
result.events = events;
|
|
105
|
+
this.deployments.set(config.id, result);
|
|
106
|
+
return result;
|
|
107
|
+
|
|
108
|
+
} catch (error: any) {
|
|
109
|
+
emit('complete', 'failed', `Deployment failed: ${error.message}`);
|
|
110
|
+
const result: DeploymentResult = { success: false, events, error: error.message };
|
|
111
|
+
this.deployments.set(config.id, result);
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Stop a running agent
|
|
118
|
+
*/
|
|
119
|
+
async stop(config: AgentConfig): Promise<{ success: boolean; message: string }> {
|
|
120
|
+
switch (config.deployment.target) {
|
|
121
|
+
case 'docker':
|
|
122
|
+
return this.execCommand(`docker stop agenticmail-${config.name} && docker rm agenticmail-${config.name}`);
|
|
123
|
+
case 'vps':
|
|
124
|
+
return this.execSSH(config, `sudo systemctl stop agenticmail-${config.name}`);
|
|
125
|
+
case 'fly':
|
|
126
|
+
return this.execCommand(`fly apps destroy agenticmail-${config.name} --yes`);
|
|
127
|
+
default:
|
|
128
|
+
return { success: false, message: `Cannot stop: unsupported target ${config.deployment.target}` };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Restart a running agent
|
|
134
|
+
*/
|
|
135
|
+
async restart(config: AgentConfig): Promise<{ success: boolean; message: string }> {
|
|
136
|
+
switch (config.deployment.target) {
|
|
137
|
+
case 'docker':
|
|
138
|
+
return this.execCommand(`docker restart agenticmail-${config.name}`);
|
|
139
|
+
case 'vps':
|
|
140
|
+
return this.execSSH(config, `sudo systemctl restart agenticmail-${config.name}`);
|
|
141
|
+
case 'fly':
|
|
142
|
+
return this.execCommand(`fly apps restart agenticmail-${config.name}`);
|
|
143
|
+
default:
|
|
144
|
+
return { success: false, message: `Cannot restart: unsupported target ${config.deployment.target}` };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Get live status of a deployed agent
|
|
150
|
+
*/
|
|
151
|
+
async getStatus(config: AgentConfig): Promise<LiveAgentStatus> {
|
|
152
|
+
const base: LiveAgentStatus = {
|
|
153
|
+
agentId: config.id,
|
|
154
|
+
name: config.displayName,
|
|
155
|
+
status: 'not-deployed',
|
|
156
|
+
healthStatus: 'unknown',
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
switch (config.deployment.target) {
|
|
161
|
+
case 'docker':
|
|
162
|
+
return await this.getDockerStatus(config, base);
|
|
163
|
+
case 'vps':
|
|
164
|
+
return await this.getVPSStatus(config, base);
|
|
165
|
+
case 'fly':
|
|
166
|
+
return await this.getCloudStatus(config, base);
|
|
167
|
+
default:
|
|
168
|
+
return base;
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
return { ...base, status: 'error', healthStatus: 'unhealthy' };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Stream logs from a deployed agent
|
|
177
|
+
*/
|
|
178
|
+
async getLogs(config: AgentConfig, lines: number = 100): Promise<string> {
|
|
179
|
+
switch (config.deployment.target) {
|
|
180
|
+
case 'docker':
|
|
181
|
+
return (await this.execCommand(`docker logs --tail ${lines} agenticmail-${config.name}`)).message;
|
|
182
|
+
case 'vps':
|
|
183
|
+
return (await this.execSSH(config, `journalctl -u agenticmail-${config.name} --no-pager -n ${lines}`)).message;
|
|
184
|
+
case 'fly':
|
|
185
|
+
return (await this.execCommand(`fly logs -a agenticmail-${config.name} -n ${lines}`)).message;
|
|
186
|
+
default:
|
|
187
|
+
return 'Log streaming not supported for this target';
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Update a deployed agent's configuration without full redeployment
|
|
193
|
+
*/
|
|
194
|
+
async updateConfig(config: AgentConfig): Promise<{ success: boolean; message: string }> {
|
|
195
|
+
const workspace = this.configGen.generateWorkspace(config);
|
|
196
|
+
const gatewayConfig = this.configGen.generateGatewayConfig(config);
|
|
197
|
+
|
|
198
|
+
switch (config.deployment.target) {
|
|
199
|
+
case 'docker': {
|
|
200
|
+
// Write config files into the container
|
|
201
|
+
for (const [file, content] of Object.entries(workspace)) {
|
|
202
|
+
const escaped = content.replace(/'/g, "'\\''");
|
|
203
|
+
await this.execCommand(`docker exec agenticmail-${config.name} sh -c 'echo "${Buffer.from(content).toString('base64')}" | base64 -d > /workspace/${file}'`);
|
|
204
|
+
}
|
|
205
|
+
// Restart gateway inside container
|
|
206
|
+
await this.execCommand(`docker exec agenticmail-${config.name} openclaw gateway restart`);
|
|
207
|
+
return { success: true, message: 'Configuration updated and gateway restarted' };
|
|
208
|
+
}
|
|
209
|
+
case 'vps': {
|
|
210
|
+
const vps = config.deployment.config.vps!;
|
|
211
|
+
for (const [file, content] of Object.entries(workspace)) {
|
|
212
|
+
await this.execSSH(config, `cat > ${vps.installPath}/workspace/${file} << 'EOF'\n${content}\nEOF`);
|
|
213
|
+
}
|
|
214
|
+
await this.execSSH(config, `sudo systemctl restart agenticmail-${config.name}`);
|
|
215
|
+
return { success: true, message: 'Configuration updated and service restarted' };
|
|
216
|
+
}
|
|
217
|
+
default:
|
|
218
|
+
return { success: false, message: 'Hot config update not supported for this target' };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ─── Docker Deployment ────────────────────────────────
|
|
223
|
+
|
|
224
|
+
private async deployDocker(config: AgentConfig, emit: Function): Promise<DeploymentResult> {
|
|
225
|
+
const dc = config.deployment.config.docker;
|
|
226
|
+
if (!dc) throw new Error('Docker config missing');
|
|
227
|
+
|
|
228
|
+
// Generate docker-compose
|
|
229
|
+
emit('provision', 'started', 'Generating Docker configuration...');
|
|
230
|
+
const compose = this.configGen.generateDockerCompose(config);
|
|
231
|
+
emit('provision', 'completed', 'Docker Compose generated');
|
|
232
|
+
|
|
233
|
+
// Generate workspace files
|
|
234
|
+
emit('configure', 'started', 'Generating agent workspace...');
|
|
235
|
+
const workspace = this.configGen.generateWorkspace(config);
|
|
236
|
+
emit('configure', 'completed', `Generated ${Object.keys(workspace).length} workspace files`);
|
|
237
|
+
|
|
238
|
+
// Pull image
|
|
239
|
+
emit('install', 'started', `Pulling image ${dc.image}:${dc.tag}...`);
|
|
240
|
+
await this.execCommand(`docker pull ${dc.image}:${dc.tag}`);
|
|
241
|
+
emit('install', 'completed', 'Image pulled');
|
|
242
|
+
|
|
243
|
+
// Start container
|
|
244
|
+
emit('start', 'started', 'Starting container...');
|
|
245
|
+
|
|
246
|
+
// Build env args
|
|
247
|
+
const envArgs = Object.entries(dc.env).map(([k, v]) => `-e ${k}="${v}"`).join(' ');
|
|
248
|
+
const volumeArgs = dc.volumes.map(v => `-v ${v}`).join(' ');
|
|
249
|
+
const portArgs = dc.ports.map(p => `-p ${p}:${p}`).join(' ');
|
|
250
|
+
|
|
251
|
+
const runCmd = `docker run -d --name agenticmail-${config.name} --restart ${dc.restart} ${portArgs} ${volumeArgs} ${envArgs} ${dc.resources ? `--cpus="${dc.resources.cpuLimit}" --memory="${dc.resources.memoryLimit}"` : ''} ${dc.image}:${dc.tag}`;
|
|
252
|
+
const runResult = await this.execCommand(runCmd);
|
|
253
|
+
|
|
254
|
+
if (!runResult.success) {
|
|
255
|
+
throw new Error(`Container failed to start: ${runResult.message}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const containerId = runResult.message.trim().substring(0, 12);
|
|
259
|
+
emit('start', 'completed', `Container ${containerId} running`);
|
|
260
|
+
|
|
261
|
+
// Write workspace files into container
|
|
262
|
+
emit('upload', 'started', 'Writing workspace files...');
|
|
263
|
+
for (const [file, content] of Object.entries(workspace)) {
|
|
264
|
+
await this.execCommand(`docker exec agenticmail-${config.name} sh -c 'echo "${Buffer.from(content).toString('base64')}" | base64 -d > /workspace/${file}'`);
|
|
265
|
+
}
|
|
266
|
+
emit('upload', 'completed', 'Workspace configured');
|
|
267
|
+
|
|
268
|
+
// Health check
|
|
269
|
+
emit('healthcheck', 'started', 'Checking agent health...');
|
|
270
|
+
let healthy = false;
|
|
271
|
+
for (let i = 0; i < 10; i++) {
|
|
272
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
273
|
+
const check = await this.execCommand(`docker exec agenticmail-${config.name} openclaw status 2>/dev/null || echo "not ready"`);
|
|
274
|
+
if (check.success && !check.message.includes('not ready')) {
|
|
275
|
+
healthy = true;
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (healthy) {
|
|
281
|
+
emit('healthcheck', 'completed', 'Agent is healthy');
|
|
282
|
+
emit('complete', 'completed', `Agent "${config.displayName}" deployed successfully`);
|
|
283
|
+
} else {
|
|
284
|
+
emit('healthcheck', 'failed', 'Agent did not become healthy within 30s');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
success: healthy,
|
|
289
|
+
containerId,
|
|
290
|
+
url: `http://localhost:${dc.ports[0]}`,
|
|
291
|
+
events: [],
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ─── VPS Deployment ───────────────────────────────────
|
|
296
|
+
|
|
297
|
+
private async deployVPS(config: AgentConfig, emit: Function): Promise<DeploymentResult> {
|
|
298
|
+
const vps = config.deployment.config.vps;
|
|
299
|
+
if (!vps) throw new Error('VPS config missing');
|
|
300
|
+
|
|
301
|
+
// Generate deploy script
|
|
302
|
+
emit('provision', 'started', `Connecting to ${vps.host}...`);
|
|
303
|
+
const script = this.configGen.generateVPSDeployScript(config);
|
|
304
|
+
emit('provision', 'completed', 'Deploy script generated');
|
|
305
|
+
|
|
306
|
+
// Test SSH connection
|
|
307
|
+
emit('configure', 'started', 'Testing SSH connection...');
|
|
308
|
+
const sshTest = await this.execSSH(config, 'echo "ok"');
|
|
309
|
+
if (!sshTest.success) {
|
|
310
|
+
throw new Error(`SSH connection failed: ${sshTest.message}`);
|
|
311
|
+
}
|
|
312
|
+
emit('configure', 'completed', 'SSH connection verified');
|
|
313
|
+
|
|
314
|
+
// Upload and run deploy script
|
|
315
|
+
emit('upload', 'started', 'Uploading deployment script...');
|
|
316
|
+
const scriptB64 = Buffer.from(script).toString('base64');
|
|
317
|
+
await this.execSSH(config, `echo "${scriptB64}" | base64 -d > /tmp/deploy-agenticmail.sh && chmod +x /tmp/deploy-agenticmail.sh`);
|
|
318
|
+
emit('upload', 'completed', 'Script uploaded');
|
|
319
|
+
|
|
320
|
+
emit('install', 'started', 'Running deployment (this may take a few minutes)...');
|
|
321
|
+
const deployResult = await this.execSSH(config, 'bash /tmp/deploy-agenticmail.sh');
|
|
322
|
+
if (!deployResult.success) {
|
|
323
|
+
throw new Error(`Deployment script failed: ${deployResult.message}`);
|
|
324
|
+
}
|
|
325
|
+
emit('install', 'completed', 'Installation complete');
|
|
326
|
+
|
|
327
|
+
// Verify service is running
|
|
328
|
+
emit('healthcheck', 'started', 'Verifying service status...');
|
|
329
|
+
await new Promise(r => setTimeout(r, 5000));
|
|
330
|
+
const statusCheck = await this.execSSH(config, `systemctl is-active agenticmail-${config.name}`);
|
|
331
|
+
const isActive = statusCheck.success && statusCheck.message.trim() === 'active';
|
|
332
|
+
|
|
333
|
+
if (isActive) {
|
|
334
|
+
emit('healthcheck', 'completed', 'Service is active');
|
|
335
|
+
emit('complete', 'completed', `Agent deployed to ${vps.host}`);
|
|
336
|
+
} else {
|
|
337
|
+
emit('healthcheck', 'failed', 'Service not active');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
success: isActive,
|
|
342
|
+
sshCommand: `ssh ${vps.user}@${vps.host}${vps.port !== 22 ? ` -p ${vps.port}` : ''}`,
|
|
343
|
+
events: [],
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ─── Fly.io Deployment ────────────────────────────────
|
|
348
|
+
|
|
349
|
+
private async deployFly(config: AgentConfig, emit: Function): Promise<DeploymentResult> {
|
|
350
|
+
const cloud = config.deployment.config.cloud;
|
|
351
|
+
if (!cloud || cloud.provider !== 'fly') throw new Error('Fly.io config missing');
|
|
352
|
+
|
|
353
|
+
const appName = cloud.appName || `agenticmail-${config.name}`;
|
|
354
|
+
|
|
355
|
+
emit('provision', 'started', `Creating Fly.io app ${appName}...`);
|
|
356
|
+
await this.execCommand(`fly apps create ${appName} --org personal`, { FLY_API_TOKEN: cloud.apiToken });
|
|
357
|
+
emit('provision', 'completed', `App ${appName} created`);
|
|
358
|
+
|
|
359
|
+
// Generate Dockerfile
|
|
360
|
+
emit('configure', 'started', 'Generating Dockerfile...');
|
|
361
|
+
const dockerfile = this.generateDockerfile(config);
|
|
362
|
+
const workspace = this.configGen.generateWorkspace(config);
|
|
363
|
+
|
|
364
|
+
// Write temp build context
|
|
365
|
+
const buildDir = `/tmp/agenticmail-build-${config.name}`;
|
|
366
|
+
await this.execCommand(`mkdir -p ${buildDir}/workspace`);
|
|
367
|
+
await this.writeFile(`${buildDir}/Dockerfile`, dockerfile);
|
|
368
|
+
for (const [file, content] of Object.entries(workspace)) {
|
|
369
|
+
await this.writeFile(`${buildDir}/workspace/${file}`, content);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Write fly.toml
|
|
373
|
+
const flyToml = `
|
|
374
|
+
app = "${appName}"
|
|
375
|
+
primary_region = "${cloud.region || 'iad'}"
|
|
376
|
+
|
|
377
|
+
[build]
|
|
378
|
+
dockerfile = "Dockerfile"
|
|
379
|
+
|
|
380
|
+
[http_service]
|
|
381
|
+
internal_port = 3000
|
|
382
|
+
force_https = true
|
|
383
|
+
auto_stop_machines = true
|
|
384
|
+
auto_start_machines = true
|
|
385
|
+
min_machines_running = 1
|
|
386
|
+
|
|
387
|
+
[[vm]]
|
|
388
|
+
size = "${cloud.size || 'shared-cpu-1x'}"
|
|
389
|
+
memory = "512mb"
|
|
390
|
+
`;
|
|
391
|
+
await this.writeFile(`${buildDir}/fly.toml`, flyToml);
|
|
392
|
+
emit('configure', 'completed', 'Build context ready');
|
|
393
|
+
|
|
394
|
+
// Deploy
|
|
395
|
+
emit('install', 'started', 'Deploying to Fly.io (building + pushing)...');
|
|
396
|
+
const deployResult = await this.execCommand(`cd ${buildDir} && fly deploy --now`, { FLY_API_TOKEN: cloud.apiToken });
|
|
397
|
+
emit('install', deployResult.success ? 'completed' : 'failed', deployResult.message);
|
|
398
|
+
|
|
399
|
+
// Cleanup
|
|
400
|
+
await this.execCommand(`rm -rf ${buildDir}`);
|
|
401
|
+
|
|
402
|
+
const url = cloud.customDomain || `https://${appName}.fly.dev`;
|
|
403
|
+
|
|
404
|
+
if (deployResult.success) {
|
|
405
|
+
emit('complete', 'completed', `Agent live at ${url}`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
success: deployResult.success,
|
|
410
|
+
url,
|
|
411
|
+
appId: appName,
|
|
412
|
+
events: [],
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ─── Railway Deployment ───────────────────────────────
|
|
417
|
+
|
|
418
|
+
private async deployRailway(config: AgentConfig, emit: Function): Promise<DeploymentResult> {
|
|
419
|
+
const cloud = config.deployment.config.cloud;
|
|
420
|
+
if (!cloud || cloud.provider !== 'railway') throw new Error('Railway config missing');
|
|
421
|
+
|
|
422
|
+
emit('provision', 'started', 'Creating Railway project...');
|
|
423
|
+
// Railway CLI deployment
|
|
424
|
+
const appName = cloud.appName || `agenticmail-${config.name}`;
|
|
425
|
+
const result = await this.execCommand(`railway init --name ${appName}`, { RAILWAY_TOKEN: cloud.apiToken });
|
|
426
|
+
emit('provision', result.success ? 'completed' : 'failed', result.message);
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
success: result.success,
|
|
430
|
+
url: `https://${appName}.up.railway.app`,
|
|
431
|
+
appId: appName,
|
|
432
|
+
events: [],
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ─── Status Checkers ──────────────────────────────────
|
|
437
|
+
|
|
438
|
+
private async getDockerStatus(config: AgentConfig, base: LiveAgentStatus): Promise<LiveAgentStatus> {
|
|
439
|
+
const inspect = await this.execCommand(`docker inspect agenticmail-${config.name} --format '{{.State.Status}} {{.State.StartedAt}}'`);
|
|
440
|
+
if (!inspect.success) return { ...base, status: 'not-deployed' };
|
|
441
|
+
|
|
442
|
+
const [status, startedAt] = inspect.message.trim().split(' ');
|
|
443
|
+
const running = status === 'running';
|
|
444
|
+
const uptime = running ? Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000) : 0;
|
|
445
|
+
|
|
446
|
+
// Get resource usage
|
|
447
|
+
let metrics: LiveAgentStatus['metrics'] = undefined;
|
|
448
|
+
if (running) {
|
|
449
|
+
const stats = await this.execCommand(`docker stats agenticmail-${config.name} --no-stream --format '{{.CPUPerc}} {{.MemUsage}}'`);
|
|
450
|
+
if (stats.success) {
|
|
451
|
+
const parts = stats.message.trim().split(' ');
|
|
452
|
+
metrics = {
|
|
453
|
+
cpuPercent: parseFloat(parts[0]) || 0,
|
|
454
|
+
memoryMb: parseFloat(parts[1]) || 0,
|
|
455
|
+
toolCallsToday: 0,
|
|
456
|
+
activeSessionCount: 0,
|
|
457
|
+
errorRate: 0,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
...base,
|
|
464
|
+
status: running ? 'running' : 'stopped',
|
|
465
|
+
uptime,
|
|
466
|
+
healthStatus: running ? 'healthy' : 'unhealthy',
|
|
467
|
+
lastHealthCheck: new Date().toISOString(),
|
|
468
|
+
metrics,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private async getVPSStatus(config: AgentConfig, base: LiveAgentStatus): Promise<LiveAgentStatus> {
|
|
473
|
+
const result = await this.execSSH(config, `systemctl is-active agenticmail-${config.name}`);
|
|
474
|
+
const active = result.success && result.message.trim() === 'active';
|
|
475
|
+
|
|
476
|
+
let uptime = 0;
|
|
477
|
+
if (active) {
|
|
478
|
+
const uptimeResult = await this.execSSH(config, `systemctl show agenticmail-${config.name} --property=ActiveEnterTimestamp --value`);
|
|
479
|
+
if (uptimeResult.success) {
|
|
480
|
+
uptime = Math.floor((Date.now() - new Date(uptimeResult.message.trim()).getTime()) / 1000);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return {
|
|
485
|
+
...base,
|
|
486
|
+
status: active ? 'running' : 'stopped',
|
|
487
|
+
uptime,
|
|
488
|
+
healthStatus: active ? 'healthy' : 'unhealthy',
|
|
489
|
+
lastHealthCheck: new Date().toISOString(),
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private async getCloudStatus(config: AgentConfig, base: LiveAgentStatus): Promise<LiveAgentStatus> {
|
|
494
|
+
const cloud = config.deployment.config.cloud;
|
|
495
|
+
if (!cloud) return base;
|
|
496
|
+
|
|
497
|
+
const appName = cloud.appName || `agenticmail-${config.name}`;
|
|
498
|
+
const result = await this.execCommand(`fly status -a ${appName} --json`, { FLY_API_TOKEN: cloud.apiToken });
|
|
499
|
+
|
|
500
|
+
if (!result.success) return { ...base, status: 'error' };
|
|
501
|
+
|
|
502
|
+
try {
|
|
503
|
+
const status = JSON.parse(result.message);
|
|
504
|
+
return {
|
|
505
|
+
...base,
|
|
506
|
+
status: status.Deployed ? 'running' : 'stopped',
|
|
507
|
+
healthStatus: status.Deployed ? 'healthy' : 'unhealthy',
|
|
508
|
+
endpoint: `https://${appName}.fly.dev`,
|
|
509
|
+
version: status.Version?.toString(),
|
|
510
|
+
};
|
|
511
|
+
} catch {
|
|
512
|
+
return { ...base, status: 'error' };
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ─── Helpers ──────────────────────────────────────────
|
|
517
|
+
|
|
518
|
+
private validateConfig(config: AgentConfig) {
|
|
519
|
+
if (!config.name) throw new Error('Agent name is required');
|
|
520
|
+
if (!config.identity.role) throw new Error('Agent role is required');
|
|
521
|
+
if (!config.model.modelId) throw new Error('Model ID is required');
|
|
522
|
+
if (!config.deployment.target) throw new Error('Deployment target is required');
|
|
523
|
+
|
|
524
|
+
switch (config.deployment.target) {
|
|
525
|
+
case 'docker':
|
|
526
|
+
if (!config.deployment.config.docker) throw new Error('Docker configuration missing');
|
|
527
|
+
break;
|
|
528
|
+
case 'vps':
|
|
529
|
+
if (!config.deployment.config.vps?.host) throw new Error('VPS host is required');
|
|
530
|
+
break;
|
|
531
|
+
case 'fly':
|
|
532
|
+
case 'railway':
|
|
533
|
+
if (!config.deployment.config.cloud?.apiToken) throw new Error('Cloud API token is required');
|
|
534
|
+
break;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private generateDockerfile(config: AgentConfig): string {
|
|
539
|
+
return `FROM node:22-slim
|
|
540
|
+
|
|
541
|
+
WORKDIR /app
|
|
542
|
+
|
|
543
|
+
RUN npm install -g openclaw agenticmail @agenticmail/core @agenticmail/openclaw
|
|
544
|
+
|
|
545
|
+
COPY workspace/ /workspace/
|
|
546
|
+
|
|
547
|
+
ENV NODE_ENV=production
|
|
548
|
+
ENV OPENCLAW_MODEL=${config.model.provider}/${config.model.modelId}
|
|
549
|
+
ENV OPENCLAW_THINKING=${config.model.thinkingLevel}
|
|
550
|
+
|
|
551
|
+
EXPOSE 3000
|
|
552
|
+
|
|
553
|
+
CMD ["openclaw", "gateway", "start"]
|
|
554
|
+
`;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private async execCommand(cmd: string, env?: Record<string, string>): Promise<{ success: boolean; message: string }> {
|
|
558
|
+
const { exec } = await import('child_process');
|
|
559
|
+
const { promisify } = await import('util');
|
|
560
|
+
const execAsync = promisify(exec);
|
|
561
|
+
|
|
562
|
+
try {
|
|
563
|
+
const { stdout, stderr } = await execAsync(cmd, {
|
|
564
|
+
timeout: 300_000, // 5 min max
|
|
565
|
+
env: { ...process.env, ...env },
|
|
566
|
+
});
|
|
567
|
+
return { success: true, message: stdout || stderr };
|
|
568
|
+
} catch (error: any) {
|
|
569
|
+
return { success: false, message: error.stderr || error.message };
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private async execSSH(config: AgentConfig, command: string): Promise<{ success: boolean; message: string }> {
|
|
574
|
+
const vps = config.deployment.config.vps;
|
|
575
|
+
if (!vps) return { success: false, message: 'No VPS config' };
|
|
576
|
+
|
|
577
|
+
const sshArgs = [
|
|
578
|
+
'-o StrictHostKeyChecking=no',
|
|
579
|
+
`-p ${vps.port || 22}`,
|
|
580
|
+
vps.sshKeyPath ? `-i ${vps.sshKeyPath}` : '',
|
|
581
|
+
`${vps.user}@${vps.host}`,
|
|
582
|
+
`"${command.replace(/"/g, '\\"')}"`,
|
|
583
|
+
].filter(Boolean).join(' ');
|
|
584
|
+
|
|
585
|
+
return this.execCommand(`ssh ${sshArgs}`);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
private async writeFile(path: string, content: string): Promise<void> {
|
|
589
|
+
const { writeFile } = await import('fs/promises');
|
|
590
|
+
const { dirname } = await import('path');
|
|
591
|
+
const { mkdir } = await import('fs/promises');
|
|
592
|
+
await mkdir(dirname(path), { recursive: true });
|
|
593
|
+
await writeFile(path, content, 'utf-8');
|
|
594
|
+
}
|
|
595
|
+
}
|