@cocorograph/hub-agent 0.5.6 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/main.mjs CHANGED
@@ -10,6 +10,7 @@
10
10
  *
11
11
  * 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
12
12
  */
13
+ import { readFileSync } from "node:fs"
13
14
  import { readFile } from "node:fs/promises"
14
15
  import os from "node:os"
15
16
  import path from "node:path"
@@ -53,6 +54,20 @@ async function readBundleVersion() {
53
54
  }
54
55
  }
55
56
 
57
+ /**
58
+ * WsClient の runtime refresh / fs.watch コールバックから同期で呼ばれる版。
59
+ * 失敗時は null を返す (silent fail)。
60
+ */
61
+ function readBundleVersionSync() {
62
+ try {
63
+ const text = readFileSync(BUNDLE_MANIFEST_PATH, "utf-8")
64
+ const data = JSON.parse(text)
65
+ return typeof data?.version === "string" ? data.version : null
66
+ } catch {
67
+ return null
68
+ }
69
+ }
70
+
56
71
  export async function startDaemon({ version, ptyModule } = {}) {
57
72
  const config = await readConfig()
58
73
  if (!config) {
@@ -74,7 +89,13 @@ export async function startDaemon({ version, ptyModule } = {}) {
74
89
  logger.info({ bundleVersion }, "hub bundle version detected")
75
90
  }
76
91
 
77
- const client = new WsClient(config, { logger, version, bundleVersion })
92
+ const client = new WsClient(config, {
93
+ logger,
94
+ version,
95
+ bundleVersion,
96
+ bundleVersionProvider: readBundleVersionSync,
97
+ bundleManifestPath: BUNDLE_MANIFEST_PATH,
98
+ })
78
99
 
79
100
  // EventEmitter の 'error' は listener が無いと process が落ちる。
80
101
  // ws-client は close 側で reconnect を予約しているので、ここでは log だけ。
package/src/ws-client.mjs CHANGED
@@ -5,10 +5,14 @@
5
5
  * - 起動時に `hello`、30s おきに `heartbeat` を送信
6
6
  * - 切断時は exponential backoff (1s, 2s, 4s, ..., max 30s) で再接続
7
7
  * - サーバから受け取った JSON は `onMessage` callback に渡す
8
+ * - `bundleVersionProvider` + `bundleManifestPath` を渡すと、manifest.json の
9
+ * 変更を fs.watch で検知して即時 heartbeat を送信し Cockpit UI に最新版を
10
+ * リアルタイム反映する (heartbeat 30s 待ちを回避)
8
11
  *
9
12
  * 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
10
13
  */
11
14
  import { EventEmitter } from "node:events"
15
+ import fs from "node:fs"
12
16
  import os from "node:os"
13
17
 
14
18
  import WebSocket from "ws"
@@ -16,11 +20,19 @@ import WebSocket from "ws"
16
20
  const HEARTBEAT_INTERVAL_MS = 30_000
17
21
  const MIN_BACKOFF_MS = 1_000
18
22
  const MAX_BACKOFF_MS = 30_000
23
+ const BUNDLE_WATCH_DEBOUNCE_MS = 500
19
24
 
20
25
  export class WsClient extends EventEmitter {
21
26
  /**
22
27
  * @param {{ hub_url: string, agent_id: string, agent_token: string }} config
23
- * @param {{ logger?: import('pino').Logger, version?: string, bundleVersion?: string|null, hostname?: string }} opts
28
+ * @param {{
29
+ * logger?: import('pino').Logger,
30
+ * version?: string,
31
+ * bundleVersion?: string|null,
32
+ * bundleVersionProvider?: () => (string|null),
33
+ * bundleManifestPath?: string|null,
34
+ * hostname?: string,
35
+ * }} opts
24
36
  */
25
37
  constructor(config, opts = {}) {
26
38
  super()
@@ -28,6 +40,13 @@ export class WsClient extends EventEmitter {
28
40
  this.logger = opts.logger
29
41
  this.version = opts.version || "0.1.0"
30
42
  this.bundleVersion = opts.bundleVersion || null
43
+ this.bundleVersionProvider =
44
+ typeof opts.bundleVersionProvider === "function"
45
+ ? opts.bundleVersionProvider
46
+ : null
47
+ this.bundleManifestPath = opts.bundleManifestPath || null
48
+ this.bundleWatcher = null
49
+ this.bundleWatchDebounceTimer = null
31
50
  this.hostname = opts.hostname || os.hostname()
32
51
  this.ws = null
33
52
  this.heartbeatTimer = null
@@ -52,6 +71,8 @@ export class WsClient extends EventEmitter {
52
71
  ws.on("open", () => {
53
72
  this.backoff = MIN_BACKOFF_MS
54
73
  this.logger?.info("ws open")
74
+ // hello 前に最新の bundle version を読み直す (再接続時の鮮度確保)
75
+ this._refreshBundleVersion()
55
76
  this._sendJson({
56
77
  type: "hello",
57
78
  agent_id: this.config.agent_id,
@@ -60,6 +81,7 @@ export class WsClient extends EventEmitter {
60
81
  bundle_version: this.bundleVersion,
61
82
  })
62
83
  this._startHeartbeat()
84
+ this._startBundleWatcher()
63
85
  this.emit("open")
64
86
  })
65
87
 
@@ -76,6 +98,7 @@ export class WsClient extends EventEmitter {
76
98
 
77
99
  ws.on("close", (code, reason) => {
78
100
  this._stopHeartbeat()
101
+ this._stopBundleWatcher()
79
102
  this.logger?.info({ code, reason: reason?.toString() }, "ws close")
80
103
  this.emit("close", { code, reason })
81
104
  if (!this.stopped) this._scheduleReconnect()
@@ -101,6 +124,7 @@ export class WsClient extends EventEmitter {
101
124
  stop() {
102
125
  this.stopped = true
103
126
  this._stopHeartbeat()
127
+ this._stopBundleWatcher()
104
128
  if (this.reconnectTimer) {
105
129
  clearTimeout(this.reconnectTimer)
106
130
  this.reconnectTimer = null
@@ -138,6 +162,9 @@ export class WsClient extends EventEmitter {
138
162
  _startHeartbeat() {
139
163
  this._stopHeartbeat()
140
164
  this.heartbeatTimer = setInterval(() => {
165
+ // heartbeat 都度 provider 経由で bundle version を最新化する。
166
+ // fs.watch を取り逃した変更 (atomic rename / 別マウント越し等) への保険。
167
+ this._refreshBundleVersion()
141
168
  // 送信失敗 (= ws not OPEN) を検知したら即時 close + reconnect 発火。
142
169
  // setInterval を放置すると死んだコネクションに heartbeat を投げ続けて
143
170
  // 30s 間気付かないので、close を強制トリガーする。
@@ -162,6 +189,99 @@ export class WsClient extends EventEmitter {
162
189
  }
163
190
  }
164
191
 
192
+ /**
193
+ * bundleVersionProvider を呼んで `this.bundleVersion` を最新化する。
194
+ * 値が変わったら true を返す。provider が未設定 / 失敗時は false。
195
+ */
196
+ _refreshBundleVersion() {
197
+ if (!this.bundleVersionProvider) return false
198
+ try {
199
+ const next = this.bundleVersionProvider()
200
+ const normalized = typeof next === "string" && next.length > 0 ? next : null
201
+ if (normalized === this.bundleVersion) return false
202
+ this.logger?.info(
203
+ { from: this.bundleVersion, to: normalized },
204
+ "bundle version refreshed",
205
+ )
206
+ this.bundleVersion = normalized
207
+ return true
208
+ } catch (err) {
209
+ this.logger?.warn({ err: err.message }, "bundle version refresh failed")
210
+ return false
211
+ }
212
+ }
213
+
214
+ /**
215
+ * `bundleManifestPath` を fs.watch で監視し、変更時に即時 heartbeat を送る。
216
+ * Cockpit UI への反映ラグを 30s heartbeat 周期から ~15s (UI poll) に短縮する。
217
+ * fs.watch の起動に失敗しても heartbeat 都度 refresh が保険として動くので no-op。
218
+ */
219
+ _startBundleWatcher() {
220
+ if (!this.bundleManifestPath || this.bundleWatcher) return
221
+ try {
222
+ this.bundleWatcher = fs.watch(
223
+ this.bundleManifestPath,
224
+ { persistent: false },
225
+ () => this._onBundleManifestChange(),
226
+ )
227
+ this.bundleWatcher.on("error", (err) => {
228
+ this.logger?.warn(
229
+ { err: err.message, path: this.bundleManifestPath },
230
+ "bundle manifest watcher errored",
231
+ )
232
+ })
233
+ this.logger?.debug(
234
+ { path: this.bundleManifestPath },
235
+ "bundle manifest watcher started",
236
+ )
237
+ } catch (err) {
238
+ this.logger?.warn(
239
+ { err: err.message, path: this.bundleManifestPath },
240
+ "bundle manifest watcher failed to start",
241
+ )
242
+ }
243
+ }
244
+
245
+ _stopBundleWatcher() {
246
+ if (this.bundleWatchDebounceTimer) {
247
+ clearTimeout(this.bundleWatchDebounceTimer)
248
+ this.bundleWatchDebounceTimer = null
249
+ }
250
+ if (this.bundleWatcher) {
251
+ try {
252
+ this.bundleWatcher.close()
253
+ } catch {
254
+ /* ignore */
255
+ }
256
+ this.bundleWatcher = null
257
+ }
258
+ }
259
+
260
+ /**
261
+ * fs.watch のコールバック。同一書き込みで多重 fire することがある (macOS) ため
262
+ * デバウンスしてから refresh + 即時 heartbeat する。
263
+ */
264
+ _onBundleManifestChange() {
265
+ if (this.bundleWatchDebounceTimer) {
266
+ clearTimeout(this.bundleWatchDebounceTimer)
267
+ }
268
+ this.bundleWatchDebounceTimer = setTimeout(() => {
269
+ this.bundleWatchDebounceTimer = null
270
+ const changed = this._refreshBundleVersion()
271
+ if (!changed) return
272
+ const ok = this._sendJson({
273
+ type: "heartbeat",
274
+ uptime_sec: Math.floor((Date.now() - this.startedAt) / 1000),
275
+ bundle_version: this.bundleVersion,
276
+ })
277
+ if (!ok) {
278
+ this.logger?.warn("bundle-change heartbeat send failed, forcing reconnect")
279
+ this._forceReconnect()
280
+ }
281
+ }, BUNDLE_WATCH_DEBOUNCE_MS)
282
+ this.bundleWatchDebounceTimer.unref?.()
283
+ }
284
+
165
285
  _forceReconnect() {
166
286
  // 現在の ws を強制 close。close ハンドラが _scheduleReconnect を呼ぶので
167
287
  // それ以上の処理は不要。stopped 中は no-op。