@alfe.ai/gateway 0.0.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/README.md +163 -0
- package/dist/bin/gateway.d.ts +1 -0
- package/dist/bin/gateway.js +125 -0
- package/dist/health.js +1846 -0
- package/dist/src/index.d.ts +164 -0
- package/dist/src/index.js +2 -0
- package/package.json +44 -0
package/dist/health.js
ADDED
|
@@ -0,0 +1,1846 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import pino from "pino";
|
|
6
|
+
import { chmodSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
|
|
7
|
+
import { getEndpointFromToken, readConfig } from "@alfe.ai/config";
|
|
8
|
+
import { parse } from "smol-toml";
|
|
9
|
+
import WebSocket from "ws";
|
|
10
|
+
import { createConnection, createServer } from "node:net";
|
|
11
|
+
import { execSync } from "node:child_process";
|
|
12
|
+
import { IntegrationManager, IntegrationManagerAdapter, OpenClawApplier } from "@alfe.ai/integrations";
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
//#region \0rolldown/runtime.js
|
|
15
|
+
var __commonJSMin = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
|
16
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/logger.ts
|
|
19
|
+
/**
|
|
20
|
+
* Daemon logger — pino with rolling file output.
|
|
21
|
+
*
|
|
22
|
+
* Writes to ~/.alfe/logs/gateway.log with 10MB rotation, keep 5 files.
|
|
23
|
+
* Also outputs to stdout in development mode.
|
|
24
|
+
*/
|
|
25
|
+
const LOG_DIR = join(homedir(), ".alfe", "logs");
|
|
26
|
+
const LOG_FILE = join(LOG_DIR, "gateway.log");
|
|
27
|
+
try {
|
|
28
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
29
|
+
} catch {}
|
|
30
|
+
const isDev = process.env.NODE_ENV !== "production";
|
|
31
|
+
/**
|
|
32
|
+
* Create the daemon logger.
|
|
33
|
+
* In development: pretty-prints to stdout.
|
|
34
|
+
* In production: JSON to rolling file.
|
|
35
|
+
*/
|
|
36
|
+
function createLogger() {
|
|
37
|
+
if (isDev) return pino({
|
|
38
|
+
level: process.env.LOG_LEVEL ?? "debug",
|
|
39
|
+
transport: {
|
|
40
|
+
target: "pino/file",
|
|
41
|
+
options: { destination: 1 }
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
const transport = pino.transport({
|
|
45
|
+
target: "pino-roll",
|
|
46
|
+
options: {
|
|
47
|
+
file: LOG_FILE,
|
|
48
|
+
size: "10m",
|
|
49
|
+
limit: { count: 5 }
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
return pino({ level: process.env.LOG_LEVEL ?? "info" }, transport);
|
|
53
|
+
}
|
|
54
|
+
const logger = createLogger();
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/config.ts
|
|
57
|
+
/**
|
|
58
|
+
* Daemon configuration — reads ~/.alfe/config.toml and resolves agent identity.
|
|
59
|
+
*
|
|
60
|
+
* Bootstrap flow:
|
|
61
|
+
* 1. Read api_key from ~/.alfe/config.toml (via @alfe.ai/config)
|
|
62
|
+
* 2. Derive API endpoint from token prefix
|
|
63
|
+
* 3. Call /auth/validate to get tenantId (orgId)
|
|
64
|
+
* 4. Use tokenId as agent identity for cloud registration
|
|
65
|
+
*/
|
|
66
|
+
const ALFE_DIR = join(homedir(), ".alfe");
|
|
67
|
+
const SOCKET_PATH = join(ALFE_DIR, "gateway.sock");
|
|
68
|
+
const PID_PATH = join(ALFE_DIR, "gateway.pid");
|
|
69
|
+
/**
|
|
70
|
+
* Resolve agent identity by validating the api_key with the auth service.
|
|
71
|
+
* Returns agentId (derived from tokenId) and orgId (tenantId).
|
|
72
|
+
*/
|
|
73
|
+
async function resolveAgentIdentity(apiKey, apiEndpoint) {
|
|
74
|
+
const validateUrl = `${apiEndpoint}/auth/validate`;
|
|
75
|
+
const response = await fetch(validateUrl, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { "Content-Type": "application/json" },
|
|
78
|
+
body: JSON.stringify({ token: apiKey })
|
|
79
|
+
});
|
|
80
|
+
if (!response.ok) throw new Error(`Token validation failed (HTTP ${String(response.status)}). Is your API key valid? Run \`alfe login\` to reconfigure.`);
|
|
81
|
+
const result = await response.json();
|
|
82
|
+
if (!result.success || !result.data?.valid) throw new Error(`API key invalid: ${result.data?.error ?? "validation failed"}. Run \`alfe login\` to reconfigure.`);
|
|
83
|
+
const orgId = result.data.tenantId;
|
|
84
|
+
if (!orgId) throw new Error("Token validation returned no tenantId — cannot determine org.");
|
|
85
|
+
return {
|
|
86
|
+
agentId: result.data.tokenId ?? deriveAgentId(apiKey),
|
|
87
|
+
orgId,
|
|
88
|
+
runtime: result.data.runtime ?? "openclaw"
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Derive a stable agent ID from an API key (fallback when tokenId not available).
|
|
93
|
+
*/
|
|
94
|
+
function deriveAgentId(apiKey) {
|
|
95
|
+
let hash = 0;
|
|
96
|
+
for (let i = 0; i < apiKey.length; i++) {
|
|
97
|
+
const chr = apiKey.charCodeAt(i);
|
|
98
|
+
hash = (hash << 5) - hash + chr | 0;
|
|
99
|
+
}
|
|
100
|
+
return `agent-${Math.abs(hash).toString(36)}`;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Derive the cloud gateway WebSocket URL from the API endpoint.
|
|
104
|
+
* The cloud gateway runs on Fly.io — its URL follows a convention.
|
|
105
|
+
*/
|
|
106
|
+
function deriveGatewayWsUrl(apiEndpoint) {
|
|
107
|
+
if (apiEndpoint.includes("dev.alfe.ai")) return process.env.ALFE_GATEWAY_WS_URL ?? "wss://gateway.dev.alfe.ai/ws";
|
|
108
|
+
if (apiEndpoint.includes("test.alfe.ai")) return process.env.ALFE_GATEWAY_WS_URL ?? "wss://gateway.test.alfe.ai/ws";
|
|
109
|
+
return process.env.ALFE_GATEWAY_WS_URL ?? "wss://gateway.alfe.ai/ws";
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Parse [runtimes.*] sections from config.toml.
|
|
113
|
+
* Returns a map of runtime name → RuntimeConfig.
|
|
114
|
+
*
|
|
115
|
+
* Example config.toml:
|
|
116
|
+
* [runtimes.openclaw]
|
|
117
|
+
* workspace = "~/.openclaw"
|
|
118
|
+
*/
|
|
119
|
+
async function loadRuntimeConfigs() {
|
|
120
|
+
const configPath = join(ALFE_DIR, "config.toml");
|
|
121
|
+
if (!existsSync(configPath)) return {};
|
|
122
|
+
try {
|
|
123
|
+
const runtimes = parse(await readFile(configPath, "utf-8")).runtimes;
|
|
124
|
+
if (!runtimes) return {};
|
|
125
|
+
const result = {};
|
|
126
|
+
for (const [name, cfg] of Object.entries(runtimes)) if (typeof cfg.workspace === "string") result[name] = { workspace: cfg.workspace.startsWith("~/") ? join(homedir(), cfg.workspace.slice(2)) : cfg.workspace };
|
|
127
|
+
return result;
|
|
128
|
+
} catch {
|
|
129
|
+
return {};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Whether the daemon is running in managed mode (ECS Fargate container).
|
|
134
|
+
* In managed mode, configuration comes from environment variables instead
|
|
135
|
+
* of ~/.alfe/config.toml, and IPC/PID features are disabled.
|
|
136
|
+
*/
|
|
137
|
+
function isManagedMode() {
|
|
138
|
+
return process.env.ALFE_MANAGED === "true";
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Load configuration for managed mode (ECS Fargate).
|
|
142
|
+
* All config comes from environment variables — no local files needed.
|
|
143
|
+
*/
|
|
144
|
+
async function loadManagedConfig() {
|
|
145
|
+
const apiKey = process.env.ALFE_API_KEY;
|
|
146
|
+
const apiEndpoint = process.env.ALFE_API_ENDPOINT;
|
|
147
|
+
if (!apiKey || !apiEndpoint) throw new Error("ALFE_API_KEY and ALFE_API_ENDPOINT required in managed mode");
|
|
148
|
+
const identity = await resolveAgentIdentity(apiKey, apiEndpoint);
|
|
149
|
+
return {
|
|
150
|
+
apiKey,
|
|
151
|
+
apiEndpoint,
|
|
152
|
+
gatewayWsUrl: process.env.ALFE_GATEWAY_WS_URL ?? deriveGatewayWsUrl(apiEndpoint),
|
|
153
|
+
socketPath: "",
|
|
154
|
+
pidPath: "",
|
|
155
|
+
agentId: identity.agentId,
|
|
156
|
+
orgId: identity.orgId,
|
|
157
|
+
runtime: identity.runtime,
|
|
158
|
+
runtimes: identity.runtime === "openclaw" ? { openclaw: { workspace: join(homedir(), ".openclaw") } } : {}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Load full daemon configuration.
|
|
163
|
+
* Reads config.toml, validates the API key, resolves agent identity,
|
|
164
|
+
* and parses runtime configurations.
|
|
165
|
+
*
|
|
166
|
+
* In managed mode, loads from environment variables instead.
|
|
167
|
+
*/
|
|
168
|
+
async function loadDaemonConfig() {
|
|
169
|
+
if (isManagedMode()) return loadManagedConfig();
|
|
170
|
+
let alfeConfig;
|
|
171
|
+
try {
|
|
172
|
+
alfeConfig = await readConfig();
|
|
173
|
+
} catch {
|
|
174
|
+
throw new Error("Alfe not configured. Run `alfe login` first.");
|
|
175
|
+
}
|
|
176
|
+
const apiEndpoint = alfeConfig.gateway ?? getEndpointFromToken(alfeConfig.api_key);
|
|
177
|
+
const identity = await resolveAgentIdentity(alfeConfig.api_key, apiEndpoint);
|
|
178
|
+
const gatewayWsUrl = alfeConfig.gateway_url ?? deriveGatewayWsUrl(apiEndpoint);
|
|
179
|
+
const runtimes = await loadRuntimeConfigs();
|
|
180
|
+
return {
|
|
181
|
+
apiKey: alfeConfig.api_key,
|
|
182
|
+
apiEndpoint,
|
|
183
|
+
gatewayWsUrl,
|
|
184
|
+
socketPath: process.env.ALFE_GATEWAY_SOCKET ?? SOCKET_PATH,
|
|
185
|
+
pidPath: PID_PATH,
|
|
186
|
+
agentId: identity.agentId,
|
|
187
|
+
orgId: identity.orgId,
|
|
188
|
+
runtime: identity.runtime,
|
|
189
|
+
runtimes
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Fetch agent workspace config from the API.
|
|
194
|
+
*
|
|
195
|
+
* 1. GET /agents/me/workspace → { personalityKey }
|
|
196
|
+
* 2. If personalityKey set, GET /personalities/:key/files → persona file contents
|
|
197
|
+
*
|
|
198
|
+
* Returns null if the agent has no personality assigned or the fetch fails.
|
|
199
|
+
*/
|
|
200
|
+
async function fetchAgentConfig(apiKey, apiEndpoint) {
|
|
201
|
+
try {
|
|
202
|
+
const wsResponse = await fetch(`${apiEndpoint}/agents/me/workspace`, {
|
|
203
|
+
method: "GET",
|
|
204
|
+
headers: { "Authorization": `Bearer ${apiKey}` }
|
|
205
|
+
});
|
|
206
|
+
if (!wsResponse.ok) return null;
|
|
207
|
+
const personalityKey = (await wsResponse.json()).data?.personalityKey;
|
|
208
|
+
if (!personalityKey) return null;
|
|
209
|
+
const filesResponse = await fetch(`${apiEndpoint}/personalities/${encodeURIComponent(personalityKey)}/files`, {
|
|
210
|
+
method: "GET",
|
|
211
|
+
headers: { "Authorization": `Bearer ${apiKey}` }
|
|
212
|
+
});
|
|
213
|
+
if (!filesResponse.ok) return {
|
|
214
|
+
personalityKey,
|
|
215
|
+
files: {}
|
|
216
|
+
};
|
|
217
|
+
return {
|
|
218
|
+
personalityKey,
|
|
219
|
+
files: (await filesResponse.json()).data?.files ?? {}
|
|
220
|
+
};
|
|
221
|
+
} catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
//#endregion
|
|
226
|
+
//#region ../../packages-internal/ids/dist/prefixes.js
|
|
227
|
+
const ID_PREFIXES = {
|
|
228
|
+
agent: "agt",
|
|
229
|
+
organization: "org",
|
|
230
|
+
person: "per",
|
|
231
|
+
auditEvent: "evt",
|
|
232
|
+
token: "tok",
|
|
233
|
+
transaction: "txn",
|
|
234
|
+
subscription: "sub",
|
|
235
|
+
request: "req",
|
|
236
|
+
connection: "conn",
|
|
237
|
+
correlation: "cor",
|
|
238
|
+
command: "cmd",
|
|
239
|
+
message: "msg",
|
|
240
|
+
ipcRequest: "ipc",
|
|
241
|
+
pluginConnection: "plg"
|
|
242
|
+
};
|
|
243
|
+
//#endregion
|
|
244
|
+
//#region ../../packages-internal/ids/dist/create.js
|
|
245
|
+
var import_index_umd = (/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
246
|
+
(function(global, factory) {
|
|
247
|
+
typeof exports === "object" && typeof module !== "undefined" ? factory(exports) : typeof define === "function" && define.amd ? define(["exports"], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, factory(global.ULID = {}));
|
|
248
|
+
})(exports, (function(exports$1) {
|
|
249
|
+
"use strict";
|
|
250
|
+
function createError(message) {
|
|
251
|
+
const err = new Error(message);
|
|
252
|
+
err.source = "ulid";
|
|
253
|
+
return err;
|
|
254
|
+
}
|
|
255
|
+
const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
256
|
+
const ENCODING_LEN = 32;
|
|
257
|
+
const TIME_MAX = Math.pow(2, 48) - 1;
|
|
258
|
+
const TIME_LEN = 10;
|
|
259
|
+
const RANDOM_LEN = 16;
|
|
260
|
+
function replaceCharAt(str, index, char) {
|
|
261
|
+
if (index > str.length - 1) return str;
|
|
262
|
+
return str.substr(0, index) + char + str.substr(index + 1);
|
|
263
|
+
}
|
|
264
|
+
function incrementBase32(str) {
|
|
265
|
+
let done = void 0;
|
|
266
|
+
let index = str.length;
|
|
267
|
+
let char;
|
|
268
|
+
let charIndex;
|
|
269
|
+
const maxCharIndex = ENCODING_LEN - 1;
|
|
270
|
+
while (!done && index-- >= 0) {
|
|
271
|
+
char = str[index];
|
|
272
|
+
charIndex = ENCODING.indexOf(char);
|
|
273
|
+
if (charIndex === -1) throw createError("incorrectly encoded string");
|
|
274
|
+
if (charIndex === maxCharIndex) {
|
|
275
|
+
str = replaceCharAt(str, index, ENCODING[0]);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
done = replaceCharAt(str, index, ENCODING[charIndex + 1]);
|
|
279
|
+
}
|
|
280
|
+
if (typeof done === "string") return done;
|
|
281
|
+
throw createError("cannot increment this string");
|
|
282
|
+
}
|
|
283
|
+
function randomChar(prng) {
|
|
284
|
+
let rand = Math.floor(prng() * ENCODING_LEN);
|
|
285
|
+
if (rand === ENCODING_LEN) rand = ENCODING_LEN - 1;
|
|
286
|
+
return ENCODING.charAt(rand);
|
|
287
|
+
}
|
|
288
|
+
function encodeTime(now, len) {
|
|
289
|
+
if (isNaN(now)) throw new Error(now + " must be a number");
|
|
290
|
+
if (now > TIME_MAX) throw createError("cannot encode time greater than " + TIME_MAX);
|
|
291
|
+
if (now < 0) throw createError("time must be positive");
|
|
292
|
+
if (Number.isInteger(Number(now)) === false) throw createError("time must be an integer");
|
|
293
|
+
let mod;
|
|
294
|
+
let str = "";
|
|
295
|
+
for (; len > 0; len--) {
|
|
296
|
+
mod = now % ENCODING_LEN;
|
|
297
|
+
str = ENCODING.charAt(mod) + str;
|
|
298
|
+
now = (now - mod) / ENCODING_LEN;
|
|
299
|
+
}
|
|
300
|
+
return str;
|
|
301
|
+
}
|
|
302
|
+
function encodeRandom(len, prng) {
|
|
303
|
+
let str = "";
|
|
304
|
+
for (; len > 0; len--) str = randomChar(prng) + str;
|
|
305
|
+
return str;
|
|
306
|
+
}
|
|
307
|
+
function decodeTime(id) {
|
|
308
|
+
if (id.length !== TIME_LEN + RANDOM_LEN) throw createError("malformed ulid");
|
|
309
|
+
var time = id.substr(0, TIME_LEN).split("").reverse().reduce((carry, char, index) => {
|
|
310
|
+
const encodingIndex = ENCODING.indexOf(char);
|
|
311
|
+
if (encodingIndex === -1) throw createError("invalid character found: " + char);
|
|
312
|
+
return carry += encodingIndex * Math.pow(ENCODING_LEN, index);
|
|
313
|
+
}, 0);
|
|
314
|
+
if (time > TIME_MAX) throw createError("malformed ulid, timestamp too large");
|
|
315
|
+
return time;
|
|
316
|
+
}
|
|
317
|
+
function detectPrng(allowInsecure = false, root) {
|
|
318
|
+
if (!root) root = typeof window !== "undefined" ? window : null;
|
|
319
|
+
const browserCrypto = root && (root.crypto || root.msCrypto);
|
|
320
|
+
if (browserCrypto) return () => {
|
|
321
|
+
const buffer = new Uint8Array(1);
|
|
322
|
+
browserCrypto.getRandomValues(buffer);
|
|
323
|
+
return buffer[0] / 255;
|
|
324
|
+
};
|
|
325
|
+
else try {
|
|
326
|
+
const nodeCrypto = __require("crypto");
|
|
327
|
+
return () => nodeCrypto.randomBytes(1).readUInt8() / 255;
|
|
328
|
+
} catch (e) {}
|
|
329
|
+
if (allowInsecure) {
|
|
330
|
+
try {
|
|
331
|
+
console.error("secure crypto unusable, falling back to insecure Math.random()!");
|
|
332
|
+
} catch (e) {}
|
|
333
|
+
return () => Math.random();
|
|
334
|
+
}
|
|
335
|
+
throw createError("secure crypto unusable, insecure Math.random not allowed");
|
|
336
|
+
}
|
|
337
|
+
function factory(currPrng) {
|
|
338
|
+
if (!currPrng) currPrng = detectPrng();
|
|
339
|
+
return function ulid(seedTime) {
|
|
340
|
+
if (isNaN(seedTime)) seedTime = Date.now();
|
|
341
|
+
return encodeTime(seedTime, TIME_LEN) + encodeRandom(RANDOM_LEN, currPrng);
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
function monotonicFactory(currPrng) {
|
|
345
|
+
if (!currPrng) currPrng = detectPrng();
|
|
346
|
+
let lastTime = 0;
|
|
347
|
+
let lastRandom;
|
|
348
|
+
return function ulid(seedTime) {
|
|
349
|
+
if (isNaN(seedTime)) seedTime = Date.now();
|
|
350
|
+
if (seedTime <= lastTime) {
|
|
351
|
+
const incrementedRandom = lastRandom = incrementBase32(lastRandom);
|
|
352
|
+
return encodeTime(lastTime, TIME_LEN) + incrementedRandom;
|
|
353
|
+
}
|
|
354
|
+
lastTime = seedTime;
|
|
355
|
+
const newRandom = lastRandom = encodeRandom(RANDOM_LEN, currPrng);
|
|
356
|
+
return encodeTime(seedTime, TIME_LEN) + newRandom;
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
const ulid = factory();
|
|
360
|
+
exports$1.decodeTime = decodeTime;
|
|
361
|
+
exports$1.detectPrng = detectPrng;
|
|
362
|
+
exports$1.encodeRandom = encodeRandom;
|
|
363
|
+
exports$1.encodeTime = encodeTime;
|
|
364
|
+
exports$1.factory = factory;
|
|
365
|
+
exports$1.incrementBase32 = incrementBase32;
|
|
366
|
+
exports$1.monotonicFactory = monotonicFactory;
|
|
367
|
+
exports$1.randomChar = randomChar;
|
|
368
|
+
exports$1.replaceCharAt = replaceCharAt;
|
|
369
|
+
exports$1.ulid = ulid;
|
|
370
|
+
}));
|
|
371
|
+
})))();
|
|
372
|
+
function createId(prefix) {
|
|
373
|
+
return `${prefix}_${(0, import_index_umd.ulid)()}`;
|
|
374
|
+
}
|
|
375
|
+
function pluginConnectionId() {
|
|
376
|
+
return createId(ID_PREFIXES.pluginConnection);
|
|
377
|
+
}
|
|
378
|
+
//#endregion
|
|
379
|
+
//#region src/protocol.ts
|
|
380
|
+
/**
|
|
381
|
+
* Map a cloud command name to an IPC method name.
|
|
382
|
+
* Cloud commands use dot-notation matching IPC methods.
|
|
383
|
+
*
|
|
384
|
+
* Currently empty — Phase 1 has no daemon-to-plugin commands.
|
|
385
|
+
* Commands will be added here as the protocol evolves.
|
|
386
|
+
*/
|
|
387
|
+
const CLOUD_COMMAND_TO_IPC = {};
|
|
388
|
+
/**
|
|
389
|
+
* Translate a cloud COMMAND into a local IPC request.
|
|
390
|
+
* Returns null if the command isn't recognized.
|
|
391
|
+
*/
|
|
392
|
+
function cloudCommandToIPCRequest(command) {
|
|
393
|
+
const method = CLOUD_COMMAND_TO_IPC[command.command];
|
|
394
|
+
if (!method) return null;
|
|
395
|
+
return {
|
|
396
|
+
type: "req",
|
|
397
|
+
id: command.commandId,
|
|
398
|
+
method,
|
|
399
|
+
params: {
|
|
400
|
+
...typeof command.payload === "object" && command.payload !== null ? command.payload : {},
|
|
401
|
+
_agentId: command.agentId,
|
|
402
|
+
_commandId: command.commandId
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Translate a local IPC response back into a cloud COMMAND_ACK.
|
|
408
|
+
*/
|
|
409
|
+
function ipcResponseToCloudAck(commandId, response) {
|
|
410
|
+
return {
|
|
411
|
+
type: "COMMAND_ACK",
|
|
412
|
+
commandId,
|
|
413
|
+
status: response.ok ? "ok" : "error",
|
|
414
|
+
result: response.ok ? response.payload : response.error
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Create a SERVICE_REGISTER message for cloud registration.
|
|
419
|
+
*/
|
|
420
|
+
function createServiceRegister(agentId, capabilities = [
|
|
421
|
+
"integrations",
|
|
422
|
+
"lifecycle",
|
|
423
|
+
"health"
|
|
424
|
+
]) {
|
|
425
|
+
return {
|
|
426
|
+
type: "SERVICE_REGISTER",
|
|
427
|
+
serviceId: `gateway-daemon-${agentId}`,
|
|
428
|
+
agentIds: [agentId],
|
|
429
|
+
capabilities
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Create an IPC success response.
|
|
434
|
+
*/
|
|
435
|
+
function createIPCResponse(id, payload) {
|
|
436
|
+
return {
|
|
437
|
+
id,
|
|
438
|
+
ok: true,
|
|
439
|
+
payload
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Create an IPC error response.
|
|
444
|
+
*/
|
|
445
|
+
function createIPCError(id, code, message) {
|
|
446
|
+
return {
|
|
447
|
+
id,
|
|
448
|
+
ok: false,
|
|
449
|
+
error: {
|
|
450
|
+
code,
|
|
451
|
+
message
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Create an IPC event message.
|
|
457
|
+
*/
|
|
458
|
+
function createIPCEvent(event, payload) {
|
|
459
|
+
return {
|
|
460
|
+
type: "event",
|
|
461
|
+
event,
|
|
462
|
+
payload
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Parse a raw JSON string into a typed message, or null on failure.
|
|
467
|
+
*/
|
|
468
|
+
function parseMessage(raw) {
|
|
469
|
+
try {
|
|
470
|
+
return JSON.parse(raw);
|
|
471
|
+
} catch {
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Type guard: is this a cloud COMMAND message?
|
|
477
|
+
*/
|
|
478
|
+
function isCloudCommand(msg) {
|
|
479
|
+
return typeof msg === "object" && msg !== null && msg.type === "COMMAND" && typeof msg.commandId === "string";
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Type guard: is this a cloud SERVICE_ACK message?
|
|
483
|
+
*/
|
|
484
|
+
function isCloudServiceAck(msg) {
|
|
485
|
+
return typeof msg === "object" && msg !== null && msg.type === "SERVICE_ACK";
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Type guard: is this a cloud DESIRED_STATE message?
|
|
489
|
+
*/
|
|
490
|
+
function isCloudDesiredState(msg) {
|
|
491
|
+
return typeof msg === "object" && msg !== null && msg.type === "DESIRED_STATE" && Array.isArray(msg.integrations);
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Create a RECONCILIATION_REPORT message.
|
|
495
|
+
*/
|
|
496
|
+
function createReconciliationReport(results) {
|
|
497
|
+
return {
|
|
498
|
+
type: "RECONCILIATION_REPORT",
|
|
499
|
+
results
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Type guard: is this a cloud PING message?
|
|
504
|
+
*/
|
|
505
|
+
function isCloudPing(msg) {
|
|
506
|
+
return typeof msg === "object" && msg !== null && msg.type === "PING";
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Type guard: is this an IPC request?
|
|
510
|
+
*/
|
|
511
|
+
function isIPCRequest(msg) {
|
|
512
|
+
return typeof msg === "object" && msg !== null && msg.type === "req" && typeof msg.id === "string" && typeof msg.method === "string";
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Type guard: is this an IPC response?
|
|
516
|
+
*/
|
|
517
|
+
function isIPCResponse(msg) {
|
|
518
|
+
return typeof msg === "object" && msg !== null && typeof msg.id === "string" && typeof msg.ok === "boolean" && !("type" in msg);
|
|
519
|
+
}
|
|
520
|
+
/** Current IPC protocol version */
|
|
521
|
+
const PROTOCOL_VERSION = 1;
|
|
522
|
+
//#endregion
|
|
523
|
+
//#region src/reconciliation.ts
|
|
524
|
+
const log = logger.child({ component: "Reconciliation" });
|
|
525
|
+
var ReconciliationEngine = class {
|
|
526
|
+
constructor(manager) {
|
|
527
|
+
this.manager = manager;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Reconcile local state against desired state from cloud.
|
|
531
|
+
*/
|
|
532
|
+
async reconcile(desiredIntegrations) {
|
|
533
|
+
const report = {
|
|
534
|
+
results: [],
|
|
535
|
+
installed: [],
|
|
536
|
+
activated: [],
|
|
537
|
+
deactivated: [],
|
|
538
|
+
uninstalled: [],
|
|
539
|
+
errors: []
|
|
540
|
+
};
|
|
541
|
+
let localIntegrations;
|
|
542
|
+
try {
|
|
543
|
+
localIntegrations = await this.manager.getInstalledIntegrations();
|
|
544
|
+
} catch (err) {
|
|
545
|
+
log.error({ err }, "Failed to get local integrations");
|
|
546
|
+
localIntegrations = [];
|
|
547
|
+
}
|
|
548
|
+
const localMap = new Map(localIntegrations.map((i) => [i.id, i]));
|
|
549
|
+
const desiredMap = new Map(desiredIntegrations.map((d) => [d.integrationId, d]));
|
|
550
|
+
for (const desired of desiredIntegrations) if (desired.desiredStatus === "active") await this.reconcileActive(desired, localMap.get(desired.integrationId), report);
|
|
551
|
+
else await this.reconcileRemoved(desired.integrationId, localMap.get(desired.integrationId), report);
|
|
552
|
+
for (const local of localIntegrations) if (!desiredMap.has(local.id)) await this.reconcileRemoved(local.id, local, report);
|
|
553
|
+
return report;
|
|
554
|
+
}
|
|
555
|
+
async reconcileActive(desired, local, report) {
|
|
556
|
+
const id = desired.integrationId;
|
|
557
|
+
try {
|
|
558
|
+
if (!local) {
|
|
559
|
+
log.info(`Installing ${id}@${desired.version}`);
|
|
560
|
+
await this.manager.install(id, desired.version, desired.config);
|
|
561
|
+
report.installed.push(id);
|
|
562
|
+
report.results.push({
|
|
563
|
+
integrationId: id,
|
|
564
|
+
action: "installed",
|
|
565
|
+
actualStatus: "active"
|
|
566
|
+
});
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (local.version !== desired.version) {
|
|
570
|
+
log.info(`Upgrading ${id}: ${local.version} → ${desired.version}`);
|
|
571
|
+
try {
|
|
572
|
+
await this.manager.deactivate(id);
|
|
573
|
+
await this.manager.uninstall(id);
|
|
574
|
+
} catch {}
|
|
575
|
+
await this.manager.install(id, desired.version, desired.config);
|
|
576
|
+
report.installed.push(id);
|
|
577
|
+
report.results.push({
|
|
578
|
+
integrationId: id,
|
|
579
|
+
action: "installed",
|
|
580
|
+
actualStatus: "active"
|
|
581
|
+
});
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
if (local.status !== "active") {
|
|
585
|
+
log.info(`Activating ${id}`);
|
|
586
|
+
await this.manager.activate(id);
|
|
587
|
+
report.activated.push(id);
|
|
588
|
+
report.results.push({
|
|
589
|
+
integrationId: id,
|
|
590
|
+
action: "activated",
|
|
591
|
+
actualStatus: "active"
|
|
592
|
+
});
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
report.results.push({
|
|
596
|
+
integrationId: id,
|
|
597
|
+
action: "up_to_date",
|
|
598
|
+
actualStatus: "active"
|
|
599
|
+
});
|
|
600
|
+
} catch (err) {
|
|
601
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
602
|
+
log.error({ err }, `Error reconciling ${id}`);
|
|
603
|
+
report.errors.push({
|
|
604
|
+
integrationId: id,
|
|
605
|
+
error: message
|
|
606
|
+
});
|
|
607
|
+
report.results.push({
|
|
608
|
+
integrationId: id,
|
|
609
|
+
action: "error",
|
|
610
|
+
actualStatus: "error",
|
|
611
|
+
errorMessage: message
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
async reconcileRemoved(id, local, report) {
|
|
616
|
+
if (!local) {
|
|
617
|
+
report.results.push({
|
|
618
|
+
integrationId: id,
|
|
619
|
+
action: "up_to_date",
|
|
620
|
+
actualStatus: "inactive"
|
|
621
|
+
});
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
try {
|
|
625
|
+
log.info(`Removing ${id}`);
|
|
626
|
+
if (local.status === "active") {
|
|
627
|
+
await this.manager.deactivate(id);
|
|
628
|
+
report.deactivated.push(id);
|
|
629
|
+
}
|
|
630
|
+
await this.manager.uninstall(id);
|
|
631
|
+
report.uninstalled.push(id);
|
|
632
|
+
report.results.push({
|
|
633
|
+
integrationId: id,
|
|
634
|
+
action: "uninstalled",
|
|
635
|
+
actualStatus: "inactive"
|
|
636
|
+
});
|
|
637
|
+
} catch (err) {
|
|
638
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
639
|
+
log.error({ err }, `Error removing ${id}`);
|
|
640
|
+
report.errors.push({
|
|
641
|
+
integrationId: id,
|
|
642
|
+
error: message
|
|
643
|
+
});
|
|
644
|
+
report.results.push({
|
|
645
|
+
integrationId: id,
|
|
646
|
+
action: "error",
|
|
647
|
+
actualStatus: "error",
|
|
648
|
+
errorMessage: message
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
};
|
|
653
|
+
//#endregion
|
|
654
|
+
//#region src/cloud-client.ts
|
|
655
|
+
/**
|
|
656
|
+
* Cloud WebSocket client — connects to Alfe cloud gateway (Fly.io).
|
|
657
|
+
*
|
|
658
|
+
* Adapted from gateway-relay-plugin.ts CloudRelay class.
|
|
659
|
+
* Uses the cloud protocol: SERVICE_REGISTER / COMMAND / COMMAND_ACK / PING / PONG.
|
|
660
|
+
*
|
|
661
|
+
* Auth: user's api_key as Bearer token in WS upgrade header.
|
|
662
|
+
*/
|
|
663
|
+
var CloudClient = class {
|
|
664
|
+
ws = null;
|
|
665
|
+
backoffMs = 1e3;
|
|
666
|
+
closed = false;
|
|
667
|
+
registered = false;
|
|
668
|
+
pingTimer = null;
|
|
669
|
+
lastPong = 0;
|
|
670
|
+
config;
|
|
671
|
+
onCommand = null;
|
|
672
|
+
onConnectionChange = null;
|
|
673
|
+
reconciliationEngine = null;
|
|
674
|
+
constructor(config) {
|
|
675
|
+
this.config = config;
|
|
676
|
+
}
|
|
677
|
+
get isConnected() {
|
|
678
|
+
return this.registered && this.ws?.readyState === WebSocket.OPEN;
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Set the handler for incoming COMMAND messages from cloud.
|
|
682
|
+
*/
|
|
683
|
+
setCommandHandler(handler) {
|
|
684
|
+
this.onCommand = handler;
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Set a callback for connection state changes.
|
|
688
|
+
*/
|
|
689
|
+
setConnectionChangeHandler(handler) {
|
|
690
|
+
this.onConnectionChange = handler;
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Set the integration manager for reconciliation.
|
|
694
|
+
* When set, the cloud client will handle DESIRED_STATE messages automatically.
|
|
695
|
+
*/
|
|
696
|
+
setIntegrationManager(manager) {
|
|
697
|
+
this.reconciliationEngine = new ReconciliationEngine(manager);
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Start the cloud connection with auto-reconnect.
|
|
701
|
+
*/
|
|
702
|
+
start() {
|
|
703
|
+
this.closed = false;
|
|
704
|
+
this.doConnect();
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Stop the cloud connection and all timers.
|
|
708
|
+
*/
|
|
709
|
+
stop() {
|
|
710
|
+
this.closed = true;
|
|
711
|
+
this.stopPingTimer();
|
|
712
|
+
if (this.ws) {
|
|
713
|
+
try {
|
|
714
|
+
this.ws.close(1e3, "Daemon shutting down");
|
|
715
|
+
} catch {}
|
|
716
|
+
this.ws = null;
|
|
717
|
+
}
|
|
718
|
+
this.registered = false;
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Get connection latency (time since last pong).
|
|
722
|
+
* Returns -1 if no pong received yet.
|
|
723
|
+
*/
|
|
724
|
+
getLatencyMs() {
|
|
725
|
+
if (this.lastPong === 0) return -1;
|
|
726
|
+
return Date.now() - this.lastPong;
|
|
727
|
+
}
|
|
728
|
+
doConnect() {
|
|
729
|
+
if (this.closed) return;
|
|
730
|
+
logger.info({ url: this.config.wsUrl }, "Connecting to cloud gateway...");
|
|
731
|
+
this.ws = new WebSocket(this.config.wsUrl, {
|
|
732
|
+
headers: { authorization: `Bearer ${this.config.apiKey}` },
|
|
733
|
+
maxPayload: 10 * 1024 * 1024
|
|
734
|
+
});
|
|
735
|
+
this.ws.on("open", () => {
|
|
736
|
+
logger.info("Cloud WebSocket connected");
|
|
737
|
+
this.backoffMs = 1e3;
|
|
738
|
+
this.sendRegister();
|
|
739
|
+
});
|
|
740
|
+
this.ws.on("message", (data) => {
|
|
741
|
+
const text = Buffer.isBuffer(data) ? data.toString("utf-8") : Buffer.from(data).toString("utf-8");
|
|
742
|
+
this.handleMessage(text);
|
|
743
|
+
});
|
|
744
|
+
this.ws.on("ping", () => {
|
|
745
|
+
this.ws?.pong();
|
|
746
|
+
});
|
|
747
|
+
this.ws.on("close", (code, reason) => {
|
|
748
|
+
logger.warn({
|
|
749
|
+
code,
|
|
750
|
+
reason: reason.toString()
|
|
751
|
+
}, "Cloud WebSocket disconnected");
|
|
752
|
+
this.registered = false;
|
|
753
|
+
this.stopPingTimer();
|
|
754
|
+
this.onConnectionChange?.(false);
|
|
755
|
+
this.scheduleReconnect();
|
|
756
|
+
});
|
|
757
|
+
this.ws.on("error", (err) => {
|
|
758
|
+
logger.error({ err: err.message }, "Cloud WebSocket error");
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
sendRegister() {
|
|
762
|
+
const msg = createServiceRegister(this.config.agentId);
|
|
763
|
+
this.send(msg);
|
|
764
|
+
logger.info({ serviceId: msg.serviceId }, "Sent SERVICE_REGISTER");
|
|
765
|
+
}
|
|
766
|
+
async handleMessage(raw) {
|
|
767
|
+
const msg = parseMessage(raw);
|
|
768
|
+
if (!msg) {
|
|
769
|
+
logger.warn("Cloud: received invalid JSON");
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (isCloudServiceAck(msg)) {
|
|
773
|
+
this.handleServiceAck(msg);
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
if (isCloudPing(msg)) {
|
|
777
|
+
this.send({ type: "PONG" });
|
|
778
|
+
this.lastPong = Date.now();
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
if (isCloudDesiredState(msg)) {
|
|
782
|
+
await this.handleDesiredState(msg);
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
if (isCloudCommand(msg)) {
|
|
786
|
+
await this.handleCommand(msg);
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
logger.debug({ msg }, "Cloud: unhandled message");
|
|
790
|
+
}
|
|
791
|
+
handleServiceAck(ack) {
|
|
792
|
+
if (ack.status === "ok") {
|
|
793
|
+
logger.info("Cloud: registered successfully ✅");
|
|
794
|
+
this.registered = true;
|
|
795
|
+
this.startPingTimer();
|
|
796
|
+
this.onConnectionChange?.(true);
|
|
797
|
+
} else {
|
|
798
|
+
logger.error({ message: ack.message }, "Cloud: registration failed");
|
|
799
|
+
this.ws?.close(1008, "Registration rejected");
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
async handleCommand(command) {
|
|
803
|
+
logger.info({
|
|
804
|
+
commandId: command.commandId,
|
|
805
|
+
command: command.command,
|
|
806
|
+
agentId: command.agentId
|
|
807
|
+
}, "Cloud: received COMMAND");
|
|
808
|
+
if (!this.onCommand) {
|
|
809
|
+
const ack = {
|
|
810
|
+
type: "COMMAND_ACK",
|
|
811
|
+
commandId: command.commandId,
|
|
812
|
+
status: "error",
|
|
813
|
+
result: {
|
|
814
|
+
code: "NO_HANDLER",
|
|
815
|
+
message: "No command handler registered"
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
this.send(ack);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
try {
|
|
822
|
+
const ack = await this.onCommand(command);
|
|
823
|
+
this.send(ack);
|
|
824
|
+
} catch (err) {
|
|
825
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
826
|
+
logger.error({
|
|
827
|
+
err: message,
|
|
828
|
+
commandId: command.commandId
|
|
829
|
+
}, "Cloud: command handler failed");
|
|
830
|
+
const ack = {
|
|
831
|
+
type: "COMMAND_ACK",
|
|
832
|
+
commandId: command.commandId,
|
|
833
|
+
status: "error",
|
|
834
|
+
result: {
|
|
835
|
+
code: "HANDLER_ERROR",
|
|
836
|
+
message
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
this.send(ack);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
async handleDesiredState(msg) {
|
|
843
|
+
logger.info({ count: msg.integrations.length }, "Cloud: received DESIRED_STATE");
|
|
844
|
+
if (!this.reconciliationEngine) {
|
|
845
|
+
logger.warn("Cloud: DESIRED_STATE received but no integration manager set — skipping reconciliation");
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
try {
|
|
849
|
+
const report = await this.reconciliationEngine.reconcile(msg.integrations);
|
|
850
|
+
logger.info({
|
|
851
|
+
installed: report.installed.length,
|
|
852
|
+
activated: report.activated.length,
|
|
853
|
+
deactivated: report.deactivated.length,
|
|
854
|
+
uninstalled: report.uninstalled.length,
|
|
855
|
+
errors: report.errors.length
|
|
856
|
+
}, "Cloud: reconciliation complete");
|
|
857
|
+
const reportMsg = createReconciliationReport(report.results);
|
|
858
|
+
this.send(reportMsg);
|
|
859
|
+
} catch (err) {
|
|
860
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
861
|
+
logger.error({ err: message }, "Cloud: reconciliation failed");
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
send(msg) {
|
|
865
|
+
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg));
|
|
866
|
+
}
|
|
867
|
+
startPingTimer() {
|
|
868
|
+
this.stopPingTimer();
|
|
869
|
+
this.pingTimer = setInterval(() => {
|
|
870
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
871
|
+
if (this.lastPong > 0 && Date.now() - this.lastPong > 9e4) {
|
|
872
|
+
logger.warn("Cloud: no ping received in 90s — reconnecting");
|
|
873
|
+
this.ws.close(4e3, "Ping timeout");
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}, 3e4);
|
|
878
|
+
}
|
|
879
|
+
stopPingTimer() {
|
|
880
|
+
if (this.pingTimer) {
|
|
881
|
+
clearInterval(this.pingTimer);
|
|
882
|
+
this.pingTimer = null;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
scheduleReconnect() {
|
|
886
|
+
if (this.closed) return;
|
|
887
|
+
const delay = this.backoffMs;
|
|
888
|
+
this.backoffMs = Math.min(this.backoffMs * 2, 3e4);
|
|
889
|
+
logger.info({ delayMs: delay }, "Cloud: reconnecting...");
|
|
890
|
+
setTimeout(() => {
|
|
891
|
+
this.doConnect();
|
|
892
|
+
}, delay);
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
//#endregion
|
|
896
|
+
//#region src/ipc-server.ts
|
|
897
|
+
/**
|
|
898
|
+
* IPC Server — Unix socket server for local plugin connections.
|
|
899
|
+
*
|
|
900
|
+
* Plugins connect to ~/.alfe/gateway.sock and speak the IPC protocol:
|
|
901
|
+
* Request: { type: 'req', id, method, params }
|
|
902
|
+
* Response: { id, ok, payload?, error? }
|
|
903
|
+
* Event: { type: 'event', event, payload }
|
|
904
|
+
*
|
|
905
|
+
* Each connected plugin registers with its name/version and receives
|
|
906
|
+
* commands from the daemon (forwarded from cloud).
|
|
907
|
+
*/
|
|
908
|
+
var IPCServer = class {
|
|
909
|
+
server = null;
|
|
910
|
+
connections = /* @__PURE__ */ new Map();
|
|
911
|
+
socketPath;
|
|
912
|
+
requestHandler = null;
|
|
913
|
+
constructor(socketPath) {
|
|
914
|
+
this.socketPath = socketPath;
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Set the handler for incoming IPC requests from plugins.
|
|
918
|
+
*/
|
|
919
|
+
setRequestHandler(handler) {
|
|
920
|
+
this.requestHandler = handler;
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* Start listening on the Unix socket.
|
|
924
|
+
*/
|
|
925
|
+
async start() {
|
|
926
|
+
try {
|
|
927
|
+
unlinkSync(this.socketPath);
|
|
928
|
+
} catch {}
|
|
929
|
+
return new Promise((resolve, reject) => {
|
|
930
|
+
this.server = createServer((socket) => {
|
|
931
|
+
this.handleConnection(socket);
|
|
932
|
+
});
|
|
933
|
+
this.server.on("error", (err) => {
|
|
934
|
+
logger.error({ err: err.message }, "IPC server error");
|
|
935
|
+
reject(err);
|
|
936
|
+
});
|
|
937
|
+
this.server.listen(this.socketPath, () => {
|
|
938
|
+
try {
|
|
939
|
+
chmodSync(this.socketPath, 384);
|
|
940
|
+
} catch (err) {
|
|
941
|
+
logger.warn({ err }, "Failed to chmod socket");
|
|
942
|
+
}
|
|
943
|
+
logger.info({ path: this.socketPath }, "IPC server listening");
|
|
944
|
+
resolve();
|
|
945
|
+
});
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Stop the IPC server and disconnect all plugins.
|
|
950
|
+
*/
|
|
951
|
+
async stop() {
|
|
952
|
+
for (const conn of this.connections.values()) try {
|
|
953
|
+
this.sendEvent(conn, "daemon.shutdown", { reason: "daemon stopping" });
|
|
954
|
+
conn.socket.end();
|
|
955
|
+
} catch {}
|
|
956
|
+
return new Promise((resolve) => {
|
|
957
|
+
if (!this.server) {
|
|
958
|
+
resolve();
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
this.server.close(() => {
|
|
962
|
+
try {
|
|
963
|
+
unlinkSync(this.socketPath);
|
|
964
|
+
} catch {}
|
|
965
|
+
logger.info("IPC server stopped");
|
|
966
|
+
resolve();
|
|
967
|
+
});
|
|
968
|
+
for (const conn of this.connections.values()) conn.socket.destroy();
|
|
969
|
+
this.connections.clear();
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Send an IPC request to a specific plugin and wait for response.
|
|
974
|
+
*/
|
|
975
|
+
async sendRequest(pluginId, method, params, timeoutMs = 3e4) {
|
|
976
|
+
const conn = this.connections.get(pluginId);
|
|
977
|
+
if (!conn?.info) return {
|
|
978
|
+
id: "",
|
|
979
|
+
ok: false,
|
|
980
|
+
error: {
|
|
981
|
+
code: "PLUGIN_NOT_CONNECTED",
|
|
982
|
+
message: `Plugin ${pluginId} not connected`
|
|
983
|
+
}
|
|
984
|
+
};
|
|
985
|
+
const id = pluginConnectionId();
|
|
986
|
+
const request = {
|
|
987
|
+
type: "req",
|
|
988
|
+
id,
|
|
989
|
+
method,
|
|
990
|
+
params
|
|
991
|
+
};
|
|
992
|
+
return new Promise((resolve, reject) => {
|
|
993
|
+
const timer = setTimeout(() => {
|
|
994
|
+
conn.pending.delete(id);
|
|
995
|
+
resolve({
|
|
996
|
+
id,
|
|
997
|
+
ok: false,
|
|
998
|
+
error: {
|
|
999
|
+
code: "TIMEOUT",
|
|
1000
|
+
message: `Request ${method} timed out after ${String(timeoutMs)}ms`
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
1003
|
+
}, timeoutMs);
|
|
1004
|
+
conn.pending.set(id, {
|
|
1005
|
+
resolve,
|
|
1006
|
+
reject,
|
|
1007
|
+
timer
|
|
1008
|
+
});
|
|
1009
|
+
try {
|
|
1010
|
+
conn.socket.write(JSON.stringify(request) + "\n");
|
|
1011
|
+
} catch (err) {
|
|
1012
|
+
clearTimeout(timer);
|
|
1013
|
+
conn.pending.delete(id);
|
|
1014
|
+
resolve({
|
|
1015
|
+
id,
|
|
1016
|
+
ok: false,
|
|
1017
|
+
error: {
|
|
1018
|
+
code: "SEND_FAILED",
|
|
1019
|
+
message: `Failed to send to plugin: ${err instanceof Error ? err.message : String(err)}`
|
|
1020
|
+
}
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Send an IPC request to ALL registered plugins.
|
|
1027
|
+
* Returns responses keyed by plugin ID.
|
|
1028
|
+
*/
|
|
1029
|
+
async broadcastRequest(method, params, timeoutMs = 3e4) {
|
|
1030
|
+
const results = /* @__PURE__ */ new Map();
|
|
1031
|
+
const registered = this.getRegisteredPlugins();
|
|
1032
|
+
await Promise.all(registered.map(async ([pluginId]) => {
|
|
1033
|
+
const response = await this.sendRequest(pluginId, method, params, timeoutMs);
|
|
1034
|
+
results.set(pluginId, response);
|
|
1035
|
+
}));
|
|
1036
|
+
return results;
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Send an event to a specific plugin (fire-and-forget).
|
|
1040
|
+
*/
|
|
1041
|
+
sendEventToPlugin(pluginId, event, payload) {
|
|
1042
|
+
const conn = this.connections.get(pluginId);
|
|
1043
|
+
if (!conn) return false;
|
|
1044
|
+
return this.sendEvent(conn, event, payload);
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Broadcast an event to all connected plugins.
|
|
1048
|
+
*/
|
|
1049
|
+
broadcastEvent(event, payload) {
|
|
1050
|
+
for (const conn of this.connections.values()) if (conn.info) this.sendEvent(conn, event, payload);
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* Get all registered plugins and their info.
|
|
1054
|
+
*/
|
|
1055
|
+
getRegisteredPlugins() {
|
|
1056
|
+
const plugins = [];
|
|
1057
|
+
for (const [id, conn] of this.connections) if (conn.info) plugins.push([id, conn.info]);
|
|
1058
|
+
return plugins;
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Get number of connected plugins (including unregistered).
|
|
1062
|
+
*/
|
|
1063
|
+
get connectionCount() {
|
|
1064
|
+
return this.connections.size;
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* Get number of registered plugins.
|
|
1068
|
+
*/
|
|
1069
|
+
get registeredCount() {
|
|
1070
|
+
let count = 0;
|
|
1071
|
+
for (const conn of this.connections.values()) if (conn.info) count++;
|
|
1072
|
+
return count;
|
|
1073
|
+
}
|
|
1074
|
+
handleConnection(socket) {
|
|
1075
|
+
const connId = pluginConnectionId();
|
|
1076
|
+
const conn = {
|
|
1077
|
+
id: connId,
|
|
1078
|
+
socket,
|
|
1079
|
+
info: null,
|
|
1080
|
+
buffer: "",
|
|
1081
|
+
pending: /* @__PURE__ */ new Map()
|
|
1082
|
+
};
|
|
1083
|
+
this.connections.set(connId, conn);
|
|
1084
|
+
logger.info({ connId }, "IPC: new connection");
|
|
1085
|
+
socket.on("data", (data) => {
|
|
1086
|
+
conn.buffer += data.toString();
|
|
1087
|
+
this.processBuffer(conn);
|
|
1088
|
+
});
|
|
1089
|
+
socket.on("close", () => {
|
|
1090
|
+
logger.info({
|
|
1091
|
+
connId,
|
|
1092
|
+
plugin: conn.info?.name
|
|
1093
|
+
}, "IPC: connection closed");
|
|
1094
|
+
for (const [, pending] of conn.pending) {
|
|
1095
|
+
clearTimeout(pending.timer);
|
|
1096
|
+
pending.resolve({
|
|
1097
|
+
id: "",
|
|
1098
|
+
ok: false,
|
|
1099
|
+
error: {
|
|
1100
|
+
code: "DISCONNECTED",
|
|
1101
|
+
message: "Plugin disconnected"
|
|
1102
|
+
}
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
this.connections.delete(connId);
|
|
1106
|
+
});
|
|
1107
|
+
socket.on("error", (err) => {
|
|
1108
|
+
logger.error({
|
|
1109
|
+
connId,
|
|
1110
|
+
err: err.message
|
|
1111
|
+
}, "IPC: socket error");
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
processBuffer(conn) {
|
|
1115
|
+
let newlineIdx;
|
|
1116
|
+
while ((newlineIdx = conn.buffer.indexOf("\n")) !== -1) {
|
|
1117
|
+
const line = conn.buffer.slice(0, newlineIdx).trim();
|
|
1118
|
+
conn.buffer = conn.buffer.slice(newlineIdx + 1);
|
|
1119
|
+
if (!line) continue;
|
|
1120
|
+
let parsed;
|
|
1121
|
+
try {
|
|
1122
|
+
parsed = JSON.parse(line);
|
|
1123
|
+
} catch {
|
|
1124
|
+
logger.warn({ connId: conn.id }, "IPC: invalid JSON from plugin");
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
1127
|
+
if (isIPCResponse(parsed)) {
|
|
1128
|
+
this.handlePluginResponse(conn, parsed);
|
|
1129
|
+
continue;
|
|
1130
|
+
}
|
|
1131
|
+
if (isIPCRequest(parsed)) {
|
|
1132
|
+
this.handlePluginRequest(conn, parsed);
|
|
1133
|
+
continue;
|
|
1134
|
+
}
|
|
1135
|
+
logger.debug({
|
|
1136
|
+
connId: conn.id,
|
|
1137
|
+
msg: parsed
|
|
1138
|
+
}, "IPC: unhandled message");
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
handlePluginResponse(conn, response) {
|
|
1142
|
+
const pending = conn.pending.get(response.id);
|
|
1143
|
+
if (!pending) return;
|
|
1144
|
+
clearTimeout(pending.timer);
|
|
1145
|
+
conn.pending.delete(response.id);
|
|
1146
|
+
pending.resolve(response);
|
|
1147
|
+
}
|
|
1148
|
+
async handlePluginRequest(conn, request) {
|
|
1149
|
+
if (conn.info) conn.info.lastSeen = Date.now();
|
|
1150
|
+
if (request.method === "register") {
|
|
1151
|
+
this.handleRegister(conn, request);
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
if (this.requestHandler) try {
|
|
1155
|
+
const result = await this.requestHandler(request.method, request.params, conn.id);
|
|
1156
|
+
const response = result.ok ? createIPCResponse(request.id, result.payload) : createIPCError(request.id, result.error?.code ?? "UNKNOWN", result.error?.message ?? "Unknown error");
|
|
1157
|
+
this.sendResponse(conn, response);
|
|
1158
|
+
} catch (err) {
|
|
1159
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1160
|
+
this.sendResponse(conn, createIPCError(request.id, "INTERNAL", message));
|
|
1161
|
+
}
|
|
1162
|
+
else this.sendResponse(conn, createIPCError(request.id, "NO_HANDLER", `No handler for method: ${request.method}`));
|
|
1163
|
+
}
|
|
1164
|
+
handleRegister(conn, request) {
|
|
1165
|
+
const { name, version, protocolVersion, capabilities } = request.params;
|
|
1166
|
+
if (!name || !version) {
|
|
1167
|
+
this.sendResponse(conn, createIPCError(request.id, "INVALID_REGISTER", "name and version are required"));
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
if (protocolVersion !== void 0 && protocolVersion !== 1) {
|
|
1171
|
+
this.sendResponse(conn, createIPCError(request.id, "PROTOCOL_MISMATCH", `Unsupported protocol version ${String(protocolVersion)}. Server supports v${String(1)}.`));
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
conn.info = {
|
|
1175
|
+
name,
|
|
1176
|
+
version,
|
|
1177
|
+
protocolVersion: protocolVersion ?? 1,
|
|
1178
|
+
capabilities: capabilities ?? [],
|
|
1179
|
+
connectedAt: Date.now(),
|
|
1180
|
+
lastSeen: Date.now()
|
|
1181
|
+
};
|
|
1182
|
+
logger.info({
|
|
1183
|
+
connId: conn.id,
|
|
1184
|
+
plugin: name,
|
|
1185
|
+
version,
|
|
1186
|
+
capabilities
|
|
1187
|
+
}, "IPC: plugin registered");
|
|
1188
|
+
this.sendResponse(conn, createIPCResponse(request.id, {
|
|
1189
|
+
status: "registered",
|
|
1190
|
+
daemonVersion: "0.1.0",
|
|
1191
|
+
protocolVersion: 1
|
|
1192
|
+
}));
|
|
1193
|
+
}
|
|
1194
|
+
sendResponse(conn, response) {
|
|
1195
|
+
try {
|
|
1196
|
+
conn.socket.write(JSON.stringify(response) + "\n");
|
|
1197
|
+
} catch (err) {
|
|
1198
|
+
logger.error({
|
|
1199
|
+
connId: conn.id,
|
|
1200
|
+
err
|
|
1201
|
+
}, "IPC: failed to send response");
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
sendEvent(conn, event, payload) {
|
|
1205
|
+
try {
|
|
1206
|
+
const msg = createIPCEvent(event, payload);
|
|
1207
|
+
conn.socket.write(JSON.stringify(msg) + "\n");
|
|
1208
|
+
return true;
|
|
1209
|
+
} catch {
|
|
1210
|
+
return false;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
};
|
|
1214
|
+
//#endregion
|
|
1215
|
+
//#region src/command-queue.ts
|
|
1216
|
+
const DEFAULT_TTL_MS = 300 * 1e3;
|
|
1217
|
+
var CommandQueue = class {
|
|
1218
|
+
queues = /* @__PURE__ */ new Map();
|
|
1219
|
+
ttlMs;
|
|
1220
|
+
gcTimer = null;
|
|
1221
|
+
constructor(ttlMs = DEFAULT_TTL_MS) {
|
|
1222
|
+
this.ttlMs = ttlMs;
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Start periodic garbage collection of expired commands.
|
|
1226
|
+
*/
|
|
1227
|
+
startGC(intervalMs = 3e4) {
|
|
1228
|
+
this.stopGC();
|
|
1229
|
+
this.gcTimer = setInterval(() => this.purgeExpired(), intervalMs);
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Stop periodic garbage collection.
|
|
1233
|
+
*/
|
|
1234
|
+
stopGC() {
|
|
1235
|
+
if (this.gcTimer) {
|
|
1236
|
+
clearInterval(this.gcTimer);
|
|
1237
|
+
this.gcTimer = null;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* Enqueue a command for a specific service.
|
|
1242
|
+
*/
|
|
1243
|
+
enqueue(serviceId, request, commandId) {
|
|
1244
|
+
let queue = this.queues.get(serviceId);
|
|
1245
|
+
if (!queue) {
|
|
1246
|
+
queue = [];
|
|
1247
|
+
this.queues.set(serviceId, queue);
|
|
1248
|
+
}
|
|
1249
|
+
queue.push({
|
|
1250
|
+
request,
|
|
1251
|
+
queuedAt: Date.now(),
|
|
1252
|
+
commandId
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Drain all pending (non-expired) commands for a service.
|
|
1257
|
+
* Returns them in order and removes them from the queue.
|
|
1258
|
+
*/
|
|
1259
|
+
drain(serviceId) {
|
|
1260
|
+
const queue = this.queues.get(serviceId);
|
|
1261
|
+
if (!queue || queue.length === 0) return [];
|
|
1262
|
+
const now = Date.now();
|
|
1263
|
+
const valid = queue.filter((cmd) => now - cmd.queuedAt < this.ttlMs);
|
|
1264
|
+
this.queues.delete(serviceId);
|
|
1265
|
+
return valid;
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* Get the number of pending commands for a service.
|
|
1269
|
+
*/
|
|
1270
|
+
pendingCount(serviceId) {
|
|
1271
|
+
const queue = this.queues.get(serviceId);
|
|
1272
|
+
if (!queue) return 0;
|
|
1273
|
+
const now = Date.now();
|
|
1274
|
+
return queue.filter((cmd) => now - cmd.queuedAt < this.ttlMs).length;
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* Get total pending commands across all services.
|
|
1278
|
+
*/
|
|
1279
|
+
totalPending() {
|
|
1280
|
+
let total = 0;
|
|
1281
|
+
for (const [serviceId] of this.queues) total += this.pendingCount(serviceId);
|
|
1282
|
+
return total;
|
|
1283
|
+
}
|
|
1284
|
+
/**
|
|
1285
|
+
* Remove expired commands from all queues.
|
|
1286
|
+
*/
|
|
1287
|
+
purgeExpired() {
|
|
1288
|
+
const now = Date.now();
|
|
1289
|
+
let purged = 0;
|
|
1290
|
+
for (const [serviceId, queue] of this.queues) {
|
|
1291
|
+
const before = queue.length;
|
|
1292
|
+
const remaining = queue.filter((cmd) => now - cmd.queuedAt < this.ttlMs);
|
|
1293
|
+
if (remaining.length === 0) this.queues.delete(serviceId);
|
|
1294
|
+
else this.queues.set(serviceId, remaining);
|
|
1295
|
+
purged += before - remaining.length;
|
|
1296
|
+
}
|
|
1297
|
+
return purged;
|
|
1298
|
+
}
|
|
1299
|
+
/**
|
|
1300
|
+
* Clear all queues.
|
|
1301
|
+
*/
|
|
1302
|
+
clear() {
|
|
1303
|
+
this.queues.clear();
|
|
1304
|
+
}
|
|
1305
|
+
/**
|
|
1306
|
+
* Get all service IDs that have pending commands.
|
|
1307
|
+
*/
|
|
1308
|
+
serviceIds() {
|
|
1309
|
+
return Array.from(this.queues.keys());
|
|
1310
|
+
}
|
|
1311
|
+
};
|
|
1312
|
+
//#endregion
|
|
1313
|
+
//#region src/process-manager.ts
|
|
1314
|
+
/**
|
|
1315
|
+
* Process management — launchd/systemd service installation.
|
|
1316
|
+
*
|
|
1317
|
+
* Generates and installs user-space service units for auto-start on boot.
|
|
1318
|
+
* No root required — uses user agents (Mac) or user units (Linux).
|
|
1319
|
+
*/
|
|
1320
|
+
const LAUNCHD_LABEL = "ai.alfe.gateway";
|
|
1321
|
+
const SYSTEMD_SERVICE = "alfe-gateway";
|
|
1322
|
+
function getLaunchdPlistPath() {
|
|
1323
|
+
return join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
1324
|
+
}
|
|
1325
|
+
function getSystemdServicePath() {
|
|
1326
|
+
return join(homedir(), ".config", "systemd", "user", `${SYSTEMD_SERVICE}.service`);
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Resolve the path to the gateway binary.
|
|
1330
|
+
*/
|
|
1331
|
+
function getGatewayBinPath() {
|
|
1332
|
+
const globalBin = process.env.ALFE_GATEWAY_BIN;
|
|
1333
|
+
if (globalBin) return globalBin;
|
|
1334
|
+
const builtBin = join(import.meta.dirname, "..", "bin", "gateway.js");
|
|
1335
|
+
if (existsSync(builtBin)) return builtBin;
|
|
1336
|
+
return "alfe-gateway";
|
|
1337
|
+
}
|
|
1338
|
+
function getNodePath() {
|
|
1339
|
+
try {
|
|
1340
|
+
return execSync("which node", { encoding: "utf-8" }).trim();
|
|
1341
|
+
} catch {
|
|
1342
|
+
return "/usr/local/bin/node";
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
/**
|
|
1346
|
+
* Generate a launchd plist for macOS.
|
|
1347
|
+
*/
|
|
1348
|
+
function generateLaunchdPlist() {
|
|
1349
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1350
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1351
|
+
<plist version="1.0">
|
|
1352
|
+
<dict>
|
|
1353
|
+
<key>Label</key>
|
|
1354
|
+
<string>${LAUNCHD_LABEL}</string>
|
|
1355
|
+
<key>ProgramArguments</key>
|
|
1356
|
+
<array>
|
|
1357
|
+
<string>${getNodePath()}</string>
|
|
1358
|
+
<string>${getGatewayBinPath()}</string>
|
|
1359
|
+
<string>daemon</string>
|
|
1360
|
+
</array>
|
|
1361
|
+
<key>RunAtLoad</key>
|
|
1362
|
+
<true/>
|
|
1363
|
+
<key>KeepAlive</key>
|
|
1364
|
+
<true/>
|
|
1365
|
+
<key>ThrottleInterval</key>
|
|
1366
|
+
<integer>10</integer>
|
|
1367
|
+
<key>StandardOutPath</key>
|
|
1368
|
+
<string>${join(homedir(), ".alfe", "logs", "gateway.log")}</string>
|
|
1369
|
+
<key>StandardErrorPath</key>
|
|
1370
|
+
<string>${join(homedir(), ".alfe", "logs", "gateway.err.log")}</string>
|
|
1371
|
+
<key>EnvironmentVariables</key>
|
|
1372
|
+
<dict>
|
|
1373
|
+
<key>NODE_ENV</key>
|
|
1374
|
+
<string>production</string>
|
|
1375
|
+
<key>PATH</key>
|
|
1376
|
+
<string>/usr/local/bin:/usr/bin:/bin:${join(homedir(), ".local", "bin")}</string>
|
|
1377
|
+
</dict>
|
|
1378
|
+
</dict>
|
|
1379
|
+
</plist>`;
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* Generate a systemd user unit for Linux.
|
|
1383
|
+
*/
|
|
1384
|
+
function generateSystemdUnit() {
|
|
1385
|
+
return `[Unit]
|
|
1386
|
+
Description=Alfe Gateway Daemon
|
|
1387
|
+
After=network-online.target
|
|
1388
|
+
Wants=network-online.target
|
|
1389
|
+
|
|
1390
|
+
[Service]
|
|
1391
|
+
Type=simple
|
|
1392
|
+
ExecStart=${getNodePath()} ${getGatewayBinPath()} daemon
|
|
1393
|
+
Restart=always
|
|
1394
|
+
RestartSec=10
|
|
1395
|
+
Environment=NODE_ENV=production
|
|
1396
|
+
|
|
1397
|
+
[Install]
|
|
1398
|
+
WantedBy=default.target`;
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* Install the service unit for the current platform.
|
|
1402
|
+
*/
|
|
1403
|
+
async function installService() {
|
|
1404
|
+
const platform = process.platform;
|
|
1405
|
+
if (platform === "darwin") return installLaunchd();
|
|
1406
|
+
if (platform === "linux") return installSystemd();
|
|
1407
|
+
throw new Error(`Unsupported platform: ${platform}. Only macOS and Linux are supported.`);
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1410
|
+
* Uninstall the service unit for the current platform.
|
|
1411
|
+
*/
|
|
1412
|
+
async function uninstallService() {
|
|
1413
|
+
const platform = process.platform;
|
|
1414
|
+
if (platform === "darwin") return uninstallLaunchd();
|
|
1415
|
+
if (platform === "linux") return uninstallSystemd();
|
|
1416
|
+
throw new Error(`Unsupported platform: ${platform}`);
|
|
1417
|
+
}
|
|
1418
|
+
async function installLaunchd() {
|
|
1419
|
+
const plistPath = getLaunchdPlistPath();
|
|
1420
|
+
await mkdir(join(homedir(), "Library", "LaunchAgents"), { recursive: true });
|
|
1421
|
+
await writeFile(plistPath, generateLaunchdPlist(), "utf-8");
|
|
1422
|
+
logger.info({ path: plistPath }, "Wrote launchd plist");
|
|
1423
|
+
try {
|
|
1424
|
+
execSync(`launchctl load ${plistPath}`, { stdio: "pipe" });
|
|
1425
|
+
} catch {}
|
|
1426
|
+
return `Installed: ${plistPath}\nService will start on boot and restart on crash.`;
|
|
1427
|
+
}
|
|
1428
|
+
async function uninstallLaunchd() {
|
|
1429
|
+
const plistPath = getLaunchdPlistPath();
|
|
1430
|
+
try {
|
|
1431
|
+
execSync(`launchctl unload ${plistPath}`, { stdio: "pipe" });
|
|
1432
|
+
} catch {}
|
|
1433
|
+
try {
|
|
1434
|
+
await unlink(plistPath);
|
|
1435
|
+
} catch {}
|
|
1436
|
+
return `Uninstalled: ${plistPath}`;
|
|
1437
|
+
}
|
|
1438
|
+
async function installSystemd() {
|
|
1439
|
+
const unitPath = getSystemdServicePath();
|
|
1440
|
+
await mkdir(join(homedir(), ".config", "systemd", "user"), { recursive: true });
|
|
1441
|
+
await writeFile(unitPath, generateSystemdUnit(), "utf-8");
|
|
1442
|
+
logger.info({ path: unitPath }, "Wrote systemd unit");
|
|
1443
|
+
try {
|
|
1444
|
+
execSync("systemctl --user daemon-reload", { stdio: "pipe" });
|
|
1445
|
+
execSync(`systemctl --user enable ${SYSTEMD_SERVICE}`, { stdio: "pipe" });
|
|
1446
|
+
} catch {}
|
|
1447
|
+
return `Installed: ${unitPath}\nService enabled for user session.`;
|
|
1448
|
+
}
|
|
1449
|
+
async function uninstallSystemd() {
|
|
1450
|
+
const unitPath = getSystemdServicePath();
|
|
1451
|
+
try {
|
|
1452
|
+
execSync(`systemctl --user disable ${SYSTEMD_SERVICE}`, { stdio: "pipe" });
|
|
1453
|
+
execSync(`systemctl --user stop ${SYSTEMD_SERVICE}`, { stdio: "pipe" });
|
|
1454
|
+
} catch {}
|
|
1455
|
+
try {
|
|
1456
|
+
await unlink(unitPath);
|
|
1457
|
+
} catch {}
|
|
1458
|
+
try {
|
|
1459
|
+
execSync("systemctl --user daemon-reload", { stdio: "pipe" });
|
|
1460
|
+
} catch {}
|
|
1461
|
+
return `Uninstalled: ${unitPath}`;
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* Write the current process PID to the PID file.
|
|
1465
|
+
*/
|
|
1466
|
+
async function writePidFile() {
|
|
1467
|
+
await writeFile(PID_PATH, String(process.pid), "utf-8");
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* Remove the PID file.
|
|
1471
|
+
*/
|
|
1472
|
+
async function removePidFile() {
|
|
1473
|
+
try {
|
|
1474
|
+
await unlink(PID_PATH);
|
|
1475
|
+
} catch {}
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* Check if a daemon is already running.
|
|
1479
|
+
* Returns the PID if alive, null if not running.
|
|
1480
|
+
*/
|
|
1481
|
+
async function checkExistingDaemon() {
|
|
1482
|
+
if (!existsSync(PID_PATH)) return null;
|
|
1483
|
+
try {
|
|
1484
|
+
const pidStr = await readFile(PID_PATH, "utf-8");
|
|
1485
|
+
const pid = parseInt(pidStr.trim(), 10);
|
|
1486
|
+
if (isNaN(pid)) {
|
|
1487
|
+
await removePidFile();
|
|
1488
|
+
return null;
|
|
1489
|
+
}
|
|
1490
|
+
try {
|
|
1491
|
+
process.kill(pid, 0);
|
|
1492
|
+
return pid;
|
|
1493
|
+
} catch {
|
|
1494
|
+
await removePidFile();
|
|
1495
|
+
return null;
|
|
1496
|
+
}
|
|
1497
|
+
} catch {
|
|
1498
|
+
return null;
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* Send SIGTERM to an existing daemon process.
|
|
1503
|
+
*/
|
|
1504
|
+
async function stopExistingDaemon() {
|
|
1505
|
+
const pid = await checkExistingDaemon();
|
|
1506
|
+
if (!pid) return false;
|
|
1507
|
+
try {
|
|
1508
|
+
process.kill(pid, "SIGTERM");
|
|
1509
|
+
for (let i = 0; i < 50; i++) {
|
|
1510
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1511
|
+
try {
|
|
1512
|
+
process.kill(pid, 0);
|
|
1513
|
+
} catch {
|
|
1514
|
+
await removePidFile();
|
|
1515
|
+
return true;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
process.kill(pid, "SIGKILL");
|
|
1519
|
+
await removePidFile();
|
|
1520
|
+
return true;
|
|
1521
|
+
} catch {
|
|
1522
|
+
await removePidFile();
|
|
1523
|
+
return false;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
//#endregion
|
|
1527
|
+
//#region src/daemon.ts
|
|
1528
|
+
/**
|
|
1529
|
+
* Alfe Gateway Daemon — main entry point.
|
|
1530
|
+
*
|
|
1531
|
+
* Starts all subsystems:
|
|
1532
|
+
* 1. Load config + resolve agent identity
|
|
1533
|
+
* 2. Check for existing daemon (PID file)
|
|
1534
|
+
* 3. Start IPC server (Unix socket)
|
|
1535
|
+
* 4. Start cloud client (WebSocket to Fly.io)
|
|
1536
|
+
* 5. Wire up protocol translation (cloud COMMAND → IPC request)
|
|
1537
|
+
* 6. Start command queue GC
|
|
1538
|
+
* 7. Write PID file
|
|
1539
|
+
* 8. Handle graceful shutdown
|
|
1540
|
+
*/
|
|
1541
|
+
let config;
|
|
1542
|
+
let cloudClient;
|
|
1543
|
+
let ipcServer = null;
|
|
1544
|
+
let commandQueue;
|
|
1545
|
+
let startedAt;
|
|
1546
|
+
let integrationManager;
|
|
1547
|
+
let cloudConnected = false;
|
|
1548
|
+
let shuttingDown = false;
|
|
1549
|
+
async function startDaemon() {
|
|
1550
|
+
startedAt = Date.now();
|
|
1551
|
+
const managed = isManagedMode();
|
|
1552
|
+
logger.info({ managed }, "Starting Alfe Gateway Daemon...");
|
|
1553
|
+
if (!managed) {
|
|
1554
|
+
await mkdir(join(homedir(), ".alfe"), { recursive: true });
|
|
1555
|
+
const existingPid = await checkExistingDaemon();
|
|
1556
|
+
if (existingPid) {
|
|
1557
|
+
logger.error({ pid: existingPid }, "Daemon already running. Use `alfe gateway stop` first.");
|
|
1558
|
+
process.exit(1);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
try {
|
|
1562
|
+
config = await loadDaemonConfig();
|
|
1563
|
+
logger.info({
|
|
1564
|
+
agentId: config.agentId,
|
|
1565
|
+
orgId: config.orgId,
|
|
1566
|
+
wsUrl: config.gatewayWsUrl
|
|
1567
|
+
}, "Config loaded, identity resolved");
|
|
1568
|
+
} catch (err) {
|
|
1569
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1570
|
+
logger.error({ err: message }, "Failed to load config");
|
|
1571
|
+
process.exit(1);
|
|
1572
|
+
}
|
|
1573
|
+
commandQueue = new CommandQueue();
|
|
1574
|
+
commandQueue.startGC();
|
|
1575
|
+
if (!managed) {
|
|
1576
|
+
ipcServer = new IPCServer(config.socketPath);
|
|
1577
|
+
ipcServer.setRequestHandler(handlePluginRequest);
|
|
1578
|
+
try {
|
|
1579
|
+
await ipcServer.start();
|
|
1580
|
+
} catch (err) {
|
|
1581
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1582
|
+
logger.error({ err: message }, "Failed to start IPC server");
|
|
1583
|
+
process.exit(1);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
cloudClient = new CloudClient({
|
|
1587
|
+
wsUrl: config.gatewayWsUrl,
|
|
1588
|
+
apiKey: config.apiKey,
|
|
1589
|
+
agentId: config.agentId
|
|
1590
|
+
});
|
|
1591
|
+
cloudClient.setCommandHandler(handleCloudCommand);
|
|
1592
|
+
cloudClient.setConnectionChangeHandler((connected) => {
|
|
1593
|
+
cloudConnected = connected;
|
|
1594
|
+
logger.info({ connected }, "Cloud connection state changed");
|
|
1595
|
+
if (ipcServer) ipcServer.broadcastEvent("cloud.status", { connected });
|
|
1596
|
+
});
|
|
1597
|
+
const runtimeAppliers = /* @__PURE__ */ new Map();
|
|
1598
|
+
for (const [name, runtimeCfg] of Object.entries(config.runtimes)) if (name === "openclaw") {
|
|
1599
|
+
runtimeAppliers.set(name, new OpenClawApplier({ workspace: runtimeCfg.workspace }));
|
|
1600
|
+
logger.info({
|
|
1601
|
+
runtime: name,
|
|
1602
|
+
workspace: runtimeCfg.workspace
|
|
1603
|
+
}, "Registered OpenClaw runtime applier");
|
|
1604
|
+
} else logger.warn({ runtime: name }, "Unknown runtime type — skipping");
|
|
1605
|
+
integrationManager = new IntegrationManager({
|
|
1606
|
+
logger,
|
|
1607
|
+
runtimeAppliers
|
|
1608
|
+
});
|
|
1609
|
+
const integrationAdapter = new IntegrationManagerAdapter(integrationManager);
|
|
1610
|
+
cloudClient.setIntegrationManager(integrationAdapter);
|
|
1611
|
+
cloudClient.start();
|
|
1612
|
+
if (!managed) await writePidFile();
|
|
1613
|
+
const shutdown = async (signal) => {
|
|
1614
|
+
if (shuttingDown) return;
|
|
1615
|
+
shuttingDown = true;
|
|
1616
|
+
logger.info({ signal }, "Shutting down...");
|
|
1617
|
+
if (ipcServer) await ipcServer.stop();
|
|
1618
|
+
cloudClient.stop();
|
|
1619
|
+
commandQueue.stopGC();
|
|
1620
|
+
if (!managed) await removePidFile();
|
|
1621
|
+
logger.info("Daemon stopped");
|
|
1622
|
+
process.exit(0);
|
|
1623
|
+
};
|
|
1624
|
+
process.on("SIGTERM", () => {
|
|
1625
|
+
shutdown("SIGTERM");
|
|
1626
|
+
});
|
|
1627
|
+
process.on("SIGINT", () => {
|
|
1628
|
+
shutdown("SIGINT");
|
|
1629
|
+
});
|
|
1630
|
+
logger.info({
|
|
1631
|
+
pid: process.pid,
|
|
1632
|
+
socket: config.socketPath || "(managed)",
|
|
1633
|
+
cloud: config.gatewayWsUrl,
|
|
1634
|
+
agentId: config.agentId
|
|
1635
|
+
}, "Alfe Gateway Daemon started ✅");
|
|
1636
|
+
}
|
|
1637
|
+
async function handleCloudCommand(command) {
|
|
1638
|
+
const ipcRequest = cloudCommandToIPCRequest(command);
|
|
1639
|
+
if (!ipcRequest) {
|
|
1640
|
+
logger.warn({ command: command.command }, "Unrecognized cloud command");
|
|
1641
|
+
return {
|
|
1642
|
+
type: "COMMAND_ACK",
|
|
1643
|
+
commandId: command.commandId,
|
|
1644
|
+
status: "error",
|
|
1645
|
+
result: {
|
|
1646
|
+
code: "UNKNOWN_COMMAND",
|
|
1647
|
+
message: `Unrecognized command: ${command.command}`
|
|
1648
|
+
}
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
const plugins = ipcServer?.getRegisteredPlugins() ?? [];
|
|
1652
|
+
if (plugins.length === 0) {
|
|
1653
|
+
logger.info({
|
|
1654
|
+
commandId: command.commandId,
|
|
1655
|
+
command: command.command
|
|
1656
|
+
}, "No plugins connected — queuing command");
|
|
1657
|
+
commandQueue.enqueue("_default", ipcRequest, command.commandId);
|
|
1658
|
+
return {
|
|
1659
|
+
type: "COMMAND_ACK",
|
|
1660
|
+
commandId: command.commandId,
|
|
1661
|
+
status: "ok",
|
|
1662
|
+
result: {
|
|
1663
|
+
queued: true,
|
|
1664
|
+
message: "Command queued — no plugins connected"
|
|
1665
|
+
}
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
const [pluginId] = plugins[0];
|
|
1669
|
+
if (!ipcServer) return {
|
|
1670
|
+
type: "COMMAND_ACK",
|
|
1671
|
+
commandId: command.commandId,
|
|
1672
|
+
status: "error",
|
|
1673
|
+
result: {
|
|
1674
|
+
code: "NO_IPC",
|
|
1675
|
+
message: "IPC server not available"
|
|
1676
|
+
}
|
|
1677
|
+
};
|
|
1678
|
+
try {
|
|
1679
|
+
const response = await ipcServer.sendRequest(pluginId, ipcRequest.method, ipcRequest.params, 3e4);
|
|
1680
|
+
return ipcResponseToCloudAck(command.commandId, response);
|
|
1681
|
+
} catch (err) {
|
|
1682
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1683
|
+
return {
|
|
1684
|
+
type: "COMMAND_ACK",
|
|
1685
|
+
commandId: command.commandId,
|
|
1686
|
+
status: "error",
|
|
1687
|
+
result: {
|
|
1688
|
+
code: "PLUGIN_ERROR",
|
|
1689
|
+
message
|
|
1690
|
+
}
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
function handlePluginRequest(method, params, pluginId) {
|
|
1695
|
+
switch (method) {
|
|
1696
|
+
case "status": return Promise.resolve(handleStatus());
|
|
1697
|
+
case "integration.list": return Promise.resolve(handleIntegrationList());
|
|
1698
|
+
case "integration.report": return Promise.resolve(handleIntegrationReport(params, pluginId));
|
|
1699
|
+
default: return Promise.resolve({
|
|
1700
|
+
ok: false,
|
|
1701
|
+
error: {
|
|
1702
|
+
code: "UNKNOWN_METHOD",
|
|
1703
|
+
message: `Unknown method: ${method}`
|
|
1704
|
+
}
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
function handleStatus() {
|
|
1709
|
+
return {
|
|
1710
|
+
ok: true,
|
|
1711
|
+
payload: {
|
|
1712
|
+
daemon: {
|
|
1713
|
+
status: "running",
|
|
1714
|
+
pid: process.pid,
|
|
1715
|
+
uptime: (Date.now() - startedAt) / 1e3,
|
|
1716
|
+
version: "0.1.0"
|
|
1717
|
+
},
|
|
1718
|
+
cloud: {
|
|
1719
|
+
status: cloudConnected ? "connected" : "disconnected",
|
|
1720
|
+
latencyMs: cloudClient.getLatencyMs()
|
|
1721
|
+
},
|
|
1722
|
+
plugins: (ipcServer?.getRegisteredPlugins() ?? []).map(([, info]) => ({
|
|
1723
|
+
name: info.name,
|
|
1724
|
+
version: info.version,
|
|
1725
|
+
capabilities: info.capabilities,
|
|
1726
|
+
connectedAt: info.connectedAt,
|
|
1727
|
+
lastSeen: info.lastSeen
|
|
1728
|
+
})),
|
|
1729
|
+
commandQueue: { totalPending: commandQueue.totalPending() }
|
|
1730
|
+
}
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
function handleIntegrationList() {
|
|
1734
|
+
return {
|
|
1735
|
+
ok: true,
|
|
1736
|
+
payload: { integrations: integrationManager.list() }
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
function handleIntegrationReport(params, pluginId) {
|
|
1740
|
+
const { name, status, detail } = params;
|
|
1741
|
+
if (!name || !status) return {
|
|
1742
|
+
ok: false,
|
|
1743
|
+
error: {
|
|
1744
|
+
code: "INVALID_PARAMS",
|
|
1745
|
+
message: "name and status are required"
|
|
1746
|
+
}
|
|
1747
|
+
};
|
|
1748
|
+
logger.info({
|
|
1749
|
+
pluginId,
|
|
1750
|
+
integration: name,
|
|
1751
|
+
status,
|
|
1752
|
+
detail
|
|
1753
|
+
}, "Integration status report");
|
|
1754
|
+
return {
|
|
1755
|
+
ok: true,
|
|
1756
|
+
payload: { acknowledged: true }
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
//#endregion
|
|
1760
|
+
//#region src/health.ts
|
|
1761
|
+
/**
|
|
1762
|
+
* Health reporting — used by `alfe doctor` and `alfe gateway status`.
|
|
1763
|
+
*
|
|
1764
|
+
* Connects to the daemon's IPC socket and queries health information.
|
|
1765
|
+
* Works even when the daemon is the only thing running (OpenClaw can be down).
|
|
1766
|
+
*/
|
|
1767
|
+
/**
|
|
1768
|
+
* Query daemon health via IPC socket.
|
|
1769
|
+
* Used by `alfe doctor` and `alfe gateway status`.
|
|
1770
|
+
*/
|
|
1771
|
+
async function queryDaemonHealth(socketPath, timeoutMs = 5e3) {
|
|
1772
|
+
return new Promise((resolve, reject) => {
|
|
1773
|
+
const timer = setTimeout(() => {
|
|
1774
|
+
socket.destroy();
|
|
1775
|
+
reject(/* @__PURE__ */ new Error("Health check timed out"));
|
|
1776
|
+
}, timeoutMs);
|
|
1777
|
+
let buffer = "";
|
|
1778
|
+
const requestId = randomUUID();
|
|
1779
|
+
const socket = createConnection(socketPath, () => {
|
|
1780
|
+
const request = JSON.stringify({
|
|
1781
|
+
type: "req",
|
|
1782
|
+
id: requestId,
|
|
1783
|
+
method: "status",
|
|
1784
|
+
params: {}
|
|
1785
|
+
}) + "\n";
|
|
1786
|
+
socket.write(request);
|
|
1787
|
+
});
|
|
1788
|
+
socket.on("data", (data) => {
|
|
1789
|
+
buffer += data.toString();
|
|
1790
|
+
const newlineIdx = buffer.indexOf("\n");
|
|
1791
|
+
if (newlineIdx === -1) return;
|
|
1792
|
+
const line = buffer.slice(0, newlineIdx).trim();
|
|
1793
|
+
buffer = buffer.slice(newlineIdx + 1);
|
|
1794
|
+
clearTimeout(timer);
|
|
1795
|
+
socket.end();
|
|
1796
|
+
try {
|
|
1797
|
+
const response = JSON.parse(line);
|
|
1798
|
+
if (response.ok && response.payload) resolve(response.payload);
|
|
1799
|
+
else reject(new Error(response.error?.message ?? "Health check failed"));
|
|
1800
|
+
} catch {
|
|
1801
|
+
reject(/* @__PURE__ */ new Error("Invalid health response"));
|
|
1802
|
+
}
|
|
1803
|
+
});
|
|
1804
|
+
socket.on("error", (err) => {
|
|
1805
|
+
clearTimeout(timer);
|
|
1806
|
+
if (err.code === "ECONNREFUSED" || err.code === "ENOENT") resolve({
|
|
1807
|
+
daemon: { status: "stopped" },
|
|
1808
|
+
cloud: { status: "unknown" },
|
|
1809
|
+
plugins: [],
|
|
1810
|
+
commandQueue: { totalPending: 0 }
|
|
1811
|
+
});
|
|
1812
|
+
else reject(err);
|
|
1813
|
+
});
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
/**
|
|
1817
|
+
* Format health info for terminal display.
|
|
1818
|
+
*/
|
|
1819
|
+
function formatHealthReport(health) {
|
|
1820
|
+
const lines = [];
|
|
1821
|
+
const daemonIcon = health.daemon.status === "running" ? "✓" : "✗";
|
|
1822
|
+
lines.push(` Gateway daemon ${daemonIcon} ${health.daemon.status}` + (health.daemon.uptime ? ` (uptime: ${formatUptime(health.daemon.uptime)})` : "") + (health.daemon.version ? ` v${health.daemon.version}` : ""));
|
|
1823
|
+
const cloudIcon = health.cloud.status === "connected" ? "✓" : "✗";
|
|
1824
|
+
lines.push(` Cloud connection ${cloudIcon} ${health.cloud.status}` + (health.cloud.latencyMs !== void 0 ? ` (latency: ${String(health.cloud.latencyMs)}ms)` : ""));
|
|
1825
|
+
if (health.plugins.length === 0) lines.push(" Plugins – none connected");
|
|
1826
|
+
else for (const plugin of health.plugins) {
|
|
1827
|
+
const lastSeenAgo = Date.now() - plugin.lastSeen;
|
|
1828
|
+
lines.push(` Plugin: ${plugin.name} ✓ v${plugin.version} (last seen ${formatDuration(lastSeenAgo)} ago)`);
|
|
1829
|
+
}
|
|
1830
|
+
if (health.commandQueue.totalPending > 0) lines.push(` Queued commands ⚠ ${String(health.commandQueue.totalPending)} pending`);
|
|
1831
|
+
return lines.join("\n");
|
|
1832
|
+
}
|
|
1833
|
+
function formatUptime(seconds) {
|
|
1834
|
+
if (seconds < 60) return `${String(Math.round(seconds))}s`;
|
|
1835
|
+
if (seconds < 3600) return `${String(Math.round(seconds / 60))}m`;
|
|
1836
|
+
if (seconds < 86400) return `${String(Math.round(seconds / 3600))}h`;
|
|
1837
|
+
return `${String(Math.round(seconds / 86400))}d`;
|
|
1838
|
+
}
|
|
1839
|
+
function formatDuration(ms) {
|
|
1840
|
+
const seconds = ms / 1e3;
|
|
1841
|
+
if (seconds < 60) return `${String(Math.round(seconds))}s`;
|
|
1842
|
+
if (seconds < 3600) return `${String(Math.round(seconds / 60))}m`;
|
|
1843
|
+
return `${String(Math.round(seconds / 3600))}h`;
|
|
1844
|
+
}
|
|
1845
|
+
//#endregion
|
|
1846
|
+
export { installService as a, PROTOCOL_VERSION as c, SOCKET_PATH as d, fetchAgentConfig as f, LOG_FILE as h, checkExistingDaemon as i, ALFE_DIR as l, resolveAgentIdentity as m, queryDaemonHealth as n, stopExistingDaemon as o, loadDaemonConfig as p, startDaemon as r, uninstallService as s, formatHealthReport as t, PID_PATH as u };
|