a2acalling 0.6.47 → 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,368 @@
1
+ # E2E Test Agent Prompt Sequence
2
+
3
+ Run a full end-to-end validation of `a2acalling` in an isolated environment. Execute each step sequentially. If a step fails, log the failure and continue to the next step unless the failure is blocking (steps 1-3 block all subsequent steps).
4
+
5
+ ## Prerequisites
6
+
7
+ Before starting, verify:
8
+
9
+ - **Node.js** >= 18.0.0 (`node --version`)
10
+ - **npm** available (`npm --version`)
11
+ - **Ports** 3100-3199 range available (used by test servers)
12
+ - **Disk** at least 200MB free in system temp directory
13
+ - **Network** localhost connections allowed (no firewall blocking loopback)
14
+
15
+ If any prerequisite is missing, report it as a step-0 failure and abort.
16
+
17
+ ## Output Format
18
+
19
+ Report each step as a JSON object:
20
+
21
+ ```json
22
+ {
23
+ "step": 1,
24
+ "name": "install",
25
+ "status": "pass",
26
+ "duration_ms": 4230,
27
+ "error": null
28
+ }
29
+ ```
30
+
31
+ On completion, return a JSON array of all step results:
32
+
33
+ ```json
34
+ {
35
+ "run_id": "e2e_<timestamp>",
36
+ "started_at": "<ISO-8601>",
37
+ "finished_at": "<ISO-8601>",
38
+ "total_steps": 9,
39
+ "passed": 8,
40
+ "failed": 1,
41
+ "results": [ ... ]
42
+ }
43
+ ```
44
+
45
+ ---
46
+
47
+ ## Step 1: Install from npm
48
+
49
+ **What to do:**
50
+
51
+ ```bash
52
+ WORKDIR=$(mktemp -d -t a2a-e2e-XXXXXX)
53
+ cd "$WORKDIR"
54
+ npm init -y --silent
55
+ npm install a2acalling
56
+ ```
57
+
58
+ **Expected outcome:**
59
+ - Exit code 0 from `npm install`
60
+ - `node_modules/a2acalling/bin/cli.js` exists
61
+ - `node_modules/a2acalling/src/server.js` exists
62
+
63
+ **Failure:**
64
+ - If npm install fails, record the stderr and abort all remaining steps (blocking).
65
+
66
+ **Variables to carry forward:**
67
+ - `WORKDIR` -- root temp directory
68
+ - `CLI` = `$WORKDIR/node_modules/.bin/a2a`
69
+ - `A2A_CONFIG_DIR` = `$WORKDIR/config` (create this directory)
70
+
71
+ ---
72
+
73
+ ## Step 2: Run Quickstart Onboarding
74
+
75
+ **What to do:**
76
+
77
+ ```bash
78
+ export A2A_CONFIG_DIR="$WORKDIR/config"
79
+ export CI=true
80
+ mkdir -p "$A2A_CONFIG_DIR"
81
+ ```
82
+
83
+ First, write a minimal config to simulate port detection completing:
84
+
85
+ ```bash
86
+ cat > "$A2A_CONFIG_DIR/a2a-config.json" << 'CONF'
87
+ {
88
+ "onboarding": { "version": 2, "step": "awaiting_disclosure", "server_port": 3100 },
89
+ "agent": { "hostname": "localhost:3100", "name": "e2e-test-agent" },
90
+ "tiers": {}
91
+ }
92
+ CONF
93
+ ```
94
+
95
+ Then submit the disclosure manifest:
96
+
97
+ ```bash
98
+ node "$CLI" onboard --submit '{
99
+ "tiers": {
100
+ "public": {
101
+ "topics": [{"topic": "General", "description": "Open discussion"}],
102
+ "objectives": [],
103
+ "do_not_discuss": []
104
+ },
105
+ "friends": {
106
+ "topics": [{"topic": "Projects", "description": "Current work"}],
107
+ "objectives": [],
108
+ "do_not_discuss": []
109
+ },
110
+ "family": {
111
+ "topics": [{"topic": "Everything", "description": "Full access"}],
112
+ "objectives": [],
113
+ "do_not_discuss": []
114
+ }
115
+ },
116
+ "never_disclose": ["passwords", "api-keys"],
117
+ "personality_notes": "E2E test agent. Direct and minimal responses."
118
+ }'
119
+ ```
120
+
121
+ **Expected outcome:**
122
+ - Exit code 0
123
+ - stdout contains "Onboarding complete"
124
+ - `$A2A_CONFIG_DIR/a2a-config.json` has `onboarding.step` set to `"complete"`
125
+
126
+ **Failure:**
127
+ - If onboarding fails, record stdout/stderr and abort (blocking).
128
+
129
+ ---
130
+
131
+ ## Step 3: Verify Server Health
132
+
133
+ **What to do:**
134
+
135
+ Start the server, then check health endpoints:
136
+
137
+ ```bash
138
+ node "$WORKDIR/node_modules/a2acalling/src/server.js" &
139
+ SERVER_PID=$!
140
+ sleep 2
141
+ ```
142
+
143
+ Check ping:
144
+
145
+ ```bash
146
+ curl -s http://localhost:3100/api/a2a/ping
147
+ ```
148
+
149
+ Check status:
150
+
151
+ ```bash
152
+ curl -s http://localhost:3100/api/a2a/status
153
+ ```
154
+
155
+ **Expected outcome:**
156
+ - Ping returns `{"pong": true, "timestamp": "..."}` with HTTP 200
157
+ - Status returns JSON with `"a2a": true` and a `"version"` field
158
+ - Server process is running (PID exists)
159
+
160
+ **Failure:**
161
+ - If server does not start or endpoints do not respond within 10 seconds, abort (blocking).
162
+
163
+ **Variables to carry forward:**
164
+ - `SERVER_PID`
165
+ - `BASE_URL` = `http://localhost:3100`
166
+
167
+ ---
168
+
169
+ ## Step 4: Create Invite Token
170
+
171
+ **What to do:**
172
+
173
+ ```bash
174
+ node "$CLI" create --name "E2E-Caller" --tier public --expires 1h
175
+ ```
176
+
177
+ Parse the output to extract the token and invite URL.
178
+
179
+ **Expected outcome:**
180
+ - Exit code 0
181
+ - Output contains an invite URL matching `a2a://localhost:3100/fed_...`
182
+ - Output contains a token matching `fed_[A-Za-z0-9_-]+`
183
+
184
+ **Variables to carry forward:**
185
+ - `TOKEN` -- the `fed_...` string
186
+ - `INVITE_URL` -- the full `a2a://...` URL
187
+
188
+ ---
189
+
190
+ ## Step 5: Test Inbound Call
191
+
192
+ **What to do:**
193
+
194
+ ```bash
195
+ curl -s -X POST "$BASE_URL/api/a2a/invoke" \
196
+ -H "Authorization: Bearer $TOKEN" \
197
+ -H "Content-Type: application/json" \
198
+ -d '{
199
+ "message": "Hello from E2E test",
200
+ "caller": {
201
+ "name": "E2E Test Runner",
202
+ "instance": "localhost",
203
+ "context": "Automated E2E validation"
204
+ }
205
+ }'
206
+ ```
207
+
208
+ **Expected outcome:**
209
+ - HTTP 200
210
+ - Response JSON has `"success": true`
211
+ - Response JSON has a `"conversation_id"` starting with `conv_`
212
+ - Response JSON has a non-empty `"response"` field
213
+ - Response JSON has `"can_continue": true`
214
+
215
+ ---
216
+
217
+ ## Step 6: Test Multi-turn Conversation
218
+
219
+ **What to do:**
220
+
221
+ Use the `conversation_id` from step 5. Make two follow-up calls:
222
+
223
+ Call 1:
224
+ ```bash
225
+ curl -s -X POST "$BASE_URL/api/a2a/invoke" \
226
+ -H "Authorization: Bearer $TOKEN" \
227
+ -H "Content-Type: application/json" \
228
+ -d "{
229
+ \"message\": \"Follow-up question 1\",
230
+ \"conversation_id\": \"$CONVERSATION_ID\"
231
+ }"
232
+ ```
233
+
234
+ Call 2:
235
+ ```bash
236
+ curl -s -X POST "$BASE_URL/api/a2a/invoke" \
237
+ -H "Authorization: Bearer $TOKEN" \
238
+ -H "Content-Type: application/json" \
239
+ -d "{
240
+ \"message\": \"Follow-up question 2\",
241
+ \"conversation_id\": \"$CONVERSATION_ID\"
242
+ }"
243
+ ```
244
+
245
+ **Expected outcome:**
246
+ - Both calls return HTTP 200 with `"success": true`
247
+ - Both return the same `conversation_id` as the original
248
+ - Both have non-empty `"response"` fields
249
+
250
+ ---
251
+
252
+ ## Step 7: Test Error Cases
253
+
254
+ Run three negative tests:
255
+
256
+ ### 7a: No auth header
257
+
258
+ ```bash
259
+ curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE_URL/api/a2a/invoke" \
260
+ -H "Content-Type: application/json" \
261
+ -d '{"message": "no auth"}'
262
+ ```
263
+
264
+ **Expected:** HTTP 401, response body has `"error": "missing_token"`
265
+
266
+ ### 7b: Bad token
267
+
268
+ ```bash
269
+ curl -s -X POST "$BASE_URL/api/a2a/invoke" \
270
+ -H "Authorization: Bearer fed_invalid_token_value" \
271
+ -H "Content-Type: application/json" \
272
+ -d '{"message": "bad token"}'
273
+ ```
274
+
275
+ **Expected:** HTTP 401 or 403, response body has `"success": false`
276
+
277
+ ### 7c: Missing message body
278
+
279
+ ```bash
280
+ curl -s -X POST "$BASE_URL/api/a2a/invoke" \
281
+ -H "Authorization: Bearer $TOKEN" \
282
+ -H "Content-Type: application/json" \
283
+ -d '{}'
284
+ ```
285
+
286
+ **Expected:** HTTP 400, response body has `"error": "missing_message"`
287
+
288
+ **Step passes only if all three sub-tests pass.** Report sub-test details in the error field if any fail.
289
+
290
+ ---
291
+
292
+ ## Step 8: Test Token Revocation
293
+
294
+ **What to do:**
295
+
296
+ First, find the token ID:
297
+
298
+ ```bash
299
+ node "$CLI" list
300
+ ```
301
+
302
+ Parse the token ID (`tok_...`) from the output, then revoke it:
303
+
304
+ ```bash
305
+ node "$CLI" revoke "$TOKEN_ID"
306
+ ```
307
+
308
+ Then attempt a call with the revoked token:
309
+
310
+ ```bash
311
+ curl -s -X POST "$BASE_URL/api/a2a/invoke" \
312
+ -H "Authorization: Bearer $TOKEN" \
313
+ -H "Content-Type: application/json" \
314
+ -d '{"message": "should fail"}'
315
+ ```
316
+
317
+ **Expected outcome:**
318
+ - Revoke command exits 0
319
+ - Post-revocation call returns `"success": false`
320
+ - Error field is `"token_revoked"` or similar auth error
321
+
322
+ ---
323
+
324
+ ## Step 9: Cleanup
325
+
326
+ **What to do:**
327
+
328
+ ```bash
329
+ kill $SERVER_PID 2>/dev/null
330
+ wait $SERVER_PID 2>/dev/null
331
+
332
+ rm -rf "$WORKDIR"
333
+ ```
334
+
335
+ Verify:
336
+ ```bash
337
+ ! kill -0 $SERVER_PID 2>/dev/null # process is gone
338
+ [ ! -d "$WORKDIR" ] # directory is gone
339
+ ```
340
+
341
+ **Expected outcome:**
342
+ - Server process is terminated
343
+ - Temp directory is removed
344
+ - No orphaned processes on port 3100
345
+
346
+ **This step always passes unless cleanup throws an unexpected error.**
347
+
348
+ ---
349
+
350
+ ## Failure Reporting
351
+
352
+ When a step fails, generate a Linear bug report payload:
353
+
354
+ ```json
355
+ {
356
+ "title": "E2E: Step <N> (<name>) failed",
357
+ "description": "## Failure\n\n<error message>\n\n## Reproduction\n\nRun `node test/e2e/orchestrate.js --verbose`\n\n## Environment\n\n- Node: <version>\n- npm: <version>\n- OS: <platform>\n- a2acalling: <version>",
358
+ "priority": 2,
359
+ "labels": ["bug", "e2e"],
360
+ "team": "ENG"
361
+ }
362
+ ```
363
+
364
+ Priority mapping:
365
+ - Steps 1-3 (blocking): priority 1 (Urgent)
366
+ - Steps 4-6 (core flow): priority 2 (High)
367
+ - Steps 7-8 (error handling): priority 3 (Normal)
368
+ - Step 9 (cleanup): priority 4 (Low)
package/docs/protocol.md CHANGED
@@ -309,6 +309,85 @@ a2a_call({
309
309
  }
310
310
  ```
311
311
 
312
+ ## E2E Testing
313
+
314
+ ### Architecture
315
+
316
+ E2E tests run in fully isolated environments. Each test gets its own temp directory, config directory (`A2A_CONFIG_DIR`), and ephemeral port. No shared state between tests; no pollution of the host system.
317
+
318
+ Core components:
319
+
320
+ | Component | File | Purpose |
321
+ |-----------|------|---------|
322
+ | Environment | `test/e2e/env.js` | Creates isolated temp dir, config dir, port finder, cleanup |
323
+ | Two-server harness | `test/e2e/two-server.js` | Spins up two independent A2A servers for cross-agent tests |
324
+ | CLI runner | `test/e2e/cli-runner.js` | Wraps `bin/cli.js` as child process with structured output |
325
+ | Agent prompt | `docs/prompts/e2e-test-agent.md` | 9-step prompt sequence for Claude subagent validation |
326
+
327
+ ### Test Categories
328
+
329
+ - **Infrastructure** (`env.test.js`) -- Isolated environments, port allocation, cleanup
330
+ - **CLI integration** (`cli-runner.test.js`) -- Command execution, onboarding, exit codes, timeouts
331
+ - **Cross-agent flow** (`two-server.test.js`) -- Two servers, token isolation, ping/invoke across instances
332
+ - **Error handling** -- Bad tokens, missing auth, revoked tokens, malformed requests
333
+ - **Summary validation** -- Prompt correctness across `server.js`, `openclaw-integration.js`, `claude-subagent.js` paths
334
+
335
+ ### Entry Points
336
+
337
+ ```bash
338
+ # Run all tests (unit + integration + E2E)
339
+ node test/run.js
340
+
341
+ # Run E2E tests only
342
+ node test/run.js --e2e
343
+
344
+ # Standalone orchestrator with verbose or JSON output
345
+ node test/e2e/orchestrate.js [--verbose] [--json]
346
+
347
+ # Default: excludes E2E for faster feedback
348
+ node test/run.js
349
+ ```
350
+
351
+ The `--e2e` flag filters to files under `test/e2e/`. The standalone orchestrator runs the full 9-step sequence from `docs/prompts/e2e-test-agent.md` and outputs structured JSON results.
352
+
353
+ ### File Structure
354
+
355
+ ```
356
+ test/e2e/
357
+ env.js # createE2EEnv() — isolated temp + config + port
358
+ env.test.js # Tests for env isolation and port allocation
359
+ cli-runner.js # CLIRunner class — child-process CLI wrapper
360
+ cli-runner.test.js # Tests for CLI execution, onboarding, timeouts
361
+ two-server.js # TwoServerHarness — two independent A2A instances
362
+ two-server.test.js # Tests for cross-agent token isolation and ping
363
+ orchestrate.js # Standalone E2E orchestrator (planned)
364
+ ```
365
+
366
+ ### Adding New E2E Tests
367
+
368
+ 1. Create `test/e2e/<name>.test.js` exporting `function(test, assert, helpers)`.
369
+ 2. Use `createE2EEnv()` for isolation. Always call `env.cleanup()` in a finally block.
370
+ 3. Use `TwoServerHarness` if you need two agents. Call `harness.teardown()` after.
371
+ 4. Use `CLIRunner` for CLI interactions. It handles `A2A_CONFIG_DIR` automatically.
372
+ 5. The test runner discovers all `*.test.js` files recursively -- no registration needed.
373
+
374
+ Example skeleton:
375
+
376
+ ```js
377
+ module.exports = function (test, assert, helpers) {
378
+ const { createE2EEnv } = require('./env');
379
+
380
+ test('my new E2E scenario', async () => {
381
+ const env = createE2EEnv('my-test');
382
+ try {
383
+ // ... test logic using env.configDir, env.findAvailablePort()
384
+ } finally {
385
+ env.cleanup();
386
+ }
387
+ });
388
+ };
389
+ ```
390
+
312
391
  ## Future Protocol Extensions (v1+)
313
392
 
314
393
  - **Capability advertisement**: Agents declare what they can help with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2acalling",
3
- "version": "0.6.47",
3
+ "version": "0.6.49",
4
4
  "description": "Agent-to-agent calling for OpenClaw - A2A agent communication",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,6 +1,7 @@
1
1
  const state = {
2
2
  settings: null,
3
3
  dashboardStatus: null,
4
+ autoUpdate: null,
4
5
  callbookDevices: [],
5
6
  contacts: [],
6
7
  selectedContactId: null,
@@ -63,6 +64,20 @@ function esc(text) {
63
64
  .replaceAll("'", '&#039;');
64
65
  }
65
66
 
67
+ function formatUpdaterState(stateValue) {
68
+ const state = String(stateValue || '').trim() || 'unknown';
69
+ return state.replaceAll('_', ' ');
70
+ }
71
+
72
+ function updaterPillClass(stateValue) {
73
+ const state = String(stateValue || '').trim();
74
+ if (state === 'failed') return 'err';
75
+ if (state === 'waiting_for_safe_restart' || state === 'checking' || state === 'downloading' || state === 'applying' || state === 'restarting') {
76
+ return 'warn';
77
+ }
78
+ return 'ok';
79
+ }
80
+
66
81
  async function copyText(value) {
67
82
  const text = String(value || '');
68
83
  if (!text) return false;
@@ -284,7 +299,7 @@ function bindContactsActions() {
284
299
  if (!urlEl || !serverNameEl) return;
285
300
  if (mineEl && !mineEl.checked) return;
286
301
  if (serverNameEl.value.trim()) return;
287
- const match = String(urlEl.value || '').trim().match(/^(?:a2a|oclaw):\\/\\/([^/]+)\\//);
302
+ const match = String(urlEl.value || '').trim().match(/^(?:a2a|oclaw):\/\/([^/]+)\//);
288
303
  if (match && match[1]) {
289
304
  serverNameEl.value = match[1];
290
305
  }
@@ -1035,14 +1050,59 @@ function renderCallbookStatus() {
1035
1050
  `;
1036
1051
  }
1037
1052
 
1053
+ function renderAutoUpdateStatus() {
1054
+ const el = document.getElementById('auto-update-status');
1055
+ const toggleBtn = document.getElementById('auto-update-toggle');
1056
+ if (!el) return;
1057
+
1058
+ const au = state.autoUpdate;
1059
+ if (!au) {
1060
+ el.textContent = 'Loading…';
1061
+ if (toggleBtn) toggleBtn.disabled = true;
1062
+ return;
1063
+ }
1064
+
1065
+ const stateText = formatUpdaterState(au.state);
1066
+ const pillClass = updaterPillClass(au.state);
1067
+ const enabled = Boolean(au.enabled);
1068
+ const intervalSec = Number.isFinite(au.interval_ms) ? Math.floor(au.interval_ms / 1000) : null;
1069
+
1070
+ el.innerHTML = `
1071
+ <div><strong>Status:</strong> <span class="status-pill ${pillClass}">${esc(stateText)}</span></div>
1072
+ <div><strong>Enabled:</strong> ${enabled ? 'yes' : 'no'}</div>
1073
+ <div><strong>Current version:</strong> <span class="mono">${esc(au.current_version || '-')}</span></div>
1074
+ <div><strong>Latest version:</strong> <span class="mono">${esc(au.latest_version || '-')}</span></div>
1075
+ <div><strong>Target version:</strong> <span class="mono">${esc(au.target_version || '-')}</span></div>
1076
+ <div><strong>Active calls:</strong> ${esc(String(au.active_calls || 0))}</div>
1077
+ <div><strong>Interval:</strong> ${intervalSec === null ? '-' : `${intervalSec}s`}</div>
1078
+ <div><strong>Last checked:</strong> ${esc(fmtDate(au.last_checked_at))}</div>
1079
+ <div><strong>Last success:</strong> ${esc(fmtDate(au.last_success_at))}</div>
1080
+ ${au.defer_reason ? `<div><strong>Deferred:</strong> ${esc(au.defer_reason)}</div>` : ''}
1081
+ ${au.last_error ? `<div><strong>Error:</strong> <span class="mono">${esc(au.last_error)}</span></div>` : ''}
1082
+ `;
1083
+
1084
+ if (toggleBtn) {
1085
+ toggleBtn.disabled = false;
1086
+ toggleBtn.textContent = enabled ? 'Disable auto-update' : 'Enable auto-update';
1087
+ }
1088
+ }
1089
+
1038
1090
  async function loadDashboardStatus(refreshIp = false) {
1039
1091
  const payload = await request(`/status${refreshIp ? '?refresh_ip=true' : ''}`);
1040
1092
  state.dashboardStatus = payload;
1093
+ state.autoUpdate = payload.auto_update || state.autoUpdate;
1041
1094
  renderCallbookStatus();
1095
+ renderAutoUpdateStatus();
1042
1096
  renderContacts();
1043
1097
  renderContactDetail();
1044
1098
  }
1045
1099
 
1100
+ async function loadAutoUpdateStatus() {
1101
+ const payload = await request('/update/status');
1102
+ state.autoUpdate = payload.auto_update || null;
1103
+ renderAutoUpdateStatus();
1104
+ }
1105
+
1046
1106
  function renderCallbookDevices() {
1047
1107
  const tbody = document.querySelector('#callbook-devices-table tbody');
1048
1108
  if (!tbody) return;
@@ -1154,6 +1214,47 @@ function bindCallbookActions() {
1154
1214
  });
1155
1215
  }
1156
1216
 
1217
+ function bindAutoUpdateActions() {
1218
+ document.getElementById('auto-update-refresh')?.addEventListener('click', () => {
1219
+ loadAutoUpdateStatus().catch(err => showNotice(err.message));
1220
+ });
1221
+
1222
+ document.getElementById('auto-update-check')?.addEventListener('click', async () => {
1223
+ try {
1224
+ await request('/update/check', { method: 'POST', body: JSON.stringify({}) });
1225
+ await loadAutoUpdateStatus();
1226
+ showNotice('Update check complete');
1227
+ } catch (err) {
1228
+ showNotice(err.message);
1229
+ }
1230
+ });
1231
+
1232
+ document.getElementById('auto-update-now')?.addEventListener('click', async () => {
1233
+ try {
1234
+ await request('/update/now', { method: 'POST', body: JSON.stringify({}) });
1235
+ await loadAutoUpdateStatus();
1236
+ showNotice('Update triggered');
1237
+ } catch (err) {
1238
+ showNotice(err.message);
1239
+ }
1240
+ });
1241
+
1242
+ document.getElementById('auto-update-toggle')?.addEventListener('click', async () => {
1243
+ const au = state.autoUpdate || {};
1244
+ const nextEnabled = !Boolean(au.enabled);
1245
+ try {
1246
+ await request('/update/config', {
1247
+ method: 'PUT',
1248
+ body: JSON.stringify({ enabled: nextEnabled })
1249
+ });
1250
+ await loadAutoUpdateStatus();
1251
+ showNotice(nextEnabled ? 'Auto-update enabled' : 'Auto-update disabled');
1252
+ } catch (err) {
1253
+ showNotice(err.message);
1254
+ }
1255
+ });
1256
+ }
1257
+
1157
1258
  function renderInvites() {
1158
1259
  const tbody = document.querySelector('#invites-table tbody');
1159
1260
  tbody.innerHTML = '';
@@ -1247,6 +1348,7 @@ async function bootstrap() {
1247
1348
  bindContactsActions();
1248
1349
  bindSettingsActions();
1249
1350
  bindCallbookActions();
1351
+ bindAutoUpdateActions();
1250
1352
  bindInviteActions();
1251
1353
  bindRefreshButtons();
1252
1354
 
@@ -1254,6 +1356,7 @@ async function bootstrap() {
1254
1356
  await Promise.all([
1255
1357
  loadSettings(),
1256
1358
  loadDashboardStatus(),
1359
+ loadAutoUpdateStatus(),
1257
1360
  loadCallbookDevices(),
1258
1361
  loadContacts(),
1259
1362
  loadCalls(),
@@ -1262,6 +1365,10 @@ async function bootstrap() {
1262
1365
  loadLogs()
1263
1366
  ]);
1264
1367
  showNotice('Dashboard loaded');
1368
+
1369
+ setInterval(() => {
1370
+ loadAutoUpdateStatus().catch(() => {});
1371
+ }, 10000);
1265
1372
  } catch (err) {
1266
1373
  showNotice(err.message);
1267
1374
  }
@@ -171,6 +171,15 @@
171
171
  <h3>Remote Callbook</h3>
172
172
  <div id="callbook-status" class="card"></div>
173
173
 
174
+ <h3>Auto Update</h3>
175
+ <div id="auto-update-status" class="card">Loading…</div>
176
+ <div class="row">
177
+ <button id="auto-update-refresh" type="button">Refresh</button>
178
+ <button id="auto-update-check" type="button">Check now</button>
179
+ <button id="auto-update-now" type="button">Update now</button>
180
+ <button id="auto-update-toggle" type="button">Disable auto-update</button>
181
+ </div>
182
+
174
183
  <form id="callbook-provision-form" class="card">
175
184
  <div class="row">
176
185
  <button type="submit">Create Install Link (24h)</button>
@@ -202,6 +202,33 @@ tr[data-selected="1"] td {
202
202
  display: none;
203
203
  }
204
204
 
205
+ .status-pill {
206
+ display: inline-block;
207
+ padding: 0.15rem 0.45rem;
208
+ border-radius: 999px;
209
+ border: 1px solid var(--line);
210
+ font-size: 0.78rem;
211
+ font-weight: 600;
212
+ }
213
+
214
+ .status-pill.ok {
215
+ background: #ecfdf3;
216
+ border-color: #9bd8b8;
217
+ color: #125934;
218
+ }
219
+
220
+ .status-pill.warn {
221
+ background: #fff7e8;
222
+ border-color: #f1d08e;
223
+ color: #8a5a00;
224
+ }
225
+
226
+ .status-pill.err {
227
+ background: #fff0f1;
228
+ border-color: #efb1b6;
229
+ color: #8c1d26;
230
+ }
231
+
205
232
  @media (max-width: 720px) {
206
233
  nav {
207
234
  overflow-x: auto;