@flame0510/project-aether 1.2.0 → 1.4.0

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.
Files changed (102) hide show
  1. package/README.md +3 -1
  2. package/agent-templates/README.md +42 -22
  3. package/agent-templates/base-image/Dockerfile +42 -33
  4. package/agent-templates/base-image/entrypoint.sh +67 -12
  5. package/app/agents/BrowserAccessSection.tsx +510 -0
  6. package/app/agents/ChannelManager.tsx +19 -11
  7. package/app/agents/ImageDownloadBanner.tsx +53 -19
  8. package/app/agents/ModelSection.tsx +316 -0
  9. package/app/agents/PageClient.tsx +708 -167
  10. package/app/agents/UpdateSection.tsx +300 -0
  11. package/app/agents/create/PageClient.tsx +11 -49
  12. package/app/agents/create/page.tsx +8 -21
  13. package/app/api/agents/[id]/backup/route.ts +26 -69
  14. package/app/api/agents/[id]/channels/pairing/route.ts +3 -3
  15. package/app/api/agents/[id]/channels/telegram/route.ts +2 -2
  16. package/app/api/agents/[id]/cold-backup/route.ts +56 -0
  17. package/app/api/agents/[id]/devices/route.ts +126 -0
  18. package/app/api/agents/[id]/invite-link/route.ts +53 -0
  19. package/app/api/agents/[id]/lifecycle/route.ts +3 -0
  20. package/app/api/agents/[id]/model/route.ts +113 -0
  21. package/app/api/agents/[id]/open-control-ui/route.ts +58 -0
  22. package/app/api/agents/[id]/recreate/route.ts +33 -187
  23. package/app/api/agents/[id]/restart/route.ts +5 -0
  24. package/app/api/agents/[id]/restore/route.ts +40 -70
  25. package/app/api/agents/[id]/route.ts +38 -169
  26. package/app/api/agents/[id]/update/rollback/route.ts +30 -0
  27. package/app/api/agents/[id]/update/route.ts +50 -0
  28. package/app/api/agents/activity-summary/route.ts +67 -0
  29. package/app/api/agents/create/route.ts +91 -145
  30. package/app/api/agents/devices-summary/route.ts +37 -0
  31. package/app/api/agents/download-image/route.ts +16 -9
  32. package/app/api/agents/image-status/route.ts +31 -111
  33. package/app/api/agents/models-summary/route.ts +163 -0
  34. package/app/api/agents/route.ts +25 -49
  35. package/app/api/agents/token/route.ts +33 -10
  36. package/app/api/assistant/route.ts +37 -16
  37. package/app/api/gateway/agent/route.ts +37 -6
  38. package/app/api/gateway/provider/balance/route.ts +5 -2
  39. package/app/api/gateway/provider/keys.ts +13 -1
  40. package/app/api/gateway/provider/route.ts +43 -12
  41. package/app/api/gateway/sync.ts +335 -76
  42. package/app/api/models/route.ts +28 -34
  43. package/app/api/provider/auth.ts +65 -0
  44. package/app/api/provider/upstream.ts +9 -2
  45. package/app/api/provider/v1/chat/completions/route.ts +22 -16
  46. package/app/api/provider/v1/models/route.ts +26 -133
  47. package/app/api/setup/agent-image/route.ts +14 -42
  48. package/app/components/DashboardToolbar.tsx +1 -1
  49. package/app/components/PulseChat.tsx +25 -39
  50. package/app/components/ui/RemoveButton.tsx +46 -0
  51. package/app/components/ui/Select.tsx +3 -2
  52. package/app/components/ui/index.ts +1 -0
  53. package/app/credentials/PageClient.tsx +2 -2
  54. package/app/gateway/PageClient.tsx +253 -674
  55. package/app/globals.css +8 -0
  56. package/app/lib/models-context.tsx +43 -7
  57. package/app/wizard/useWizard.ts +6 -1
  58. package/bin/rev4a.js +116 -50
  59. package/daemon.js +6 -6
  60. package/docs/ARCHITECTURE.md +110 -12
  61. package/docs/FRONTEND-ARCHITECTURE.md +31 -2
  62. package/docs/REV4A.md +93 -33
  63. package/docs/dev/API-REFERENCE.md +723 -178
  64. package/docs/dev/DATABASE.md +96 -0
  65. package/docs/dev/GATEWAY.md +250 -93
  66. package/docs/dev/PROVIDERS.md +26 -13
  67. package/docs/rag/DATA-FRESHNESS.md +59 -28
  68. package/docs/rag/GLOSSARY.md +27 -16
  69. package/docs/rag/REV4A-OVERVIEW.md +37 -25
  70. package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -8
  71. package/instrumentation.ts +52 -1
  72. package/lib/agent-busy.ts +21 -0
  73. package/lib/agent-devices.ts +361 -0
  74. package/lib/agent-edit-state.ts +108 -0
  75. package/lib/agent-edit.ts +157 -0
  76. package/lib/agent-images.ts +375 -0
  77. package/lib/agent-ports-server.ts +27 -0
  78. package/lib/agent-ports.ts +68 -0
  79. package/lib/agent-readiness.ts +110 -0
  80. package/lib/agent-recreate-state.ts +108 -0
  81. package/lib/agent-recreate.ts +305 -0
  82. package/lib/agent-restore-state.ts +107 -0
  83. package/lib/agent-restore.ts +135 -0
  84. package/lib/agent-setup.ts +66 -17
  85. package/lib/agent-update-state.ts +122 -0
  86. package/lib/agent-update.ts +448 -0
  87. package/lib/agent-versions.json +14 -0
  88. package/lib/agent-versions.ts +80 -0
  89. package/lib/buildAgentImage.ts +88 -290
  90. package/lib/channelManager.ts +153 -64
  91. package/lib/cold-backup.ts +354 -0
  92. package/lib/container-file.ts +27 -0
  93. package/lib/credentials/delivery.ts +3 -3
  94. package/lib/db-bootstrap.mjs +76 -0
  95. package/lib/docker-utils.ts +3 -3
  96. package/lib/model-catalogue.ts +140 -27
  97. package/lib/provider-balance.ts +33 -12
  98. package/lib/rev4a-paths.ts +0 -21
  99. package/model-pricing.json +118 -110
  100. package/models.config.json +27 -12
  101. package/package.json +1 -1
  102. package/app/api/gateway/route.ts +0 -191
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Persisted state of agent updates: the `agent_upgrades` table (lib/db-bootstrap.mjs).
3
+ *
4
+ * Kept apart from lib/agent-update.ts so the provider sync can ask which agents are
5
+ * being updated without importing the update action, which imports the sync.
6
+ */
7
+ import { openDb } from '@/lib/db';
8
+
9
+ export type UpdateStatus =
10
+ | 'pending'
11
+ | 'backing_up'
12
+ | 'migrating'
13
+ | 'verifying'
14
+ | 'done'
15
+ | 'failed'
16
+ | 'interrupted'
17
+ | 'rolling_back'
18
+ | 'rolled_back'
19
+ | 'rollback_failed';
20
+
21
+ /** Statuses during which the agent must not be touched by anything else. */
22
+ export const ACTIVE_STATUSES: readonly UpdateStatus[] = ['pending', 'backing_up', 'migrating', 'verifying', 'rolling_back'];
23
+
24
+ export interface AgentUpdateRow {
25
+ id: number;
26
+ agent_id: string;
27
+ status: UpdateStatus;
28
+ from_version: string | null;
29
+ to_version: string;
30
+ backup_file: string | null;
31
+ baseline_json: string | null;
32
+ verify_json: string | null;
33
+ error: string | null;
34
+ started_at: number;
35
+ updated_at: number;
36
+ finished_at: number | null;
37
+ }
38
+
39
+ const FINAL: readonly UpdateStatus[] = ['done', 'failed', 'interrupted', 'rolled_back', 'rollback_failed'];
40
+
41
+ export function insertUpdate(agentId: string, fromVersion: string | null, toVersion: string): number {
42
+ const db = openDb(false);
43
+ try {
44
+ const now = Date.now();
45
+ const info = db.prepare(
46
+ `INSERT INTO agent_upgrades (agent_id, status, from_version, to_version, started_at, updated_at)
47
+ VALUES (?, 'pending', ?, ?, ?, ?)`,
48
+ ).run(agentId, fromVersion, toVersion, now, now);
49
+ return Number(info.lastInsertRowid);
50
+ } finally {
51
+ db.close();
52
+ }
53
+ }
54
+
55
+ type Writable = Partial<Pick<AgentUpdateRow, 'status' | 'backup_file' | 'baseline_json' | 'verify_json' | 'error'>>;
56
+
57
+ export function updateRow(id: number, fields: Writable): void {
58
+ const entries = Object.entries(fields).filter(([, v]) => v !== undefined);
59
+ const now = Date.now();
60
+ const sets = [...entries.map(([k]) => `${k} = ?`), 'updated_at = ?'];
61
+ const values: unknown[] = [...entries.map(([, v]) => v), now];
62
+ if (fields.status && FINAL.includes(fields.status)) {
63
+ sets.push('finished_at = ?');
64
+ values.push(now);
65
+ } else if (fields.status) {
66
+ sets.push('finished_at = NULL');
67
+ }
68
+ const db = openDb(false);
69
+ try {
70
+ db.prepare(`UPDATE agent_upgrades SET ${sets.join(', ')} WHERE id = ?`).run(...values, id);
71
+ } finally {
72
+ db.close();
73
+ }
74
+ }
75
+
76
+ export function latestUpdate(agentId: string): AgentUpdateRow | null {
77
+ const db = openDb(true);
78
+ try {
79
+ return (db.prepare('SELECT * FROM agent_upgrades WHERE agent_id = ? ORDER BY id DESC LIMIT 1').get(agentId) as AgentUpdateRow | undefined) ?? null;
80
+ } finally {
81
+ db.close();
82
+ }
83
+ }
84
+
85
+ export function isUpdateActive(agentId: string): boolean {
86
+ const row = latestUpdate(agentId);
87
+ return !!row && ACTIVE_STATUSES.includes(row.status);
88
+ }
89
+
90
+ /** AGENT_IDs with an update in an active status. */
91
+ export function activeUpdateAgentIds(): Set<string> {
92
+ const db = openDb(true);
93
+ try {
94
+ const placeholders = ACTIVE_STATUSES.map(() => '?').join(', ');
95
+ const rows = db.prepare(`SELECT DISTINCT agent_id FROM agent_upgrades WHERE status IN (${placeholders})`).all(...ACTIVE_STATUSES) as { agent_id: string }[];
96
+ return new Set(rows.map((r) => r.agent_id));
97
+ } finally {
98
+ db.close();
99
+ }
100
+ }
101
+
102
+ /**
103
+ * At startup, no update job can be running: any row still active was cut off by the
104
+ * restart. It becomes `interrupted`, keeping the step it was in, so the operator decides
105
+ * between Rollback and a new Update.
106
+ */
107
+ export function markInterruptedUpdates(): number {
108
+ const db = openDb(false);
109
+ try {
110
+ const placeholders = ACTIVE_STATUSES.map(() => '?').join(', ');
111
+ const now = Date.now();
112
+ const info = db.prepare(
113
+ `UPDATE agent_upgrades
114
+ SET error = COALESCE(error, 'Rev4a restarted while this step was running: ' || status),
115
+ status = 'interrupted', updated_at = ?, finished_at = ?
116
+ WHERE status IN (${placeholders})`,
117
+ ).run(now, now, ...ACTIVE_STATUSES);
118
+ return info.changes;
119
+ } finally {
120
+ db.close();
121
+ }
122
+ }
@@ -0,0 +1,448 @@
1
+ /**
2
+ * The Update action: move one agent to a newer supported OpenClaw version.
3
+ *
4
+ * Each step is recorded in `agent_upgrades` before it starts:
5
+ *
6
+ * pending preflight: agent running, target downloaded, newer and supported,
7
+ * no backup or update already running
8
+ * (baseline) transcript events per session and cron job names, read inside the agent
9
+ * backing_up cold backup `agent-<id>-preupdate-<from>-<ts>.tar.gz`; the agent stays stopped
10
+ * migrating container recreated on `openclaw-agent-base:<to>`; its entrypoint runs
11
+ * `doctor --fix`; Rev4a waits for `/startupz` to report the target version,
12
+ * then re-applies its own config
13
+ * verifying every session has at least as many events as before, every cron job is
14
+ * still there
15
+ * done | failed
16
+ *
17
+ * A version change migrates data one way: the previous version refuses the migrated
18
+ * database, so going back means restoring the pre-update backup on the previous image
19
+ * (Rollback). The job runs inside the Rev4a process; after a Rev4a restart an unfinished
20
+ * one is marked `interrupted` (lib/agent-update-state.ts).
21
+ */
22
+ import { dockerExec, dockerExecNoFail } from '@/lib/docker-exec';
23
+ import { isValidAgentId } from '@/lib/container';
24
+ import { compareVersions, isSupportedVersion, localImageRef, parseOpenClawVersion } from '@/lib/agent-versions';
25
+ import {
26
+ containerOpenClawVersion,
27
+ listLocalAgentImages,
28
+ localVersionExists,
29
+ newestLocalSupportedVersion,
30
+ pruneAgentImages,
31
+ pullAgentImage,
32
+ runDocker,
33
+ } from '@/lib/agent-images';
34
+ import { BACKUP_VOLUME, coldBackupStatus, isColdBackupRunning, startColdBackup, waitForColdBackup } from '@/lib/cold-backup';
35
+ import { inspectAgentContainer, recreateAgentContainer, type AgentContainerInspect } from '@/lib/agent-recreate';
36
+ import { applyRuntimeConfig } from '@/lib/agent-setup';
37
+ import { waitForGatewayReady } from '@/lib/agent-readiness';
38
+ import { patchRev4aProvider } from '@/app/api/gateway/sync';
39
+ import { activeUpdateAgentIds, insertUpdate, latestUpdate, markInterruptedUpdates, updateRow, type AgentUpdateRow } from '@/lib/agent-update-state';
40
+ import { agentBusyReason } from '@/lib/agent-busy';
41
+
42
+ /** A migration runs `doctor --fix` before the Gateway starts; allow for large state. */
43
+ const MIGRATION_READY_TIMEOUT_MS = 10 * 60 * 1000;
44
+ /** A cold backup of a 13 GB volume takes ~8 minutes; anything past this is stuck. */
45
+ const COLD_BACKUP_WAIT_TIMEOUT_MS = 30 * 60 * 1000;
46
+
47
+ export class UpdateRefusedError extends Error {}
48
+
49
+ /**
50
+ * Counts transcript events per session, inside the agent: `.jsonl` lines on 2026.7.x,
51
+ * `transcript_events` rows in `openclaw-agent.sqlite` from 9.x. Keys are
52
+ * `<agent>/<session id>`; trajectory files are debug traces and are not counted.
53
+ */
54
+ const COUNT_TRANSCRIPTS = `
55
+ const fs = require('fs'), path = require('path');
56
+ const out = {};
57
+ const root = '/root/.openclaw/agents';
58
+ for (const agent of fs.existsSync(root) ? fs.readdirSync(root) : []) {
59
+ const sessions = path.join(root, agent, 'sessions');
60
+ if (fs.existsSync(sessions)) {
61
+ for (const f of fs.readdirSync(sessions)) {
62
+ if (!f.endsWith('.jsonl') || f.endsWith('.trajectory.jsonl')) continue;
63
+ const n = fs.readFileSync(path.join(sessions, f), 'utf8').split('\\n').filter((l) => l.trim()).length;
64
+ const key = agent + '/' + f.slice(0, -'.jsonl'.length);
65
+ out[key] = Math.max(out[key] || 0, n);
66
+ }
67
+ }
68
+ const dbFile = path.join(root, agent, 'agent', 'openclaw-agent.sqlite');
69
+ if (fs.existsSync(dbFile)) {
70
+ const { DatabaseSync } = require('node:sqlite');
71
+ const db = new DatabaseSync(dbFile, { readOnly: true });
72
+ // 2026.7.x creates this database too, without the transcript table.
73
+ const hasEvents = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'transcript_events'").get();
74
+ if (hasEvents) {
75
+ for (const r of db.prepare('SELECT session_id, COUNT(*) AS n FROM transcript_events GROUP BY session_id').all()) {
76
+ const key = agent + '/' + r.session_id;
77
+ out[key] = Math.max(out[key] || 0, Number(r.n));
78
+ }
79
+ }
80
+ db.close();
81
+ }
82
+ }
83
+ process.stdout.write('REV4A-COUNTS ' + JSON.stringify(out) + '\\n');
84
+ `;
85
+
86
+ async function countTranscripts(container: string): Promise<Record<string, number>> {
87
+ const out = await dockerExec(container, ['node', '-e', COUNT_TRANSCRIPTS], { timeoutMs: 120_000 });
88
+ const line = out.split('\n').find((l) => l.startsWith('REV4A-COUNTS '));
89
+ if (!line) throw new Error('Could not count the transcript events');
90
+ return JSON.parse(line.slice('REV4A-COUNTS '.length)) as Record<string, number>;
91
+ }
92
+
93
+ const CRON_LIST = [
94
+ 'export OPENCLAW_GATEWAY_PORT=3000',
95
+ 'if [ -f /root/.agent-token ]; then OPENCLAW_GATEWAY_TOKEN=$(head -1 /root/.agent-token | tr -d "[:space:]"); export OPENCLAW_GATEWAY_TOKEN; fi',
96
+ 'exec openclaw cron list --all --json',
97
+ ].join('; ');
98
+
99
+ /** Names of the agent's cron jobs; null when the list cannot be read. */
100
+ async function cronJobNames(container: string): Promise<string[] | null> {
101
+ const out = await dockerExecNoFail(container, ['sh', '-c', CRON_LIST], { timeoutMs: 120_000 });
102
+ const start = out.search(/[{[]/);
103
+ if (start === -1) return null;
104
+ try {
105
+ const data = JSON.parse(out.slice(start)) as unknown;
106
+ const jobs = Array.isArray(data) ? data : ((data as { jobs?: unknown[] })?.jobs ?? []);
107
+ return (jobs as { name?: string; id?: string }[]).map((j) => j.name ?? j.id ?? '').filter(Boolean).sort();
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ async function runningVersion(container: string): Promise<string | null> {
114
+ const body = await dockerExecNoFail(container, ['curl', '-s', '--max-time', '3', 'http://127.0.0.1:3000/startupz'], { timeoutMs: 10_000 });
115
+ try {
116
+ return parseOpenClawVersion(String((JSON.parse(body) as { version?: unknown }).version ?? ''));
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ interface Baseline {
123
+ transcripts: Record<string, number>;
124
+ cronJobs: string[] | null;
125
+ }
126
+
127
+ export interface Verification {
128
+ sessions: number;
129
+ /** Sessions with fewer events than before, or gone: `{ session, before, after }`. */
130
+ lostEvents: { session: string; before: number; after: number }[];
131
+ /** Cron jobs present before and missing after; null when either list could not be read. */
132
+ missingCronJobs: string[] | null;
133
+ ok: boolean;
134
+ }
135
+
136
+ export function verify(baseline: Baseline, transcripts: Record<string, number>, cronJobs: string[] | null): Verification {
137
+ const lostEvents = Object.entries(baseline.transcripts)
138
+ .filter(([session, before]) => (transcripts[session] ?? 0) < before)
139
+ .map(([session, before]) => ({ session, before, after: transcripts[session] ?? 0 }));
140
+ const missingCronJobs = baseline.cronJobs && cronJobs
141
+ ? baseline.cronJobs.filter((name) => !cronJobs.includes(name))
142
+ : null;
143
+ return {
144
+ sessions: Object.keys(baseline.transcripts).length,
145
+ lostEvents,
146
+ missingCronJobs,
147
+ ok: lostEvents.length === 0 && (missingCronJobs === null || missingCronJobs.length === 0),
148
+ };
149
+ }
150
+
151
+ /** Jobs running in this process, so a second start for the same agent is refused. */
152
+ const running = new Set<string>();
153
+
154
+ /**
155
+ * Start an update. Validates synchronously enough to refuse clearly (UpdateRefusedError),
156
+ * then runs the steps in the background. Returns the new row id.
157
+ */
158
+ export async function startAgentUpdate(agentId: string, requestedVersion?: string): Promise<number> {
159
+ if (!isValidAgentId(agentId)) throw new UpdateRefusedError('Invalid agent id');
160
+ if (running.has(agentId)) throw new UpdateRefusedError('An update of this agent is already running');
161
+ // Claimed synchronously, before the first await, so two requests cannot both pass.
162
+ running.add(agentId);
163
+ try {
164
+ // One source for every long operation: an update, recreate, restore, edit or backup.
165
+ const busy = await agentBusyReason(agentId);
166
+ if (busy) throw new UpdateRefusedError(busy);
167
+
168
+ return await runUpdateStart(agentId, requestedVersion);
169
+ } catch (e) {
170
+ running.delete(agentId);
171
+ throw e;
172
+ }
173
+ }
174
+
175
+ async function runUpdateStart(agentId: string, requestedVersion?: string): Promise<number> {
176
+ const container = await inspectAgentContainer(agentId);
177
+ if (!container) throw new UpdateRefusedError(`No container found with AGENT_ID '${agentId}'`);
178
+ if (!container.State?.Running) throw new UpdateRefusedError('Start the agent before updating it: its data is counted while it runs');
179
+ const name = (container.Name ?? '').replace(/^\//, '');
180
+
181
+ const images = await listLocalAgentImages();
182
+ const fromVersion = await containerOpenClawVersion({ name, imageId: container.Image ?? '', running: true }, images);
183
+ if (!fromVersion) throw new UpdateRefusedError('The OpenClaw version this agent runs could not be read');
184
+
185
+ const toVersion = requestedVersion ?? newestLocalSupportedVersion(images);
186
+ if (!toVersion || !isSupportedVersion(toVersion)) throw new UpdateRefusedError('No supported OpenClaw version to update to is downloaded');
187
+ if (!(await localVersionExists(toVersion))) throw new UpdateRefusedError(`OpenClaw ${toVersion} is not downloaded`);
188
+ if (compareVersions(toVersion, fromVersion) <= 0) {
189
+ throw new UpdateRefusedError(`The agent already runs OpenClaw ${fromVersion}; ${toVersion} is not newer`);
190
+ }
191
+
192
+ const id = insertUpdate(agentId, fromVersion, toVersion);
193
+ void runUpdate(id, agentId, name, fromVersion, toVersion).finally(() => running.delete(agentId));
194
+ return id;
195
+ }
196
+
197
+ async function runUpdate(id: number, agentId: string, name: string, fromVersion: string, toVersion: string): Promise<void> {
198
+ let stopped = false;
199
+ try {
200
+ const baseline: Baseline = { transcripts: await countTranscripts(name), cronJobs: await cronJobNames(name) };
201
+ updateRow(id, { baseline_json: JSON.stringify(baseline), status: 'backing_up' });
202
+
203
+ const { file } = await startColdBackup(agentId, { kind: 'preupdate', label: fromVersion, leaveStopped: true });
204
+ stopped = true;
205
+ updateRow(id, { backup_file: file });
206
+ const backup = await waitForColdBackup(agentId, { timeoutMs: COLD_BACKUP_WAIT_TIMEOUT_MS });
207
+ if (backup.status !== 'succeeded') throw new Error(`The pre-update backup failed: ${backup.error ?? 'unknown error'}`);
208
+
209
+ updateRow(id, { status: 'migrating' });
210
+ const container = await inspectAgentContainer(agentId);
211
+ if (!container) throw new Error('The agent container disappeared during the update');
212
+ await recreateAgentContainer(agentId, container, localImageRef(toVersion));
213
+ stopped = false;
214
+
215
+ if (!(await waitForGatewayReady(agentId, MIGRATION_READY_TIMEOUT_MS))) {
216
+ throw new Error(`OpenClaw ${toVersion} did not finish starting within ${MIGRATION_READY_TIMEOUT_MS / 60_000} minutes`);
217
+ }
218
+ const version = await runningVersion(agentId);
219
+ if (version !== toVersion) throw new Error(`The agent reports OpenClaw ${version ?? 'no version'} instead of ${toVersion}`);
220
+
221
+ await applyRuntimeConfig(agentId);
222
+ try {
223
+ patchRev4aProvider(agentId);
224
+ } catch (e) {
225
+ console.error('[agent:update] provider patch failed:', (e as Error).message);
226
+ }
227
+
228
+ updateRow(id, { status: 'verifying' });
229
+ const transcriptsAfter = await countTranscripts(agentId);
230
+ const cronAfter = await cronJobNames(agentId);
231
+ if (baseline.cronJobs?.length && cronAfter === null) {
232
+ // Unreadable is not "no jobs": refuse to call the update verified rather than
233
+ // report `done` while cron loss would be undetectable.
234
+ throw new Error('The cron job list could not be read after the update, so the verification is incomplete');
235
+ }
236
+ const result = verify(baseline, transcriptsAfter, cronAfter);
237
+ updateRow(id, { verify_json: JSON.stringify(result) });
238
+ if (!result.ok) {
239
+ throw new Error(
240
+ result.lostEvents.length
241
+ ? `${result.lostEvents.length} session(s) have fewer transcript events than before the update`
242
+ : `Cron jobs missing after the update: ${(result.missingCronJobs ?? []).join(', ')}`,
243
+ );
244
+ }
245
+
246
+ updateRow(id, { status: 'done' });
247
+
248
+ // Retention: images nothing needs any more (lib/agent-images.ts). Best-effort.
249
+ pruneAgentImages()
250
+ .then((removed) => { if (removed.length) console.log(`[agent:update] removed unused agent images: ${removed.join(', ')}`); })
251
+ .catch((err: unknown) => console.warn('[agent:update] image cleanup failed:', (err as Error).message));
252
+ } catch (e) {
253
+ // A failure before the recreate leaves the old container stopped by the backup:
254
+ // start it again, so a failed update never leaves the agent down.
255
+ if (stopped) {
256
+ const container = await inspectAgentContainer(agentId).catch(() => null);
257
+ const containerName = container?.Name?.replace(/^\//, '');
258
+ if (containerName && !container?.State?.Running) {
259
+ await runDocker(['start', containerName], { timeoutMs: 60_000 }).catch(() => {});
260
+ }
261
+ }
262
+ updateRow(id, { status: 'failed', error: shortError(e) });
263
+ }
264
+ }
265
+
266
+ /** The useful part of an error for the panel: a `docker exec` failure carries a whole stack trace. */
267
+ function shortError(e: unknown): string {
268
+ const message = (e as Error)?.message ?? String(e);
269
+ const lines = message.split('\n').map((l) => l.trim()).filter(Boolean);
270
+ const cause = lines.find((l) => /^(Error|[A-Z]\w*Error):/.test(l));
271
+ const text = cause && cause !== lines[0] ? `${lines[0]} ${cause}` : lines[0] ?? message;
272
+ return text.length > 400 ? `${text.slice(0, 400)}…` : text;
273
+ }
274
+
275
+ /**
276
+ * At startup no update can still be running: rows still active were cut off by the
277
+ * restart. They become `interrupted` and an agent the pre-update backup left stopped is
278
+ * started again, so a Rev4a restart during the backup window never leaves it down. The
279
+ * row stays `interrupted`, so the operator can still roll back.
280
+ */
281
+ export async function recoverInterruptedUpdates(): Promise<number> {
282
+ const ids = activeUpdateAgentIds();
283
+ if (ids.size === 0) return 0;
284
+ const interrupted = markInterruptedUpdates();
285
+ for (const agentId of ids) {
286
+ const container = await inspectAgentContainer(agentId).catch(() => null);
287
+ const name = container?.Name?.replace(/^\//, '');
288
+ if (name && !container?.State?.Running) {
289
+ await runDocker(['start', name], { timeoutMs: 60_000 }).catch(() => {});
290
+ }
291
+ }
292
+ return interrupted;
293
+ }
294
+
295
+ /** The agent's current version and the version an Update would move it to, if any. */
296
+ export async function updateCandidate(agentId: string): Promise<{
297
+ currentVersion: string | null;
298
+ candidate: { fromVersion: string; toVersion: string } | null;
299
+ }> {
300
+ const container = await inspectAgentContainer(agentId);
301
+ if (!container) return { currentVersion: null, candidate: null };
302
+ const images = await listLocalAgentImages();
303
+ const currentVersion = await containerOpenClawVersion(
304
+ { name: (container.Name ?? '').replace(/^\//, ''), imageId: container.Image ?? '', running: container.State?.Running === true },
305
+ images,
306
+ );
307
+ const newest = newestLocalSupportedVersion(images);
308
+ const candidate = currentVersion && newest && compareVersions(newest, currentVersion) > 0
309
+ ? { fromVersion: currentVersion, toVersion: newest }
310
+ : null;
311
+ return { currentVersion, candidate };
312
+ }
313
+
314
+ // ── Rollback ─────────────────────────────────────────────────────────────────
315
+
316
+ const RESTORE_TIMEOUT_MS = 60 * 60 * 1000;
317
+ const ROLLBACK_READY_TIMEOUT_MS = 5 * 60 * 1000;
318
+ const ROLLBACK_FROM: readonly AgentUpdateRow['status'][] = ['done', 'failed', 'interrupted', 'rollback_failed'];
319
+
320
+ async function backupFileExists(file: string): Promise<boolean> {
321
+ try {
322
+ await runDocker(
323
+ ['run', '--rm', '-v', `${BACKUP_VOLUME}:/backup`, 'alpine', 'sh', '-c', 'test -f "/backup/$FILE"'],
324
+ { timeoutMs: 60_000, env: { FILE: file } },
325
+ );
326
+ return true;
327
+ } catch {
328
+ return false;
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Roll the latest update of an agent back: restore its pre-update backup on the version
334
+ * it ran before. Everything the agent did after that backup is lost. Refused
335
+ * (UpdateRefusedError) when there is nothing to roll back or the backup is gone.
336
+ */
337
+ export async function startAgentRollback(agentId: string): Promise<number> {
338
+ if (!isValidAgentId(agentId)) throw new UpdateRefusedError('Invalid agent id');
339
+ if (running.has(agentId)) throw new UpdateRefusedError('An update of this agent is running');
340
+ // Claimed synchronously, then the same single busy source as the update.
341
+ running.add(agentId);
342
+ try {
343
+ const busy = await agentBusyReason(agentId);
344
+ if (busy) throw new UpdateRefusedError(busy);
345
+ return await runRollbackStart(agentId);
346
+ } catch (e) {
347
+ running.delete(agentId);
348
+ throw e;
349
+ }
350
+ }
351
+
352
+ async function runRollbackStart(agentId: string): Promise<number> {
353
+ const row = latestUpdate(agentId);
354
+ if (!row || !ROLLBACK_FROM.includes(row.status)) throw new UpdateRefusedError('There is no update of this agent to roll back');
355
+ if (!row.backup_file || !row.from_version) throw new UpdateRefusedError('This update has no pre-update backup to restore');
356
+ if (!(await backupFileExists(row.backup_file))) {
357
+ throw new UpdateRefusedError(`The pre-update backup ${row.backup_file} no longer exists`);
358
+ }
359
+ const container = await inspectAgentContainer(agentId);
360
+ if (!container) throw new UpdateRefusedError(`No container found with AGENT_ID '${agentId}'`);
361
+
362
+ updateRow(row.id, { status: 'rolling_back', error: null });
363
+ void runRollback(row, container).finally(() => running.delete(agentId));
364
+ return row.id;
365
+ }
366
+
367
+ /**
368
+ * Restore exactly the pre-update state: the volume from the backup, the container on the
369
+ * previous image. Rev4a's config is not re-applied — the restored `openclaw.json` is the
370
+ * one that version accepted.
371
+ */
372
+ async function runRollback(row: AgentUpdateRow, container: AgentContainerInspect): Promise<void> {
373
+ const agentId = row.agent_id;
374
+ const fromVersion = row.from_version as string;
375
+ const file = row.backup_file as string;
376
+ try {
377
+ if (!(await localVersionExists(fromVersion))) {
378
+ if (!isSupportedVersion(fromVersion)) {
379
+ throw new Error(`The image for OpenClaw ${fromVersion} is not available locally and this Rev4a cannot download it`);
380
+ }
381
+ await pullAgentImage(fromVersion);
382
+ }
383
+
384
+ const name = (container.Name ?? '').replace(/^\//, '');
385
+ if (container.State?.Running) {
386
+ await runDocker(['stop', '-t', '30', name], { timeoutMs: 45_000 })
387
+ .catch(() => runDocker(['kill', name], { timeoutMs: 15_000 }));
388
+ }
389
+
390
+ await runDocker(
391
+ [
392
+ 'run', '--rm',
393
+ '-v', `agent-${agentId}-data:/target`,
394
+ '-v', `${BACKUP_VOLUME}:/backup`,
395
+ 'alpine', 'sh', '-c',
396
+ 'rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null; tar xzf "/backup/$FILE" -C /target',
397
+ ],
398
+ { timeoutMs: RESTORE_TIMEOUT_MS, env: { FILE: file } },
399
+ );
400
+
401
+ await recreateAgentContainer(agentId, container, localImageRef(fromVersion));
402
+ if (!(await waitForGatewayReady(agentId, ROLLBACK_READY_TIMEOUT_MS))) {
403
+ throw new Error(`OpenClaw ${fromVersion} did not finish starting within ${ROLLBACK_READY_TIMEOUT_MS / 60_000} minutes`);
404
+ }
405
+ const version = parseOpenClawVersion(await dockerExecNoFail(agentId, ['openclaw', '--version'], { timeoutMs: 60_000 }));
406
+ if (version !== fromVersion) throw new Error(`The agent reports OpenClaw ${version ?? 'no version'} instead of ${fromVersion}`);
407
+
408
+ updateRow(row.id, { status: 'rolled_back' });
409
+ } catch (e) {
410
+ updateRow(row.id, { status: 'rollback_failed', error: shortError(e) });
411
+ }
412
+ }
413
+
414
+ export interface AgentUpdateView {
415
+ id: number;
416
+ agentId: string;
417
+ status: AgentUpdateRow['status'];
418
+ fromVersion: string | null;
419
+ toVersion: string;
420
+ backupFile: string | null;
421
+ /** Cold backup progress while `backing_up`. */
422
+ backupPercent: number | null;
423
+ verification: Verification | null;
424
+ error: string | null;
425
+ startedAtMs: number;
426
+ finishedAtMs: number | null;
427
+ }
428
+
429
+ /** The latest update of an agent, with live backup progress. */
430
+ export async function agentUpdateView(agentId: string): Promise<AgentUpdateView | null> {
431
+ const row = latestUpdate(agentId);
432
+ if (!row) return null;
433
+ let backupPercent: number | null = null;
434
+ if (row.status === 'backing_up') backupPercent = (await coldBackupStatus(agentId))?.percent ?? null;
435
+ return {
436
+ id: row.id,
437
+ agentId: row.agent_id,
438
+ status: row.status,
439
+ fromVersion: row.from_version,
440
+ toVersion: row.to_version,
441
+ backupFile: row.backup_file,
442
+ backupPercent,
443
+ verification: row.verify_json ? (JSON.parse(row.verify_json) as Verification) : null,
444
+ error: row.error,
445
+ startedAtMs: row.started_at,
446
+ finishedAtMs: row.finished_at,
447
+ };
448
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "localRepository": "openclaw-agent-base",
3
+ "registryRepository": "ghcr.io/flame0510/rev4a/openclaw-agent-base",
4
+ "supported": [
5
+ {
6
+ "version": "2026.9.3",
7
+ "modelInput": ["text", "image", "audio", "video"]
8
+ },
9
+ {
10
+ "version": "2026.7.1-2",
11
+ "modelInput": ["text", "image"]
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The OpenClaw versions this Rev4a release supports, newest first.
3
+ *
4
+ * The table lives in `agent-versions.json` so the `rev4a` CLI (plain CommonJS) reads
5
+ * the same list the server does. A new OpenClaw release is supported by adding its
6
+ * entry here together with the Dockerfile's `ARG OPENCLAW_VERSION`; nothing detects
7
+ * versions at runtime.
8
+ *
9
+ * `modelInput` is the model `input` list written into `models.json` for that version:
10
+ * OpenClaw validates it against the modalities it knows, so a value an older release
11
+ * does not know invalidates the whole catalogue.
12
+ */
13
+ import table from './agent-versions.json';
14
+
15
+ export interface SupportedVersion {
16
+ version: string;
17
+ modelInput: string[];
18
+ }
19
+
20
+ export const SUPPORTED_VERSIONS: readonly SupportedVersion[] = table.supported;
21
+
22
+ /** Local image repository; agents run `openclaw-agent-base:<version>`. */
23
+ export const LOCAL_IMAGE_REPOSITORY: string = table.localRepository;
24
+
25
+ /**
26
+ * Where images are pulled from. `REV4A_AGENT_IMAGE_REGISTRY` points Rev4a at another
27
+ * registry with the same tags, such as a local `registry:2` for tests.
28
+ */
29
+ export function registryRepository(): string {
30
+ return process.env.REV4A_AGENT_IMAGE_REGISTRY?.trim() || table.registryRepository;
31
+ }
32
+
33
+ export const localImageRef = (version: string): string => `${LOCAL_IMAGE_REPOSITORY}:${version}`;
34
+
35
+ export const newestSupportedVersion = (): string => SUPPORTED_VERSIONS[0].version;
36
+
37
+ export const isSupportedVersion = (version: unknown): version is string =>
38
+ typeof version === 'string' && SUPPORTED_VERSIONS.some((v) => v.version === version);
39
+
40
+ /** Model `input` values every supported version accepts — the safe list when the version is unknown. */
41
+ export function conservativeModelInput(): string[] {
42
+ return SUPPORTED_VERSIONS.reduce<string[]>(
43
+ (acc, v, i) => (i === 0 ? [...v.modelInput] : acc.filter((x) => v.modelInput.includes(x))),
44
+ [],
45
+ );
46
+ }
47
+
48
+ /**
49
+ * Model `input` values for a version. An unknown version gets the conservative
50
+ * intersection rather than the newest list: sending an older release a value it does
51
+ * not know discards its whole generated catalogue, so the fallback must be the set all
52
+ * supported versions accept.
53
+ */
54
+ export function modelInputFor(version?: string | null): string[] {
55
+ const entry = version ? SUPPORTED_VERSIONS.find((v) => v.version === version) : undefined;
56
+ return entry ? entry.modelInput : conservativeModelInput();
57
+ }
58
+
59
+ const VERSION_RE = /(\d{4})\.(\d{1,2})\.(\d{1,2})(?:-(\d+))?/;
60
+
61
+ /** The version in `2026.9.3`, `2026.7.1-2` or `OpenClaw 2026.9.3 (1391f7c)`; null when there is none. */
62
+ export function parseOpenClawVersion(text: string | null | undefined): string | null {
63
+ const m = VERSION_RE.exec(text ?? '');
64
+ if (!m) return null;
65
+ return `${Number(m[1])}.${Number(m[2])}.${Number(m[3])}${m[4] !== undefined ? `-${Number(m[4])}` : ''}`;
66
+ }
67
+
68
+ /** Negative when `a` is older than `b`, zero when equal, positive when newer. Unparseable sorts oldest. */
69
+ export function compareVersions(a: string, b: string): number {
70
+ const parts = (v: string) => {
71
+ const m = VERSION_RE.exec(v);
72
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4] ?? 0)] : [-1, -1, -1, -1];
73
+ };
74
+ const pa = parts(a);
75
+ const pb = parts(b);
76
+ for (let i = 0; i < pa.length; i++) {
77
+ if (pa[i] !== pb[i]) return pa[i] - pb[i];
78
+ }
79
+ return 0;
80
+ }