@saadnvd1/agent-os 0.3.508 → 0.3.509
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/lib/connect/heartbeat.ts +88 -0
- package/lib/connect/serve.ts +44 -3
- package/package.json +1 -1
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tells the Connect service this machine is alive: one signed beat when
|
|
3
|
+
* Connect starts, then one a minute. The answer can switch the machine off
|
|
4
|
+
* (the account disabled it) or say it is gone (removed from the account).
|
|
5
|
+
* Nothing here may throw: a rejection would take AgentOS down.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { ApiError, clean } from "./control-api";
|
|
11
|
+
|
|
12
|
+
export const HEARTBEAT_MS = 60_000;
|
|
13
|
+
|
|
14
|
+
export interface HeartbeatOptions {
|
|
15
|
+
beat: () => Promise<{ enabled: boolean }>;
|
|
16
|
+
/** The account disabled this machine: stop the tunnel. */
|
|
17
|
+
onDisabled: () => void;
|
|
18
|
+
/** The service no longer knows this machine. */
|
|
19
|
+
onGone: () => void;
|
|
20
|
+
log?: (line: string) => void;
|
|
21
|
+
intervalMs?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function startHeartbeat(opts: HeartbeatOptions) {
|
|
25
|
+
const log = opts.log ?? (() => {});
|
|
26
|
+
const every = opts.intervalMs ?? HEARTBEAT_MS;
|
|
27
|
+
let stopped = false;
|
|
28
|
+
let failing = false;
|
|
29
|
+
let timer: NodeJS.Timeout | null = null;
|
|
30
|
+
|
|
31
|
+
const stop = () => {
|
|
32
|
+
stopped = true;
|
|
33
|
+
if (timer) clearTimeout(timer);
|
|
34
|
+
timer = null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const next = () => {
|
|
38
|
+
if (stopped) return;
|
|
39
|
+
timer = setTimeout(tick, every);
|
|
40
|
+
timer.unref?.();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const tick = async () => {
|
|
44
|
+
timer = null;
|
|
45
|
+
try {
|
|
46
|
+
const { enabled } = await opts.beat();
|
|
47
|
+
if (stopped) return;
|
|
48
|
+
failing = false;
|
|
49
|
+
if (enabled === false) {
|
|
50
|
+
stop();
|
|
51
|
+
log("connect: this machine was disabled in your account; tunnel off");
|
|
52
|
+
opts.onDisabled();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if (stopped) return;
|
|
57
|
+
const status = err instanceof ApiError ? err.status : 0;
|
|
58
|
+
if (status === 401 || status === 404) {
|
|
59
|
+
stop();
|
|
60
|
+
log("connect: machine no longer linked, run agent-os connect");
|
|
61
|
+
opts.onGone();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!failing) {
|
|
65
|
+
failing = true;
|
|
66
|
+
log(
|
|
67
|
+
`connect: heartbeat failed: ${clean((err as Error)?.message ?? String(err))}; retrying`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
next();
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
void tick();
|
|
75
|
+
return { stop };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The running AgentOS version, from package.json. */
|
|
79
|
+
export function appVersion(cwd = process.cwd()): string {
|
|
80
|
+
try {
|
|
81
|
+
const pkg = JSON.parse(
|
|
82
|
+
fs.readFileSync(path.join(cwd, "package.json"), "utf8")
|
|
83
|
+
) as { version?: string };
|
|
84
|
+
return pkg.version ?? "unknown";
|
|
85
|
+
} catch {
|
|
86
|
+
return "unknown";
|
|
87
|
+
}
|
|
88
|
+
}
|
package/lib/connect/serve.ts
CHANGED
|
@@ -28,7 +28,13 @@ import {
|
|
|
28
28
|
type ConnectConfig,
|
|
29
29
|
} from "./config";
|
|
30
30
|
import { shouldStartConnect, takeConnectLock } from "./guard";
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
checkApiUrl,
|
|
33
|
+
heartbeat as sendHeartbeat,
|
|
34
|
+
requestTunnel,
|
|
35
|
+
type Tunnel,
|
|
36
|
+
} from "./control-api";
|
|
37
|
+
import { appVersion, startHeartbeat } from "./heartbeat";
|
|
32
38
|
import { ensureCloudflared } from "./cloudflared";
|
|
33
39
|
import { superviseTunnel, type Spawn, type TunnelState } from "./supervise";
|
|
34
40
|
|
|
@@ -93,12 +99,15 @@ export interface ConnectDeps {
|
|
|
93
99
|
) => Promise<Tunnel>;
|
|
94
100
|
spawn?: Spawn;
|
|
95
101
|
delays?: Parameters<typeof superviseTunnel>[0]["delays"];
|
|
102
|
+
heartbeat?: typeof sendHeartbeat;
|
|
103
|
+
heartbeatMs?: number;
|
|
96
104
|
}
|
|
97
105
|
|
|
98
106
|
interface Running {
|
|
99
107
|
server: http.Server;
|
|
100
108
|
sockets: Set<Socket>;
|
|
101
109
|
supervisor: ReturnType<typeof superviseTunnel>;
|
|
110
|
+
heartbeat: ReturnType<typeof startHeartbeat>;
|
|
102
111
|
release: () => void;
|
|
103
112
|
}
|
|
104
113
|
|
|
@@ -115,6 +124,9 @@ export function startConnect(
|
|
|
115
124
|
let base: ConnectStatus = { state: "off" };
|
|
116
125
|
let hostname = "";
|
|
117
126
|
let running: Running | null = null;
|
|
127
|
+
// Set when the service disabled or dropped this machine; sync() leaves it
|
|
128
|
+
// off until connect.json is turned off or relinked.
|
|
129
|
+
let halted: string | null = null;
|
|
118
130
|
// Set while the tunnel listener can't bind; it keeps trying.
|
|
119
131
|
let bindError: string | undefined;
|
|
120
132
|
g.__agentosConnect = () => {
|
|
@@ -140,6 +152,7 @@ export function startConnect(
|
|
|
140
152
|
const down = (why: string) => {
|
|
141
153
|
if (!running) return;
|
|
142
154
|
bindError = undefined;
|
|
155
|
+
running.heartbeat.stop();
|
|
143
156
|
running.supervisor.stop();
|
|
144
157
|
running.sockets.forEach((s) => s.destroy());
|
|
145
158
|
running.server.close();
|
|
@@ -162,6 +175,14 @@ export function startConnect(
|
|
|
162
175
|
hostname = config.hostname;
|
|
163
176
|
base = { state: "off", enrolled: true, hostname: hostname || undefined };
|
|
164
177
|
if (!read.enabled) return;
|
|
178
|
+
if (halted) {
|
|
179
|
+
base = {
|
|
180
|
+
...base,
|
|
181
|
+
state: "off",
|
|
182
|
+
reason: "stopped by the Connect service",
|
|
183
|
+
};
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
165
186
|
try {
|
|
166
187
|
checkApiUrl(config.apiUrl, env.AGENTOS_CONNECT_OPERATOR === "1");
|
|
167
188
|
} catch (err) {
|
|
@@ -225,7 +246,22 @@ export function startConnect(
|
|
|
225
246
|
return { bin, token: tunnel.token };
|
|
226
247
|
},
|
|
227
248
|
});
|
|
228
|
-
|
|
249
|
+
const beat = deps.heartbeat ?? sendHeartbeat;
|
|
250
|
+
const version = appVersion();
|
|
251
|
+
const heartbeat = startHeartbeat({
|
|
252
|
+
beat: () => beat(config, machineKey, version),
|
|
253
|
+
log,
|
|
254
|
+
intervalMs: deps.heartbeatMs,
|
|
255
|
+
onDisabled: () => {
|
|
256
|
+
halted = config.machineId;
|
|
257
|
+
down("disabled in your account");
|
|
258
|
+
},
|
|
259
|
+
onGone: () => {
|
|
260
|
+
halted = config.machineId;
|
|
261
|
+
down("machine no longer linked");
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
running = { server, sockets, supervisor, heartbeat, release: lock.release };
|
|
229
265
|
server.listen(tunnelPort, "127.0.0.1");
|
|
230
266
|
};
|
|
231
267
|
|
|
@@ -233,7 +269,12 @@ export function startConnect(
|
|
|
233
269
|
const sync = () => {
|
|
234
270
|
try {
|
|
235
271
|
const read = readConnect(dir);
|
|
236
|
-
|
|
272
|
+
if (
|
|
273
|
+
halted &&
|
|
274
|
+
(!read.ok || !read.enabled || read.files.config.machineId !== halted)
|
|
275
|
+
)
|
|
276
|
+
halted = null;
|
|
277
|
+
const want = read.ok && read.enabled && !halted;
|
|
237
278
|
if (running && !want) down(read.ok ? "turned off" : read.reason);
|
|
238
279
|
else if (!running && want) up();
|
|
239
280
|
else if (!running) up(); // refreshes what the status shows
|