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.
- package/bin/cli.js +23 -0
- package/docs/plans/2026-02-16-auto-updater.md +1284 -0
- package/docs/plans/2026-02-16-e2e-test-prompt-sequence.md +3085 -0
- package/docs/plans/2026-02-17-claude-code-codex-skills.md +770 -0
- package/docs/prompts/e2e-test-agent.md +368 -0
- package/docs/protocol.md +79 -0
- package/package.json +1 -1
- package/src/dashboard/public/app.js +108 -1
- package/src/dashboard/public/index.html +9 -0
- package/src/dashboard/public/style.css +27 -0
- package/src/lib/config.js +41 -0
- package/src/lib/conversation-driver.js +62 -21
- package/src/lib/openclaw-integration.js +22 -66
- package/src/lib/summary-formatter.js +168 -0
- package/src/lib/summary-prompt.js +203 -0
- package/src/lib/update-checker.js +93 -0
- package/src/lib/update-manager.js +313 -0
- package/src/routes/a2a.js +8 -1
- package/src/routes/dashboard.js +103 -1
- package/src/server.js +115 -26
|
@@ -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
|