@flame0510/project-aether 1.3.0 → 1.4.1

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 (78) hide show
  1. package/README.md +1 -0
  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 +4 -1
  9. package/app/agents/PageClient.tsx +629 -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 +14 -28
  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]/open-control-ui/route.ts +58 -0
  21. package/app/api/agents/[id]/recreate/route.ts +33 -163
  22. package/app/api/agents/[id]/restart/route.ts +5 -0
  23. package/app/api/agents/[id]/restore/route.ts +40 -70
  24. package/app/api/agents/[id]/route.ts +38 -150
  25. package/app/api/agents/[id]/update/rollback/route.ts +30 -0
  26. package/app/api/agents/[id]/update/route.ts +50 -0
  27. package/app/api/agents/activity-summary/route.ts +67 -0
  28. package/app/api/agents/create/route.ts +38 -92
  29. package/app/api/agents/devices-summary/route.ts +37 -0
  30. package/app/api/agents/download-image/route.ts +16 -9
  31. package/app/api/agents/image-status/route.ts +31 -111
  32. package/app/api/agents/route.ts +25 -49
  33. package/app/api/agents/token/route.ts +33 -10
  34. package/app/api/assistant/route.ts +2 -2
  35. package/app/api/gateway/agent/route.ts +14 -0
  36. package/app/api/gateway/provider/balance/route.ts +5 -2
  37. package/app/api/gateway/sync.ts +97 -14
  38. package/app/api/setup/agent-image/route.ts +14 -42
  39. package/app/api/version/route.ts +2 -1
  40. package/app/components/DashboardToolbar.tsx +1 -1
  41. package/app/gateway/PageClient.tsx +27 -32
  42. package/bin/rev4a.js +43 -41
  43. package/daemon.js +6 -6
  44. package/docs/ARCHITECTURE.md +107 -9
  45. package/docs/FRONTEND-ARCHITECTURE.md +8 -1
  46. package/docs/REV4A.md +54 -17
  47. package/docs/dev/API-REFERENCE.md +573 -105
  48. package/docs/dev/DATABASE.md +96 -0
  49. package/docs/dev/GATEWAY.md +21 -6
  50. package/docs/rag/DATA-FRESHNESS.md +6 -4
  51. package/docs/rag/GLOSSARY.md +12 -3
  52. package/docs/rag/REV4A-OVERVIEW.md +18 -5
  53. package/docs/rag/WHAT-I-CAN-ANSWER.md +6 -2
  54. package/instrumentation.ts +43 -0
  55. package/lib/agent-busy.ts +21 -0
  56. package/lib/agent-devices.ts +361 -0
  57. package/lib/agent-edit-state.ts +108 -0
  58. package/lib/agent-edit.ts +149 -0
  59. package/lib/agent-images.ts +375 -0
  60. package/lib/agent-ports-server.ts +78 -0
  61. package/lib/agent-ports.ts +91 -0
  62. package/lib/agent-recreate-state.ts +108 -0
  63. package/lib/agent-recreate.ts +359 -0
  64. package/lib/agent-restore-state.ts +107 -0
  65. package/lib/agent-restore.ts +141 -0
  66. package/lib/agent-setup.ts +66 -17
  67. package/lib/agent-update-state.ts +122 -0
  68. package/lib/agent-update.ts +456 -0
  69. package/lib/agent-versions.json +14 -0
  70. package/lib/agent-versions.ts +80 -0
  71. package/lib/buildAgentImage.ts +88 -290
  72. package/lib/channelManager.ts +149 -102
  73. package/lib/cold-backup.ts +354 -0
  74. package/lib/credentials/delivery.ts +3 -3
  75. package/lib/db-bootstrap.mjs +76 -0
  76. package/lib/docker-utils.ts +3 -3
  77. package/lib/provider-balance.ts +33 -12
  78. package/package.json +1 -1
@@ -8,9 +8,9 @@
8
8
  * one-file change.
9
9
  */
10
10
 
11
- import { execSync } from 'child_process';
12
11
  import * as fs from 'fs';
13
12
  import { SHARED_SKILLS_DIR, REV4A_RULES_DIR } from '@/lib/rev4a-paths';
13
+ import { dockerExec, dockerExecWithInput } from '@/lib/docker-exec';
14
14
 
15
15
  // ── Volumes ──────────────────────────────────────────────────────────────────
16
16
 
@@ -103,27 +103,76 @@ const GUARANTEED_CONFIG_PATCHES: object[] = [
103
103
  },
104
104
  ];
105
105
 
106
+ const CONFIG_PATCH_TIMEOUT_MS = 15_000;
107
+
108
+ /** Exactly `["*"]`: the allow-all list earlier Rev4a versions wrote. */
109
+ function isWildcardOnly(value: unknown): boolean {
110
+ return Array.isArray(value) && value.length === 1 && value[0] === '*';
111
+ }
112
+
113
+ /**
114
+ * `gateway.controlUi` with Rev4a's browser-origin policy applied.
115
+ *
116
+ * The Host-header fallback accepts a Control UI page served from the host the
117
+ * browser connected to — whatever address the agent is reached on — and refuses
118
+ * pages from any other origin. Without it, a public IP is refused; loopback and
119
+ * private hosts are accepted either way. An `allowedOrigins: ["*"]` list, which
120
+ * accepts every origin, is dropped; any other list an operator set is kept. The
121
+ * retired `dangerouslyDisableDeviceAuth` is dropped too.
122
+ */
123
+ export function withControlUiPolicy(current: unknown): Record<string, unknown> {
124
+ const next: Record<string, unknown> = {
125
+ ...(current && typeof current === 'object' ? (current as Record<string, unknown>) : {}),
126
+ };
127
+ next.dangerouslyAllowHostHeaderOriginFallback = true;
128
+ delete next.dangerouslyDisableDeviceAuth;
129
+ if (isWildcardOnly(next.allowedOrigins)) delete next.allowedOrigins;
130
+ return next;
131
+ }
132
+
133
+ async function patchConfig(containerName: string, patch: object): Promise<void> {
134
+ await dockerExecWithInput(containerName, ['openclaw', 'config', 'patch', '--stdin'], JSON.stringify(patch), {
135
+ timeoutMs: CONFIG_PATCH_TIMEOUT_MS,
136
+ });
137
+ }
138
+
139
+ /**
140
+ * Apply the origin policy to a running container through merge patches.
141
+ *
142
+ * The fallback and the removal of `["*"]` go in one patch, so the Gateway reloads
143
+ * both together. The retired `dangerouslyDisableDeviceAuth` goes in a second patch:
144
+ * if the Gateway refuses it, the policy the Control UI needs is already in place.
145
+ */
146
+ async function applyControlUiPolicy(containerName: string): Promise<void> {
147
+ const raw = await dockerExec(containerName, ['cat', '/root/.openclaw/openclaw.json'], { timeoutMs: CONFIG_PATCH_TIMEOUT_MS });
148
+ const current = (JSON.parse(raw) as { gateway?: { controlUi?: Record<string, unknown> } })?.gateway?.controlUi ?? {};
149
+
150
+ const policy: Record<string, unknown> = { dangerouslyAllowHostHeaderOriginFallback: true };
151
+ if (isWildcardOnly(current.allowedOrigins)) policy.allowedOrigins = null;
152
+ await patchConfig(containerName, { gateway: { controlUi: policy } });
153
+
154
+ if ('dangerouslyDisableDeviceAuth' in current) {
155
+ await patchConfig(containerName, { gateway: { controlUi: { dangerouslyDisableDeviceAuth: null } } });
156
+ }
157
+ }
158
+
106
159
  /**
107
- * Apply all guaranteed config patches to a running container.
160
+ * Apply all guaranteed config patches to a running container, then the origin policy.
108
161
  * Uses `openclaw config patch --stdin` (deep-merge) so existing
109
- * user customizations are never overwritten.
162
+ * user customizations are never overwritten. Best-effort: a failed patch is logged
163
+ * and the others still run.
110
164
  */
111
- export function applyRuntimeConfig(containerName: string): void {
165
+ export async function applyRuntimeConfig(containerName: string): Promise<void> {
112
166
  for (const patch of GUARANTEED_CONFIG_PATCHES) {
113
- const json = JSON.stringify(patch);
114
167
  try {
115
- execSync(
116
- `docker exec -i ${escapeShell(containerName)} sh -c 'openclaw config patch --stdin'`,
117
- { timeout: 15_000, stdio: 'pipe', input: json },
118
- );
119
- } catch {
120
- // config patch is best-effort — the image may already have it
168
+ await patchConfig(containerName, patch);
169
+ } catch (e) {
170
+ console.warn(`[agent-setup] config patch failed on ${containerName}:`, (e as Error).message);
121
171
  }
122
172
  }
123
- }
124
-
125
- // ── Helpers ──────────────────────────────────────────────────────────────────
126
-
127
- export function escapeShell(value: string): string {
128
- return `'${value.replace(/'/g, `'\\''`)}'`;
173
+ try {
174
+ await applyControlUiPolicy(containerName);
175
+ } catch (e) {
176
+ console.warn(`[agent-setup] Control UI origin policy not applied on ${containerName}:`, (e as Error).message);
177
+ }
129
178
  }
@@ -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,456 @@
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, startAndVerifyContainer, 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. When it does not
255
+ // come back the failure text says so — a container that is "running" with no network
256
+ // is not an agent the operator can use.
257
+ if (stopped) {
258
+ const container = await inspectAgentContainer(agentId).catch(() => null);
259
+ const containerName = container?.Name?.replace(/^\//, '');
260
+ if (containerName && !container?.State?.Running) {
261
+ const started = await startAndVerifyContainer(agentId, containerName);
262
+ if (!started.ok) {
263
+ const note = `the previous container did not come back: ${started.error}`;
264
+ const message = shortError(e);
265
+ updateRow(id, { status: 'failed', error: `${message}; ${note}` });
266
+ return;
267
+ }
268
+ }
269
+ }
270
+ updateRow(id, { status: 'failed', error: shortError(e) });
271
+ }
272
+ }
273
+
274
+ /** The useful part of an error for the panel: a `docker exec` failure carries a whole stack trace. */
275
+ function shortError(e: unknown): string {
276
+ const message = (e as Error)?.message ?? String(e);
277
+ const lines = message.split('\n').map((l) => l.trim()).filter(Boolean);
278
+ const cause = lines.find((l) => /^(Error|[A-Z]\w*Error):/.test(l));
279
+ const text = cause && cause !== lines[0] ? `${lines[0]} ${cause}` : lines[0] ?? message;
280
+ return text.length > 400 ? `${text.slice(0, 400)}…` : text;
281
+ }
282
+
283
+ /**
284
+ * At startup no update can still be running: rows still active were cut off by the
285
+ * restart. They become `interrupted` and an agent the pre-update backup left stopped is
286
+ * started again, so a Rev4a restart during the backup window never leaves it down. The
287
+ * row stays `interrupted`, so the operator can still roll back.
288
+ */
289
+ export async function recoverInterruptedUpdates(): Promise<number> {
290
+ const ids = activeUpdateAgentIds();
291
+ if (ids.size === 0) return 0;
292
+ const interrupted = markInterruptedUpdates();
293
+ for (const agentId of ids) {
294
+ const container = await inspectAgentContainer(agentId).catch(() => null);
295
+ const name = container?.Name?.replace(/^\//, '');
296
+ if (!name || container?.State?.Running) continue;
297
+ const started = await startAndVerifyContainer(agentId, name);
298
+ if (!started.ok) console.warn(`[agent:update] ${agentId} did not come back after an interrupted update: ${started.error}`);
299
+ }
300
+ return interrupted;
301
+ }
302
+
303
+ /** The agent's current version and the version an Update would move it to, if any. */
304
+ export async function updateCandidate(agentId: string): Promise<{
305
+ currentVersion: string | null;
306
+ candidate: { fromVersion: string; toVersion: string } | null;
307
+ }> {
308
+ const container = await inspectAgentContainer(agentId);
309
+ if (!container) return { currentVersion: null, candidate: null };
310
+ const images = await listLocalAgentImages();
311
+ const currentVersion = await containerOpenClawVersion(
312
+ { name: (container.Name ?? '').replace(/^\//, ''), imageId: container.Image ?? '', running: container.State?.Running === true },
313
+ images,
314
+ );
315
+ const newest = newestLocalSupportedVersion(images);
316
+ const candidate = currentVersion && newest && compareVersions(newest, currentVersion) > 0
317
+ ? { fromVersion: currentVersion, toVersion: newest }
318
+ : null;
319
+ return { currentVersion, candidate };
320
+ }
321
+
322
+ // ── Rollback ─────────────────────────────────────────────────────────────────
323
+
324
+ const RESTORE_TIMEOUT_MS = 60 * 60 * 1000;
325
+ const ROLLBACK_READY_TIMEOUT_MS = 5 * 60 * 1000;
326
+ const ROLLBACK_FROM: readonly AgentUpdateRow['status'][] = ['done', 'failed', 'interrupted', 'rollback_failed'];
327
+
328
+ async function backupFileExists(file: string): Promise<boolean> {
329
+ try {
330
+ await runDocker(
331
+ ['run', '--rm', '-v', `${BACKUP_VOLUME}:/backup`, 'alpine', 'sh', '-c', 'test -f "/backup/$FILE"'],
332
+ { timeoutMs: 60_000, env: { FILE: file } },
333
+ );
334
+ return true;
335
+ } catch {
336
+ return false;
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Roll the latest update of an agent back: restore its pre-update backup on the version
342
+ * it ran before. Everything the agent did after that backup is lost. Refused
343
+ * (UpdateRefusedError) when there is nothing to roll back or the backup is gone.
344
+ */
345
+ export async function startAgentRollback(agentId: string): Promise<number> {
346
+ if (!isValidAgentId(agentId)) throw new UpdateRefusedError('Invalid agent id');
347
+ if (running.has(agentId)) throw new UpdateRefusedError('An update of this agent is running');
348
+ // Claimed synchronously, then the same single busy source as the update.
349
+ running.add(agentId);
350
+ try {
351
+ const busy = await agentBusyReason(agentId);
352
+ if (busy) throw new UpdateRefusedError(busy);
353
+ return await runRollbackStart(agentId);
354
+ } catch (e) {
355
+ running.delete(agentId);
356
+ throw e;
357
+ }
358
+ }
359
+
360
+ async function runRollbackStart(agentId: string): Promise<number> {
361
+ const row = latestUpdate(agentId);
362
+ if (!row || !ROLLBACK_FROM.includes(row.status)) throw new UpdateRefusedError('There is no update of this agent to roll back');
363
+ if (!row.backup_file || !row.from_version) throw new UpdateRefusedError('This update has no pre-update backup to restore');
364
+ if (!(await backupFileExists(row.backup_file))) {
365
+ throw new UpdateRefusedError(`The pre-update backup ${row.backup_file} no longer exists`);
366
+ }
367
+ const container = await inspectAgentContainer(agentId);
368
+ if (!container) throw new UpdateRefusedError(`No container found with AGENT_ID '${agentId}'`);
369
+
370
+ updateRow(row.id, { status: 'rolling_back', error: null });
371
+ void runRollback(row, container).finally(() => running.delete(agentId));
372
+ return row.id;
373
+ }
374
+
375
+ /**
376
+ * Restore exactly the pre-update state: the volume from the backup, the container on the
377
+ * previous image. Rev4a's config is not re-applied — the restored `openclaw.json` is the
378
+ * one that version accepted.
379
+ */
380
+ async function runRollback(row: AgentUpdateRow, container: AgentContainerInspect): Promise<void> {
381
+ const agentId = row.agent_id;
382
+ const fromVersion = row.from_version as string;
383
+ const file = row.backup_file as string;
384
+ try {
385
+ if (!(await localVersionExists(fromVersion))) {
386
+ if (!isSupportedVersion(fromVersion)) {
387
+ throw new Error(`The image for OpenClaw ${fromVersion} is not available locally and this Rev4a cannot download it`);
388
+ }
389
+ await pullAgentImage(fromVersion);
390
+ }
391
+
392
+ const name = (container.Name ?? '').replace(/^\//, '');
393
+ if (container.State?.Running) {
394
+ await runDocker(['stop', '-t', '30', name], { timeoutMs: 45_000 })
395
+ .catch(() => runDocker(['kill', name], { timeoutMs: 15_000 }));
396
+ }
397
+
398
+ await runDocker(
399
+ [
400
+ 'run', '--rm',
401
+ '-v', `agent-${agentId}-data:/target`,
402
+ '-v', `${BACKUP_VOLUME}:/backup`,
403
+ 'alpine', 'sh', '-c',
404
+ 'rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null; tar xzf "/backup/$FILE" -C /target',
405
+ ],
406
+ { timeoutMs: RESTORE_TIMEOUT_MS, env: { FILE: file } },
407
+ );
408
+
409
+ await recreateAgentContainer(agentId, container, localImageRef(fromVersion));
410
+ if (!(await waitForGatewayReady(agentId, ROLLBACK_READY_TIMEOUT_MS))) {
411
+ throw new Error(`OpenClaw ${fromVersion} did not finish starting within ${ROLLBACK_READY_TIMEOUT_MS / 60_000} minutes`);
412
+ }
413
+ const version = parseOpenClawVersion(await dockerExecNoFail(agentId, ['openclaw', '--version'], { timeoutMs: 60_000 }));
414
+ if (version !== fromVersion) throw new Error(`The agent reports OpenClaw ${version ?? 'no version'} instead of ${fromVersion}`);
415
+
416
+ updateRow(row.id, { status: 'rolled_back' });
417
+ } catch (e) {
418
+ updateRow(row.id, { status: 'rollback_failed', error: shortError(e) });
419
+ }
420
+ }
421
+
422
+ export interface AgentUpdateView {
423
+ id: number;
424
+ agentId: string;
425
+ status: AgentUpdateRow['status'];
426
+ fromVersion: string | null;
427
+ toVersion: string;
428
+ backupFile: string | null;
429
+ /** Cold backup progress while `backing_up`. */
430
+ backupPercent: number | null;
431
+ verification: Verification | null;
432
+ error: string | null;
433
+ startedAtMs: number;
434
+ finishedAtMs: number | null;
435
+ }
436
+
437
+ /** The latest update of an agent, with live backup progress. */
438
+ export async function agentUpdateView(agentId: string): Promise<AgentUpdateView | null> {
439
+ const row = latestUpdate(agentId);
440
+ if (!row) return null;
441
+ let backupPercent: number | null = null;
442
+ if (row.status === 'backing_up') backupPercent = (await coldBackupStatus(agentId))?.percent ?? null;
443
+ return {
444
+ id: row.id,
445
+ agentId: row.agent_id,
446
+ status: row.status,
447
+ fromVersion: row.from_version,
448
+ toVersion: row.to_version,
449
+ backupFile: row.backup_file,
450
+ backupPercent,
451
+ verification: row.verify_json ? (JSON.parse(row.verify_json) as Verification) : null,
452
+ error: row.error,
453
+ startedAtMs: row.started_at,
454
+ finishedAtMs: row.finished_at,
455
+ };
456
+ }