a2acalling 0.6.48 → 0.6.49

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.
@@ -0,0 +1,1284 @@
1
+ # A2A Auto-Updater Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Add automatic self-updating to `a2acalling` so globally-installed instances pull down new versions without user intervention, as long as no call is active. Keep the implementation minimal (zero new dependencies) and Linux-focused.
6
+
7
+ **Architecture:** A lightweight `UpdateManager` class that (1) periodically checks the npm registry for a newer version via native `fetch()`, (2) queries the running server's `/api/a2a/status` endpoint to confirm no active calls, (3) shells out to `npm install -g a2acalling@latest` to replace itself, and (4) gracefully restarts the server via PID-file SIGTERM + re-spawn. The check loop runs inside the server process on a configurable interval (default: 1 hour). A manual `a2a update --auto` flag enables/disables the feature via config.
8
+
9
+ **Tech Stack:** Node.js built-in `fetch()` (Node 18+), `child_process.execFile`, existing `pid-file.js`, existing `call-monitor.js`, existing `config.js`, existing zero-dependency test runner.
10
+
11
+ **Linear ticket:** A2A-26
12
+
13
+ ---
14
+
15
+ ## Research Summary
16
+
17
+ ### Approaches Considered
18
+
19
+ | Approach | Pros | Cons | Verdict |
20
+ |----------|------|------|---------|
21
+ | `update-notifier` package | Proven, 5K+ dependents, non-blocking | Adds dependency, notification-only (no auto-update), ESM-only in v7 | **Rejected** -- we want actual auto-update, not just notification |
22
+ | `cli-autoupdater` / `selfupdate` packages | Ready-made auto-update | Extra dependencies, opinionated, poor maintenance | **Rejected** -- bloat vs. simplicity |
23
+ | npm registry HTTP + `npm install -g` shell-out | Zero dependencies, uses Node 18+ native `fetch()`, full control | Must handle edge cases (permissions, network, rollback) ourselves | **Chosen** -- aligns with project's zero-dependency ethos |
24
+ | Git-based pull (for dev installs) | Already implemented in `a2a update` | Only works for git clones, not npm global installs | **Keep existing** -- auto-updater targets npm installs only |
25
+
26
+ ### Chosen Approach: Zero-Dependency Periodic Self-Update
27
+
28
+ 1. **Version check:** `GET https://registry.npmjs.org/a2acalling/latest` returns `{ "version": "x.y.z" }` -- tiny payload (~200 bytes), no auth needed.
29
+ 2. **Call-safety gate:** Query `CallMonitor.getActiveCount()` (in-process) or the server's internal state before proceeding. If any conversation is active, defer the update.
30
+ 3. **Update execution:** `child_process.execFile('npm', ['install', '-g', 'a2acalling@<version>'])` -- deterministic version pin, not `@latest`, to avoid TOCTOU race.
31
+ 4. **Graceful restart:** SIGTERM to own process after the npm install completes. The existing `shutdown()` handler in `server.js` already cleans up the PID file and closes connections. A small wrapper script or the postinstall hook re-spawns the server.
32
+ 5. **Rollback:** If the new version's server fails to start within 30 seconds, `npm install -g a2acalling@<old-version>` restores the previous version.
33
+
34
+ ### Key Design Decisions
35
+
36
+ - **Runs inside server process** -- no separate daemon, no cron job, no systemd timer. The server already runs persistently; adding a `setInterval` is the simplest integration point.
37
+ - **On by default** -- auto-update is enabled out of the box. `a2a update --auto off` disables. Config key: `auto_update.enabled` (defaults to `true`). Users who want manual control can opt out.
38
+ - **Respects env vars** -- `A2A_AUTO_UPDATE=0` or `NO_AUTO_UPDATE=1` disables. CI environments are auto-detected and skipped.
39
+ - **No major-version jumps** -- by default, only auto-update within the same major version (e.g., `0.6.x -> 0.6.y`). Cross-major updates require manual `a2a update`.
40
+ - **GUI-visible background updates on macOS** -- keep updates automatic, but surface updater state in the native app/tray so users can see progress and failures without opening logs.
41
+
42
+ ### macOS UX Contract (Background Updates)
43
+
44
+ For users running the native app, background updates should be visible in-app even when no call is active.
45
+
46
+ Required states:
47
+ - `up_to_date`
48
+ - `checking`
49
+ - `downloading`
50
+ - `applying`
51
+ - `waiting_for_safe_restart` (active call in progress)
52
+ - `restarting`
53
+ - `failed`
54
+
55
+ Required UX behaviors:
56
+ - Show current package version and target version during update.
57
+ - Show clear reason when an update is deferred (`active_calls > 0`) or fails (network/permissions/install error).
58
+ - Expose `Update now` and `Retry` actions.
59
+ - Keep auto-update enabled by default; visibility is the fix, not disabling background updates.
60
+
61
+ ### Release Sequence Guardrail (npm + macOS App)
62
+
63
+ Because macOS app binaries are downloaded from GitHub Releases during npm install, release order must guarantee app artifacts exist before users install the new npm version.
64
+
65
+ Simple rule:
66
+ 1. Build/upload macOS app artifacts to GitHub Release `vX.Y.Z`.
67
+ 2. Publish npm `a2acalling@X.Y.Z`.
68
+
69
+ This avoids a window where npm points to a version whose matching `A2A-Callbook-<version>.app.tar.gz` does not exist yet.
70
+
71
+ ---
72
+
73
+ ## Phase 1: Version Checker (Zero Dependencies)
74
+
75
+ ### Task 1: Create `src/lib/update-checker.js` -- fetch latest version from npm registry
76
+
77
+ **Files:**
78
+ - Create: `src/lib/update-checker.js`
79
+ - Create: `test/unit/update-checker.test.js`
80
+
81
+ **Step 1: Write failing test**
82
+
83
+ Create `test/unit/update-checker.test.js`:
84
+
85
+ ```js
86
+ /**
87
+ * Update Checker Tests
88
+ *
89
+ * Covers: checkForUpdate, compareVersions, shouldAutoUpdate
90
+ */
91
+
92
+ module.exports = function (test, assert, helpers) {
93
+ const path = require('path');
94
+
95
+ // We'll mock fetch globally for these tests
96
+ function requireFresh() {
97
+ const modPath = require.resolve('../../src/lib/update-checker');
98
+ delete require.cache[modPath];
99
+ return require('../../src/lib/update-checker');
100
+ }
101
+
102
+ test('compareVersions returns 1 when remote is newer', () => {
103
+ const { compareVersions } = requireFresh();
104
+ assert.equal(compareVersions('0.6.48', '0.6.49'), -1, 'patch bump');
105
+ assert.equal(compareVersions('0.6.48', '0.7.0'), -1, 'minor bump');
106
+ assert.equal(compareVersions('0.6.48', '1.0.0'), -1, 'major bump');
107
+ });
108
+
109
+ test('compareVersions returns 0 when equal', () => {
110
+ const { compareVersions } = requireFresh();
111
+ assert.equal(compareVersions('0.6.48', '0.6.48'), 0);
112
+ });
113
+
114
+ test('compareVersions returns 1 when local is newer', () => {
115
+ const { compareVersions } = requireFresh();
116
+ assert.equal(compareVersions('0.7.0', '0.6.48'), 1);
117
+ });
118
+
119
+ test('isSameMajor returns true for same major version', () => {
120
+ const { isSameMajor } = requireFresh();
121
+ assert.ok(isSameMajor('0.6.48', '0.6.50'));
122
+ assert.ok(isSameMajor('0.6.48', '0.7.0'));
123
+ assert.ok(!isSameMajor('0.6.48', '1.0.0'));
124
+ });
125
+
126
+ test('parseVersion handles valid semver strings', () => {
127
+ const { parseVersion } = requireFresh();
128
+ const v = parseVersion('1.2.3');
129
+ assert.equal(v.major, 1);
130
+ assert.equal(v.minor, 2);
131
+ assert.equal(v.patch, 3);
132
+ });
133
+
134
+ test('parseVersion returns null for invalid input', () => {
135
+ const { parseVersion } = requireFresh();
136
+ assert.equal(parseVersion('not-a-version'), null);
137
+ assert.equal(parseVersion(''), null);
138
+ assert.equal(parseVersion(null), null);
139
+ });
140
+ };
141
+ ```
142
+
143
+ **Step 2: Implement `src/lib/update-checker.js`**
144
+
145
+ ```js
146
+ /**
147
+ * Update Checker
148
+ *
149
+ * Checks the npm registry for newer versions of a2acalling.
150
+ * Zero dependencies -- uses Node 18+ built-in fetch().
151
+ */
152
+
153
+ const REGISTRY_URL = 'https://registry.npmjs.org/a2acalling/latest';
154
+ const FETCH_TIMEOUT_MS = 15000;
155
+
156
+ /**
157
+ * Parse a semver string into { major, minor, patch }.
158
+ * Returns null if invalid.
159
+ */
160
+ function parseVersion(str) {
161
+ if (!str || typeof str !== 'string') return null;
162
+ const match = str.trim().match(/^(\d+)\.(\d+)\.(\d+)/);
163
+ if (!match) return null;
164
+ return {
165
+ major: parseInt(match[1], 10),
166
+ minor: parseInt(match[2], 10),
167
+ patch: parseInt(match[3], 10)
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Compare two semver strings.
173
+ * Returns -1 if a < b, 0 if equal, 1 if a > b.
174
+ */
175
+ function compareVersions(a, b) {
176
+ const va = parseVersion(a);
177
+ const vb = parseVersion(b);
178
+ if (!va || !vb) return 0;
179
+
180
+ if (va.major !== vb.major) return va.major < vb.major ? -1 : 1;
181
+ if (va.minor !== vb.minor) return va.minor < vb.minor ? -1 : 1;
182
+ if (va.patch !== vb.patch) return va.patch < vb.patch ? -1 : 1;
183
+ return 0;
184
+ }
185
+
186
+ /**
187
+ * Check if two versions share the same major version.
188
+ */
189
+ function isSameMajor(a, b) {
190
+ const va = parseVersion(a);
191
+ const vb = parseVersion(b);
192
+ if (!va || !vb) return false;
193
+ return va.major === vb.major;
194
+ }
195
+
196
+ /**
197
+ * Fetch the latest published version from the npm registry.
198
+ * Returns { version: string } or { error: string }.
199
+ */
200
+ async function fetchLatestVersion() {
201
+ try {
202
+ const controller = new AbortController();
203
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
204
+
205
+ const res = await fetch(REGISTRY_URL, {
206
+ signal: controller.signal,
207
+ headers: { 'Accept': 'application/json' }
208
+ });
209
+ clearTimeout(timeout);
210
+
211
+ if (!res.ok) {
212
+ return { error: `Registry returned ${res.status}` };
213
+ }
214
+
215
+ const data = await res.json();
216
+ if (!data.version) {
217
+ return { error: 'No version field in registry response' };
218
+ }
219
+
220
+ return { version: data.version };
221
+ } catch (err) {
222
+ if (err.name === 'AbortError') {
223
+ return { error: 'Registry request timed out' };
224
+ }
225
+ return { error: err.message || 'Unknown fetch error' };
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Check if an update is available.
231
+ * Returns { available: boolean, current: string, latest: string, error?: string }
232
+ */
233
+ async function checkForUpdate(currentVersion) {
234
+ const result = await fetchLatestVersion();
235
+
236
+ if (result.error) {
237
+ return { available: false, current: currentVersion, latest: null, error: result.error };
238
+ }
239
+
240
+ const cmp = compareVersions(currentVersion, result.version);
241
+ return {
242
+ available: cmp < 0,
243
+ current: currentVersion,
244
+ latest: result.version,
245
+ sameMajor: isSameMajor(currentVersion, result.version)
246
+ };
247
+ }
248
+
249
+ module.exports = {
250
+ parseVersion,
251
+ compareVersions,
252
+ isSameMajor,
253
+ fetchLatestVersion,
254
+ checkForUpdate,
255
+ REGISTRY_URL
256
+ };
257
+ ```
258
+
259
+ **Step 3: Verify**
260
+
261
+ ```bash
262
+ node test/run.js --filter update-checker
263
+ ```
264
+
265
+ **Step 4: Commit**
266
+
267
+ ```bash
268
+ git add src/lib/update-checker.js test/unit/update-checker.test.js
269
+ git commit -m "feat(updater): add version checker with npm registry lookup"
270
+ ```
271
+
272
+ ---
273
+
274
+ ## Phase 2: Call-Safety Gate
275
+
276
+ ### Task 2: Add active-call detection to the server's status endpoint
277
+
278
+ The `CallMonitor` already tracks active conversations via `getActiveCount()`. We need to expose this count through the existing `/api/a2a/status` endpoint so the update manager (running in the same process) can check it, and so external tools can query it too.
279
+
280
+ **Files:**
281
+ - Modify: `src/routes/a2a.js` (add `active_calls` to `/status` response)
282
+ - Create: `test/unit/update-safety.test.js`
283
+
284
+ **Step 1: Write failing test**
285
+
286
+ Create `test/unit/update-safety.test.js`:
287
+
288
+ ```js
289
+ /**
290
+ * Update Safety Tests
291
+ *
292
+ * Covers: isUpdateSafe — checks whether it's safe to perform an auto-update
293
+ */
294
+
295
+ module.exports = function (test, assert, helpers) {
296
+ function requireFresh() {
297
+ const modPath = require.resolve('../../src/lib/update-manager');
298
+ delete require.cache[modPath];
299
+ return require('../../src/lib/update-manager');
300
+ }
301
+
302
+ test('isUpdateSafe returns true when no calls are active', () => {
303
+ const mockCallMonitor = { getActiveCount: () => 0 };
304
+ const { isUpdateSafe } = requireFresh();
305
+ assert.ok(isUpdateSafe(mockCallMonitor), 'Should be safe with 0 active calls');
306
+ });
307
+
308
+ test('isUpdateSafe returns false when calls are active', () => {
309
+ const mockCallMonitor = { getActiveCount: () => 2 };
310
+ const { isUpdateSafe } = requireFresh();
311
+ assert.ok(!isUpdateSafe(mockCallMonitor), 'Should not be safe with active calls');
312
+ });
313
+
314
+ test('isUpdateSafe returns true when callMonitor is null', () => {
315
+ const { isUpdateSafe } = requireFresh();
316
+ assert.ok(isUpdateSafe(null), 'Should be safe when no monitor exists (no calls possible)');
317
+ });
318
+ };
319
+ ```
320
+
321
+ **Step 2: Implement safety check in `src/lib/update-manager.js` (partial -- full module in Task 3)**
322
+
323
+ Add the `isUpdateSafe` function to the update-manager module (created fully in Task 3):
324
+
325
+ ```js
326
+ /**
327
+ * Check whether it's safe to perform an auto-update.
328
+ * Safe = no active A2A calls in progress.
329
+ */
330
+ function isUpdateSafe(callMonitor) {
331
+ if (!callMonitor) return true; // No monitor = no calls possible
332
+ return callMonitor.getActiveCount() === 0;
333
+ }
334
+ ```
335
+
336
+ **Step 3: Update `/status` endpoint in `src/routes/a2a.js`**
337
+
338
+ In the `router.get('/status', ...)` handler, add active call count:
339
+
340
+ ```js
341
+ router.get('/status', (req, res) => {
342
+ const monitor = getCallMonitor();
343
+ res.json({
344
+ a2a: true,
345
+ version: require('../../package.json').version,
346
+ capabilities: ['invoke', 'multi-turn'],
347
+ rate_limits: limits,
348
+ active_calls: monitor ? monitor.getActiveCount() : 0
349
+ });
350
+ });
351
+ ```
352
+
353
+ **Step 4: Verify**
354
+
355
+ ```bash
356
+ node test/run.js --filter update-safety
357
+ ```
358
+
359
+ **Step 5: Commit**
360
+
361
+ ```bash
362
+ git add src/routes/a2a.js src/lib/update-manager.js test/unit/update-safety.test.js
363
+ git commit -m "feat(updater): add call-safety gate for auto-update"
364
+ ```
365
+
366
+ ---
367
+
368
+ ## Phase 3: Update Manager Core
369
+
370
+ ### Task 3: Create `src/lib/update-manager.js` -- orchestrate check + gate + install + restart
371
+
372
+ This is the main module. It ties together the version checker, call-safety gate, npm install shell-out, and graceful restart.
373
+
374
+ **Files:**
375
+ - Create (or extend from Task 2): `src/lib/update-manager.js`
376
+ - Create: `test/unit/update-manager.test.js`
377
+
378
+ **Step 1: Write failing test**
379
+
380
+ Create `test/unit/update-manager.test.js`:
381
+
382
+ ```js
383
+ /**
384
+ * Update Manager Tests
385
+ *
386
+ * Covers: UpdateManager lifecycle, config reading, interval scheduling,
387
+ * dry-run mode, and the full check->gate->install->restart pipeline.
388
+ */
389
+
390
+ module.exports = function (test, assert, helpers) {
391
+ const path = require('path');
392
+ const fs = require('fs');
393
+
394
+ function requireFresh(configDir) {
395
+ // Clear all related module caches
396
+ for (const key of Object.keys(require.cache)) {
397
+ if (key.includes('update-manager') || key.includes('update-checker')) {
398
+ delete require.cache[key];
399
+ }
400
+ }
401
+ if (configDir) process.env.A2A_CONFIG_DIR = configDir;
402
+ return require('../../src/lib/update-manager');
403
+ }
404
+
405
+ test('UpdateManager constructor sets default options', () => {
406
+ const { UpdateManager } = requireFresh();
407
+ const mgr = new UpdateManager({ currentVersion: '0.6.48' });
408
+ assert.equal(mgr.enabled, true, 'enabled by default');
409
+ assert.equal(mgr.intervalMs, 3600000, 'default interval is 1 hour');
410
+ assert.equal(mgr.currentVersion, '0.6.48');
411
+ });
412
+
413
+ test('UpdateManager respects A2A_AUTO_UPDATE=0 env var', () => {
414
+ process.env.A2A_AUTO_UPDATE = '0';
415
+ const { UpdateManager } = requireFresh();
416
+ const mgr = new UpdateManager({ currentVersion: '0.6.48', enabled: true });
417
+ assert.equal(mgr.enabled, false, 'env var overrides enabled flag');
418
+ delete process.env.A2A_AUTO_UPDATE;
419
+ });
420
+
421
+ test('UpdateManager respects NO_AUTO_UPDATE env var', () => {
422
+ process.env.NO_AUTO_UPDATE = '1';
423
+ const { UpdateManager } = requireFresh();
424
+ const mgr = new UpdateManager({ currentVersion: '0.6.48', enabled: true });
425
+ assert.equal(mgr.enabled, false, 'NO_AUTO_UPDATE disables');
426
+ delete process.env.NO_AUTO_UPDATE;
427
+ });
428
+
429
+ test('isUpdateSafe returns true with no active calls', () => {
430
+ const { isUpdateSafe } = requireFresh();
431
+ const mock = { getActiveCount: () => 0 };
432
+ assert.ok(isUpdateSafe(mock));
433
+ });
434
+
435
+ test('isUpdateSafe returns false with active calls', () => {
436
+ const { isUpdateSafe } = requireFresh();
437
+ const mock = { getActiveCount: () => 1 };
438
+ assert.ok(!isUpdateSafe(mock));
439
+ });
440
+
441
+ test('shouldApplyUpdate skips cross-major updates by default', () => {
442
+ const { shouldApplyUpdate } = requireFresh();
443
+ assert.ok(shouldApplyUpdate('0.6.48', '0.6.50', {}), 'same major ok');
444
+ assert.ok(shouldApplyUpdate('0.6.48', '0.7.0', {}), 'same major ok');
445
+ assert.ok(!shouldApplyUpdate('0.6.48', '1.0.0', {}), 'cross-major blocked');
446
+ });
447
+
448
+ test('shouldApplyUpdate allows cross-major when config says so', () => {
449
+ const { shouldApplyUpdate } = requireFresh();
450
+ assert.ok(shouldApplyUpdate('0.6.48', '1.0.0', { allowMajor: true }));
451
+ });
452
+
453
+ test('start() does nothing when disabled', () => {
454
+ const { UpdateManager } = requireFresh();
455
+ const mgr = new UpdateManager({ currentVersion: '0.6.48', enabled: false });
456
+ mgr.start();
457
+ assert.equal(mgr._intervalId, null, 'no interval set when disabled');
458
+ });
459
+
460
+ test('stop() clears interval', () => {
461
+ const { UpdateManager } = requireFresh();
462
+ const mgr = new UpdateManager({ currentVersion: '0.6.48', enabled: true });
463
+ // Manually set a fake interval
464
+ mgr._intervalId = setInterval(() => {}, 999999);
465
+ mgr.stop();
466
+ assert.equal(mgr._intervalId, null, 'interval cleared');
467
+ });
468
+ };
469
+ ```
470
+
471
+ **Step 2: Implement `src/lib/update-manager.js`**
472
+
473
+ ```js
474
+ /**
475
+ * Update Manager
476
+ *
477
+ * Orchestrates automatic self-updates for a2acalling:
478
+ * 1. Periodically checks npm registry for newer version
479
+ * 2. Verifies no A2A calls are active (call-safety gate)
480
+ * 3. Runs `npm install -g a2acalling@<version>`
481
+ * 4. Gracefully restarts the server process
482
+ * 5. Rolls back on failure
483
+ *
484
+ * Zero new dependencies. Uses Node 18+ built-in fetch() and child_process.
485
+ */
486
+
487
+ const { execFile } = require('child_process');
488
+ const { createLogger } = require('./logger');
489
+ const { checkForUpdate, isSameMajor } = require('./update-checker');
490
+
491
+ const DEFAULT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
492
+ const INSTALL_TIMEOUT_MS = 120000; // 2 minutes
493
+ const RESTART_DELAY_MS = 2000; // 2s grace before restart
494
+
495
+ /**
496
+ * Check whether it's safe to perform an auto-update.
497
+ */
498
+ function isUpdateSafe(callMonitor) {
499
+ if (!callMonitor) return true;
500
+ return callMonitor.getActiveCount() === 0;
501
+ }
502
+
503
+ /**
504
+ * Decide whether a discovered update should be applied.
505
+ */
506
+ function shouldApplyUpdate(currentVersion, latestVersion, options = {}) {
507
+ if (!currentVersion || !latestVersion) return false;
508
+ if (!options.allowMajor && !isSameMajor(currentVersion, latestVersion)) {
509
+ return false;
510
+ }
511
+ return true;
512
+ }
513
+
514
+ class UpdateManager {
515
+ constructor(options = {}) {
516
+ this.currentVersion = options.currentVersion || require('../../package.json').version;
517
+ this.callMonitor = options.callMonitor || null;
518
+ this.logger = options.logger || createLogger({ component: 'a2a.updater' });
519
+ this.intervalMs = options.intervalMs || DEFAULT_INTERVAL_MS;
520
+ this.allowMajor = options.allowMajor || false;
521
+ this.onRestart = options.onRestart || null; // callback before restart
522
+
523
+ // Respect env var overrides
524
+ const envDisabled =
525
+ process.env.A2A_AUTO_UPDATE === '0' ||
526
+ process.env.A2A_AUTO_UPDATE === 'false' ||
527
+ process.env.NO_AUTO_UPDATE === '1' ||
528
+ process.env.NO_AUTO_UPDATE === 'true' ||
529
+ process.env.CI === 'true' ||
530
+ process.env.CONTINUOUS_INTEGRATION === 'true';
531
+
532
+ this.enabled = envDisabled ? false : (options.enabled !== false);
533
+ this._intervalId = null;
534
+ this._updating = false;
535
+ }
536
+
537
+ /**
538
+ * Start the periodic update check loop.
539
+ */
540
+ start() {
541
+ if (!this.enabled) {
542
+ this.logger.info('Auto-updater disabled', { event: 'updater_disabled' });
543
+ return;
544
+ }
545
+ if (this._intervalId) return; // already running
546
+
547
+ this.logger.info('Auto-updater started', {
548
+ event: 'updater_started',
549
+ data: {
550
+ interval_ms: this.intervalMs,
551
+ current_version: this.currentVersion,
552
+ allow_major: this.allowMajor
553
+ }
554
+ });
555
+
556
+ // Run first check after a short delay (don't block startup)
557
+ setTimeout(() => this._tick(), 30000);
558
+
559
+ this._intervalId = setInterval(() => this._tick(), this.intervalMs);
560
+ this._intervalId.unref(); // Don't keep process alive just for updates
561
+ }
562
+
563
+ /**
564
+ * Stop the update check loop.
565
+ */
566
+ stop() {
567
+ if (this._intervalId) {
568
+ clearInterval(this._intervalId);
569
+ this._intervalId = null;
570
+ this.logger.info('Auto-updater stopped', { event: 'updater_stopped' });
571
+ }
572
+ }
573
+
574
+ /**
575
+ * Single update check cycle.
576
+ */
577
+ async _tick() {
578
+ if (this._updating) return; // Already running an update
579
+
580
+ try {
581
+ const result = await checkForUpdate(this.currentVersion);
582
+
583
+ if (result.error) {
584
+ this.logger.warn('Update check failed', {
585
+ event: 'update_check_failed',
586
+ data: { error: result.error }
587
+ });
588
+ return;
589
+ }
590
+
591
+ if (!result.available) {
592
+ this.logger.debug('No update available', {
593
+ event: 'update_check_current',
594
+ data: { version: this.currentVersion }
595
+ });
596
+ return;
597
+ }
598
+
599
+ // Update available -- check constraints
600
+ if (!shouldApplyUpdate(this.currentVersion, result.latest, {
601
+ allowMajor: this.allowMajor
602
+ })) {
603
+ this.logger.info('Update available but cross-major; skipping auto-update', {
604
+ event: 'update_skipped_major',
605
+ data: { current: this.currentVersion, latest: result.latest }
606
+ });
607
+ return;
608
+ }
609
+
610
+ // Check call safety
611
+ if (!isUpdateSafe(this.callMonitor)) {
612
+ this.logger.info('Update available but calls are active; deferring', {
613
+ event: 'update_deferred_active_calls',
614
+ data: {
615
+ current: this.currentVersion,
616
+ latest: result.latest,
617
+ active_calls: this.callMonitor ? this.callMonitor.getActiveCount() : 0
618
+ }
619
+ });
620
+ return;
621
+ }
622
+
623
+ // All clear -- perform update
624
+ await this._performUpdate(result.latest);
625
+
626
+ } catch (err) {
627
+ this.logger.error('Update tick failed unexpectedly', {
628
+ event: 'update_tick_error',
629
+ error: err
630
+ });
631
+ }
632
+ }
633
+
634
+ /**
635
+ * Execute the npm install and restart.
636
+ */
637
+ async _performUpdate(targetVersion) {
638
+ this._updating = true;
639
+ const previousVersion = this.currentVersion;
640
+
641
+ this.logger.info('Starting auto-update', {
642
+ event: 'update_started',
643
+ data: { from: previousVersion, to: targetVersion }
644
+ });
645
+
646
+ try {
647
+ // Step 1: npm install -g a2acalling@<exact version>
648
+ await this._npmInstall(`a2acalling@${targetVersion}`);
649
+
650
+ this.logger.info('npm install completed successfully', {
651
+ event: 'update_install_complete',
652
+ data: { version: targetVersion }
653
+ });
654
+
655
+ // Step 2: Graceful restart
656
+ await this._gracefulRestart(targetVersion);
657
+
658
+ } catch (err) {
659
+ this.logger.error('Auto-update failed; attempting rollback', {
660
+ event: 'update_failed',
661
+ error: err,
662
+ data: { target: targetVersion, previous: previousVersion }
663
+ });
664
+
665
+ // Rollback: re-install previous version
666
+ try {
667
+ await this._npmInstall(`a2acalling@${previousVersion}`);
668
+ this.logger.info('Rollback successful', {
669
+ event: 'update_rollback_complete',
670
+ data: { restored: previousVersion }
671
+ });
672
+ } catch (rollbackErr) {
673
+ this.logger.error('Rollback also failed -- manual intervention needed', {
674
+ event: 'update_rollback_failed',
675
+ error: rollbackErr,
676
+ data: { target: targetVersion, previous: previousVersion }
677
+ });
678
+ }
679
+ } finally {
680
+ this._updating = false;
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Shell out to npm install -g.
686
+ */
687
+ _npmInstall(packageSpec) {
688
+ return new Promise((resolve, reject) => {
689
+ const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
690
+ execFile(npmCmd, ['install', '-g', packageSpec], {
691
+ timeout: INSTALL_TIMEOUT_MS,
692
+ env: { ...process.env, npm_config_cache: '/tmp/npm-cache-a2a' }
693
+ }, (err, stdout, stderr) => {
694
+ if (err) {
695
+ reject(new Error(`npm install failed: ${stderr || err.message}`));
696
+ } else {
697
+ resolve(stdout);
698
+ }
699
+ });
700
+ });
701
+ }
702
+
703
+ /**
704
+ * Gracefully restart the server process.
705
+ *
706
+ * Strategy: Send SIGTERM to self. The existing shutdown handler in
707
+ * server.js cleans up the PID file and closes connections. The
708
+ * postinstall script or a systemd/pm2 process manager restarts us.
709
+ *
710
+ * For standalone (no process manager): we spawn the new server
711
+ * before exiting.
712
+ */
713
+ async _gracefulRestart(newVersion) {
714
+ this.logger.info('Initiating graceful restart', {
715
+ event: 'update_restart',
716
+ data: { new_version: newVersion }
717
+ });
718
+
719
+ if (this.onRestart) {
720
+ await this.onRestart(newVersion);
721
+ }
722
+
723
+ // Give in-flight responses a moment to complete
724
+ await new Promise(r => setTimeout(r, RESTART_DELAY_MS));
725
+
726
+ // Spawn the new server before we exit (detached, unref'd)
727
+ const { spawn } = require('child_process');
728
+ const serverScript = require('path').resolve(__dirname, '..', 'server.js');
729
+ const child = spawn(process.execPath, [serverScript], {
730
+ env: { ...process.env },
731
+ detached: true,
732
+ stdio: 'ignore'
733
+ });
734
+ child.unref();
735
+
736
+ // Now exit the current (old) process
737
+ process.kill(process.pid, 'SIGTERM');
738
+ }
739
+ }
740
+
741
+ module.exports = {
742
+ UpdateManager,
743
+ isUpdateSafe,
744
+ shouldApplyUpdate
745
+ };
746
+ ```
747
+
748
+ **Step 3: Verify**
749
+
750
+ ```bash
751
+ node test/run.js --filter update-manager
752
+ ```
753
+
754
+ **Step 4: Commit**
755
+
756
+ ```bash
757
+ git add src/lib/update-manager.js test/unit/update-manager.test.js
758
+ git commit -m "feat(updater): add UpdateManager with check/gate/install/restart pipeline"
759
+ ```
760
+
761
+ ---
762
+
763
+ ## Phase 4: Config Integration
764
+
765
+ ### Task 4: Add `auto_update` config section and CLI toggle
766
+
767
+ **Files:**
768
+ - Modify: `src/lib/config.js` (add `auto_update` config section)
769
+ - Modify: `bin/cli.js` (add `--auto` flag to `update` command)
770
+ - Create: `test/unit/update-config.test.js`
771
+
772
+ **Step 1: Write failing test**
773
+
774
+ Create `test/unit/update-config.test.js`:
775
+
776
+ ```js
777
+ /**
778
+ * Update Config Tests
779
+ *
780
+ * Covers: reading/writing auto_update config section
781
+ */
782
+
783
+ module.exports = function (test, assert, helpers) {
784
+ const fs = require('fs');
785
+ const path = require('path');
786
+
787
+ function requireFreshConfig(configDir) {
788
+ const modPath = require.resolve('../../src/lib/config');
789
+ delete require.cache[modPath];
790
+ process.env.A2A_CONFIG_DIR = configDir;
791
+ return require('../../src/lib/config');
792
+ }
793
+
794
+ test('getAutoUpdateConfig returns defaults when section missing', () => {
795
+ const tmp = helpers.tmpConfigDir('au-defaults');
796
+ fs.writeFileSync(path.join(tmp.dir, 'a2a-config.json'), JSON.stringify({}));
797
+ const { A2AConfig } = requireFreshConfig(tmp.dir);
798
+ const config = new A2AConfig();
799
+ const au = config.getAutoUpdate();
800
+ assert.equal(au.enabled, true);
801
+ assert.equal(au.intervalMs, 3600000);
802
+ assert.equal(au.allowMajor, false);
803
+ tmp.cleanup();
804
+ });
805
+
806
+ test('setAutoUpdate persists enabled flag', () => {
807
+ const tmp = helpers.tmpConfigDir('au-enable');
808
+ fs.writeFileSync(path.join(tmp.dir, 'a2a-config.json'), JSON.stringify({}));
809
+ const { A2AConfig } = requireFreshConfig(tmp.dir);
810
+ const config = new A2AConfig();
811
+ config.setAutoUpdate({ enabled: true });
812
+ const au = config.getAutoUpdate();
813
+ assert.equal(au.enabled, true);
814
+ tmp.cleanup();
815
+ });
816
+
817
+ test('setAutoUpdate validates intervalMs', () => {
818
+ const tmp = helpers.tmpConfigDir('au-interval');
819
+ fs.writeFileSync(path.join(tmp.dir, 'a2a-config.json'), JSON.stringify({}));
820
+ const { A2AConfig } = requireFreshConfig(tmp.dir);
821
+ const config = new A2AConfig();
822
+ config.setAutoUpdate({ intervalMs: 300000 }); // 5 min
823
+ const au = config.getAutoUpdate();
824
+ assert.equal(au.intervalMs, 300000);
825
+ tmp.cleanup();
826
+ });
827
+ };
828
+ ```
829
+
830
+ **Step 2: Add config methods to `src/lib/config.js`**
831
+
832
+ Add to the `A2AConfig` class:
833
+
834
+ ```js
835
+ getAutoUpdate() {
836
+ const raw = this._read();
837
+ const au = raw.auto_update || {};
838
+ return {
839
+ enabled: au.enabled !== false,
840
+ intervalMs: Number.isFinite(au.intervalMs) && au.intervalMs >= 60000
841
+ ? au.intervalMs
842
+ : 3600000,
843
+ allowMajor: Boolean(au.allowMajor)
844
+ };
845
+ }
846
+
847
+ setAutoUpdate(patch) {
848
+ const raw = this._read();
849
+ const current = raw.auto_update || {};
850
+
851
+ if (patch.enabled !== undefined) current.enabled = Boolean(patch.enabled);
852
+ if (Number.isFinite(patch.intervalMs) && patch.intervalMs >= 60000) {
853
+ current.intervalMs = patch.intervalMs;
854
+ }
855
+ if (patch.allowMajor !== undefined) current.allowMajor = Boolean(patch.allowMajor);
856
+
857
+ raw.auto_update = current;
858
+ this._write(raw);
859
+ return this.getAutoUpdate();
860
+ }
861
+ ```
862
+
863
+ **Step 3: Add `--auto` flag to `a2a update` in `bin/cli.js`**
864
+
865
+ Extend the existing `update` command to support:
866
+
867
+ ```
868
+ a2a update --auto on # Enable auto-updates
869
+ a2a update --auto off # Disable auto-updates
870
+ a2a update --auto status # Show current auto-update config
871
+ ```
872
+
873
+ Add at the top of the `update` command handler:
874
+
875
+ ```js
876
+ const autoFlag = args.flags.auto;
877
+ if (autoFlag !== undefined) {
878
+ const { A2AConfig } = require('../src/lib/config');
879
+ const config = new A2AConfig();
880
+
881
+ if (autoFlag === 'status') {
882
+ const au = config.getAutoUpdate();
883
+ console.log(`\nAuto-update: ${au.enabled ? 'enabled' : 'disabled'}`);
884
+ console.log(` Check interval: ${Math.round(au.intervalMs / 60000)} minutes`);
885
+ console.log(` Allow major: ${au.allowMajor}\n`);
886
+ return;
887
+ }
888
+
889
+ const enable = autoFlag === 'on' || autoFlag === 'true' || autoFlag === true;
890
+ config.setAutoUpdate({ enabled: enable });
891
+ console.log(`\nAuto-update ${enable ? 'enabled' : 'disabled'}.\n`);
892
+ if (enable) {
893
+ console.log('The server will check for updates hourly and install them');
894
+ console.log('automatically when no calls are active.\n');
895
+ }
896
+ return;
897
+ }
898
+ ```
899
+
900
+ **Step 4: Verify**
901
+
902
+ ```bash
903
+ node test/run.js --filter update-config
904
+ ```
905
+
906
+ **Step 5: Commit**
907
+
908
+ ```bash
909
+ git add src/lib/config.js bin/cli.js test/unit/update-config.test.js
910
+ git commit -m "feat(updater): add auto-update config section and CLI toggle"
911
+ ```
912
+
913
+ ---
914
+
915
+ ## Phase 5: Server Integration
916
+
917
+ ### Task 5: Wire UpdateManager into `src/server.js`
918
+
919
+ **Files:**
920
+ - Modify: `src/server.js` (instantiate and start UpdateManager)
921
+
922
+ **Step 1: Write failing integration test**
923
+
924
+ Add to `test/integration/` a test that verifies the server starts with auto-updater when config says so. This is a lightweight smoke test.
925
+
926
+ Create `test/integration/auto-updater.test.js`:
927
+
928
+ ```js
929
+ /**
930
+ * Auto-Updater Integration Test
931
+ *
932
+ * Verifies the update manager initializes correctly inside the server context.
933
+ */
934
+
935
+ module.exports = function (test, assert, helpers) {
936
+ test('UpdateManager can be instantiated with server-like options', () => {
937
+ // Clear caches
938
+ for (const key of Object.keys(require.cache)) {
939
+ if (key.includes('update-manager') || key.includes('update-checker')) {
940
+ delete require.cache[key];
941
+ }
942
+ }
943
+
944
+ const { UpdateManager } = require('../../src/lib/update-manager');
945
+ const { CallMonitor } = require('../../src/lib/call-monitor');
946
+
947
+ const monitor = new CallMonitor();
948
+ const mgr = new UpdateManager({
949
+ currentVersion: '0.6.48',
950
+ callMonitor: monitor,
951
+ enabled: false // don't actually start checking
952
+ });
953
+
954
+ assert.equal(mgr.currentVersion, '0.6.48');
955
+ assert.equal(mgr.enabled, false);
956
+ assert.ok(mgr.callMonitor === monitor);
957
+ });
958
+
959
+ test('UpdateManager does not start interval when disabled', () => {
960
+ for (const key of Object.keys(require.cache)) {
961
+ if (key.includes('update-manager') || key.includes('update-checker')) {
962
+ delete require.cache[key];
963
+ }
964
+ }
965
+
966
+ const { UpdateManager } = require('../../src/lib/update-manager');
967
+ const mgr = new UpdateManager({
968
+ currentVersion: '0.6.48',
969
+ enabled: false
970
+ });
971
+ mgr.start();
972
+ assert.equal(mgr._intervalId, null);
973
+ });
974
+ };
975
+ ```
976
+
977
+ **Step 2: Add UpdateManager to `src/server.js`**
978
+
979
+ After the server starts listening (inside the `app.listen` callback), add:
980
+
981
+ ```js
982
+ // Auto-updater (opt-in via config or CLI)
983
+ try {
984
+ const { A2AConfig } = require('./lib/config');
985
+ const { UpdateManager } = require('./lib/update-manager');
986
+ const appConfig = new A2AConfig();
987
+ const auConfig = appConfig.getAutoUpdate();
988
+
989
+ const updateManager = new UpdateManager({
990
+ currentVersion: require('../package.json').version,
991
+ callMonitor: null, // Will be set when call monitor initializes
992
+ enabled: auConfig.enabled,
993
+ intervalMs: auConfig.intervalMs,
994
+ allowMajor: auConfig.allowMajor,
995
+ logger: logger.child({ component: 'a2a.updater' }),
996
+ onRestart: async (newVersion) => {
997
+ logger.info('Auto-update restarting server', {
998
+ event: 'update_restart_initiated',
999
+ data: { new_version: newVersion }
1000
+ });
1001
+ }
1002
+ });
1003
+
1004
+ updateManager.start();
1005
+
1006
+ // Clean up on shutdown
1007
+ const origShutdown = shutdown;
1008
+ shutdown = function() {
1009
+ updateManager.stop();
1010
+ origShutdown();
1011
+ };
1012
+ } catch (err) {
1013
+ logger.warn('Auto-updater failed to initialize (non-fatal)', {
1014
+ event: 'updater_init_failed',
1015
+ error: err
1016
+ });
1017
+ }
1018
+ ```
1019
+
1020
+ **Step 3: Verify**
1021
+
1022
+ ```bash
1023
+ node test/run.js --filter auto-updater
1024
+ npm test # full suite
1025
+ ```
1026
+
1027
+ **Step 4: Commit**
1028
+
1029
+ ```bash
1030
+ git add src/server.js test/integration/auto-updater.test.js
1031
+ git commit -m "feat(updater): wire UpdateManager into server lifecycle"
1032
+ ```
1033
+
1034
+ ---
1035
+
1036
+ ## Phase 6: Rollback Safety
1037
+
1038
+ ### Task 6: Add rollback verification and last-known-good tracking
1039
+
1040
+ **Files:**
1041
+ - Modify: `src/lib/update-manager.js` (add rollback tracking)
1042
+ - Modify: `src/lib/config.js` (add `auto_update.last_good_version` field)
1043
+ - Add to: `test/unit/update-manager.test.js`
1044
+
1045
+ **Step 1: Write failing test**
1046
+
1047
+ Add to `test/unit/update-manager.test.js`:
1048
+
1049
+ ```js
1050
+ test('_performUpdate rolls back on install failure (dry run)', async () => {
1051
+ const { UpdateManager } = requireFresh();
1052
+ const installCalls = [];
1053
+
1054
+ const mgr = new UpdateManager({
1055
+ currentVersion: '0.6.48',
1056
+ enabled: true
1057
+ });
1058
+
1059
+ // Mock _npmInstall to fail on first call, succeed on rollback
1060
+ let callCount = 0;
1061
+ mgr._npmInstall = async (spec) => {
1062
+ installCalls.push(spec);
1063
+ callCount++;
1064
+ if (callCount === 1) throw new Error('simulated install failure');
1065
+ return 'ok';
1066
+ };
1067
+
1068
+ // Mock _gracefulRestart to do nothing
1069
+ mgr._gracefulRestart = async () => {};
1070
+
1071
+ await mgr._performUpdate('0.6.99');
1072
+
1073
+ assert.equal(installCalls.length, 2, 'Should have called install twice (attempt + rollback)');
1074
+ assert.equal(installCalls[0], 'a2acalling@0.6.99', 'First call is target version');
1075
+ assert.equal(installCalls[1], 'a2acalling@0.6.48', 'Second call is rollback');
1076
+ });
1077
+ ```
1078
+
1079
+ **Step 2: Add last-good tracking to config**
1080
+
1081
+ In the `_performUpdate` method, after successful install and before restart:
1082
+
1083
+ ```js
1084
+ // Record last known good version
1085
+ try {
1086
+ const { A2AConfig } = require('./config');
1087
+ const appConfig = new A2AConfig();
1088
+ appConfig.setAutoUpdate({ lastGoodVersion: this.currentVersion });
1089
+ } catch (e) {
1090
+ // Non-fatal
1091
+ }
1092
+ ```
1093
+
1094
+ In the config module, update `getAutoUpdate` and `setAutoUpdate` to handle `lastGoodVersion`.
1095
+
1096
+ **Step 3: Verify**
1097
+
1098
+ ```bash
1099
+ node test/run.js --filter update-manager
1100
+ ```
1101
+
1102
+ **Step 4: Commit**
1103
+
1104
+ ```bash
1105
+ git add src/lib/update-manager.js src/lib/config.js test/unit/update-manager.test.js
1106
+ git commit -m "feat(updater): add rollback tracking and last-known-good version"
1107
+ ```
1108
+
1109
+ ---
1110
+
1111
+ ## Phase 7: Logging and Observability
1112
+
1113
+ ### Task 7: Add structured log events for update lifecycle
1114
+
1115
+ This is mostly done inline in the UpdateManager (see Task 3 code), but we need to verify the log output is useful and add a test for it.
1116
+
1117
+ **Files:**
1118
+ - Add to: `test/unit/update-manager.test.js`
1119
+
1120
+ **Step 1: Write test for log events**
1121
+
1122
+ ```js
1123
+ test('_tick logs when no update is available', async () => {
1124
+ const { UpdateManager } = requireFresh();
1125
+ const logEvents = [];
1126
+
1127
+ const mgr = new UpdateManager({
1128
+ currentVersion: '99.99.99', // Artificially high to guarantee "no update"
1129
+ enabled: true,
1130
+ logger: {
1131
+ info: (msg, meta) => logEvents.push({ level: 'info', msg, ...meta }),
1132
+ warn: (msg, meta) => logEvents.push({ level: 'warn', msg, ...meta }),
1133
+ error: (msg, meta) => logEvents.push({ level: 'error', msg, ...meta }),
1134
+ debug: (msg, meta) => logEvents.push({ level: 'debug', msg, ...meta }),
1135
+ child: () => mgr.logger // return self for simplicity
1136
+ }
1137
+ });
1138
+
1139
+ // This will make a real HTTP call to the registry (acceptable for integration)
1140
+ // Or we can mock checkForUpdate
1141
+ await mgr._tick();
1142
+
1143
+ // Should have logged something (check_current or check_failed depending on network)
1144
+ assert.ok(logEvents.length > 0, 'Should have produced at least one log event');
1145
+ });
1146
+ ```
1147
+
1148
+ **Step 2: Verify**
1149
+
1150
+ ```bash
1151
+ node test/run.js --filter update-manager
1152
+ ```
1153
+
1154
+ **Step 3: Commit**
1155
+
1156
+ ```bash
1157
+ git add test/unit/update-manager.test.js
1158
+ git commit -m "test(updater): add log event verification tests"
1159
+ ```
1160
+
1161
+ ---
1162
+
1163
+ ## Phase 8: Documentation and Help Text
1164
+
1165
+ ### Task 8: Update CLI help text and add user-facing docs
1166
+
1167
+ **Files:**
1168
+ - Modify: `bin/cli.js` (update help text for `update` command)
1169
+
1170
+ **Step 1: Update help text**
1171
+
1172
+ In the `help` command output, update the `update` section:
1173
+
1174
+ ```
1175
+ update Update A2A to latest version (npm or git pull)
1176
+ --check, -c Check for updates without installing
1177
+ --auto on Enable automatic background updates
1178
+ --auto off Disable automatic background updates
1179
+ --auto status Show auto-update configuration
1180
+ ```
1181
+
1182
+ **Step 2: Verify help output**
1183
+
1184
+ ```bash
1185
+ node bin/cli.js help | grep -A 4 "update"
1186
+ ```
1187
+
1188
+ **Step 3: Commit**
1189
+
1190
+ ```bash
1191
+ git add bin/cli.js
1192
+ git commit -m "docs(updater): update CLI help text with auto-update flags"
1193
+ ```
1194
+
1195
+ ---
1196
+
1197
+ ## Summary of New Files
1198
+
1199
+ | File | Purpose |
1200
+ |------|---------|
1201
+ | `src/lib/update-checker.js` | Zero-dep npm registry version check |
1202
+ | `src/lib/update-manager.js` | Orchestrator: check + gate + install + restart + rollback |
1203
+ | `test/unit/update-checker.test.js` | Unit tests for version comparison and registry fetch |
1204
+ | `test/unit/update-safety.test.js` | Unit tests for call-safety gate |
1205
+ | `test/unit/update-manager.test.js` | Unit tests for UpdateManager lifecycle |
1206
+ | `test/unit/update-config.test.js` | Unit tests for config read/write |
1207
+ | `test/integration/auto-updater.test.js` | Integration smoke test |
1208
+
1209
+ ## Modified Files
1210
+
1211
+ | File | Change |
1212
+ |------|--------|
1213
+ | `src/server.js` | Instantiate and start UpdateManager on server boot |
1214
+ | `src/routes/a2a.js` | Add `active_calls` to `/status` endpoint |
1215
+ | `src/lib/config.js` | Add `getAutoUpdate()` / `setAutoUpdate()` methods |
1216
+ | `bin/cli.js` | Add `--auto` flag to `update` command, update help text |
1217
+
1218
+ ## Dependencies Added
1219
+
1220
+ **None.** The entire implementation uses:
1221
+ - `fetch()` -- built into Node 18+ (already required by `engines` field)
1222
+ - `child_process.execFile` -- Node built-in
1223
+ - Existing project modules: `logger`, `config`, `call-monitor`, `pid-file`
1224
+
1225
+ ## Sequence Diagram
1226
+
1227
+ ```
1228
+ Server Process (running v0.6.48)
1229
+
1230
+ ├─ setInterval(1 hour)
1231
+ │ │
1232
+ │ ├─ fetch("https://registry.npmjs.org/a2acalling/latest")
1233
+ │ │ └─ Response: { "version": "0.6.50" }
1234
+ │ │
1235
+ │ ├─ compareVersions("0.6.48", "0.6.50") → update available
1236
+ │ │
1237
+ │ ├─ isSameMajor("0.6.48", "0.6.50") → true (safe)
1238
+ │ │
1239
+ │ ├─ callMonitor.getActiveCount() → 0 (no active calls)
1240
+ │ │
1241
+ │ ├─ execFile("npm", ["install", "-g", "a2acalling@0.6.50"])
1242
+ │ │ └─ Success
1243
+ │ │
1244
+ │ ├─ spawn(node, ["server.js"]) → new server process (detached)
1245
+ │ │
1246
+ │ └─ process.kill(self, SIGTERM)
1247
+ │ └─ Existing shutdown handler cleans up PID file
1248
+
1249
+ └─ New server process starts (v0.6.50), writes new PID file
1250
+ ```
1251
+
1252
+ ## Rollback Sequence
1253
+
1254
+ ```
1255
+ ├─ execFile("npm", ["install", "-g", "a2acalling@0.6.50"])
1256
+ │ └─ FAILURE (network error, permission error, etc.)
1257
+
1258
+ ├─ Log error
1259
+
1260
+ ├─ execFile("npm", ["install", "-g", "a2acalling@0.6.48"]) ← rollback
1261
+ │ └─ Success → server continues running on v0.6.48
1262
+
1263
+ └─ _updating = false → next check cycle will retry
1264
+ ```
1265
+
1266
+ ## Configuration
1267
+
1268
+ Config lives in `~/.config/openclaw/a2a-config.json`:
1269
+
1270
+ ```json
1271
+ {
1272
+ "auto_update": {
1273
+ "enabled": true,
1274
+ "intervalMs": 3600000,
1275
+ "allowMajor": false,
1276
+ "lastGoodVersion": "0.6.48"
1277
+ }
1278
+ }
1279
+ ```
1280
+
1281
+ Environment variable overrides:
1282
+ - `A2A_AUTO_UPDATE=0` -- disable auto-updates
1283
+ - `NO_AUTO_UPDATE=1` -- disable auto-updates
1284
+ - `CI=true` -- auto-detected, disables auto-updates