@flame0510/project-aether 1.3.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 (76) 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/api/agents/[id]/backup/route.ts +26 -69
  13. package/app/api/agents/[id]/channels/pairing/route.ts +3 -3
  14. package/app/api/agents/[id]/channels/telegram/route.ts +2 -2
  15. package/app/api/agents/[id]/cold-backup/route.ts +56 -0
  16. package/app/api/agents/[id]/devices/route.ts +126 -0
  17. package/app/api/agents/[id]/invite-link/route.ts +53 -0
  18. package/app/api/agents/[id]/lifecycle/route.ts +3 -0
  19. package/app/api/agents/[id]/open-control-ui/route.ts +58 -0
  20. package/app/api/agents/[id]/recreate/route.ts +33 -163
  21. package/app/api/agents/[id]/restart/route.ts +5 -0
  22. package/app/api/agents/[id]/restore/route.ts +40 -70
  23. package/app/api/agents/[id]/route.ts +38 -150
  24. package/app/api/agents/[id]/update/rollback/route.ts +30 -0
  25. package/app/api/agents/[id]/update/route.ts +50 -0
  26. package/app/api/agents/activity-summary/route.ts +67 -0
  27. package/app/api/agents/create/route.ts +32 -88
  28. package/app/api/agents/devices-summary/route.ts +37 -0
  29. package/app/api/agents/download-image/route.ts +16 -9
  30. package/app/api/agents/image-status/route.ts +31 -111
  31. package/app/api/agents/route.ts +25 -49
  32. package/app/api/agents/token/route.ts +33 -10
  33. package/app/api/assistant/route.ts +2 -2
  34. package/app/api/gateway/agent/route.ts +14 -0
  35. package/app/api/gateway/provider/balance/route.ts +5 -2
  36. package/app/api/gateway/sync.ts +97 -14
  37. package/app/api/setup/agent-image/route.ts +14 -42
  38. package/app/components/DashboardToolbar.tsx +1 -1
  39. package/app/gateway/PageClient.tsx +27 -32
  40. package/bin/rev4a.js +43 -41
  41. package/daemon.js +6 -6
  42. package/docs/ARCHITECTURE.md +95 -9
  43. package/docs/FRONTEND-ARCHITECTURE.md +8 -1
  44. package/docs/REV4A.md +54 -17
  45. package/docs/dev/API-REFERENCE.md +554 -100
  46. package/docs/dev/DATABASE.md +96 -0
  47. package/docs/dev/GATEWAY.md +21 -6
  48. package/docs/rag/DATA-FRESHNESS.md +6 -4
  49. package/docs/rag/GLOSSARY.md +12 -3
  50. package/docs/rag/REV4A-OVERVIEW.md +18 -5
  51. package/docs/rag/WHAT-I-CAN-ANSWER.md +6 -2
  52. package/instrumentation.ts +43 -0
  53. package/lib/agent-busy.ts +21 -0
  54. package/lib/agent-devices.ts +361 -0
  55. package/lib/agent-edit-state.ts +108 -0
  56. package/lib/agent-edit.ts +157 -0
  57. package/lib/agent-images.ts +375 -0
  58. package/lib/agent-ports-server.ts +27 -0
  59. package/lib/agent-ports.ts +68 -0
  60. package/lib/agent-recreate-state.ts +108 -0
  61. package/lib/agent-recreate.ts +305 -0
  62. package/lib/agent-restore-state.ts +107 -0
  63. package/lib/agent-restore.ts +135 -0
  64. package/lib/agent-setup.ts +66 -17
  65. package/lib/agent-update-state.ts +122 -0
  66. package/lib/agent-update.ts +448 -0
  67. package/lib/agent-versions.json +14 -0
  68. package/lib/agent-versions.ts +80 -0
  69. package/lib/buildAgentImage.ts +88 -290
  70. package/lib/channelManager.ts +149 -102
  71. package/lib/cold-backup.ts +354 -0
  72. package/lib/credentials/delivery.ts +3 -3
  73. package/lib/db-bootstrap.mjs +76 -0
  74. package/lib/docker-utils.ts +3 -3
  75. package/lib/provider-balance.ts +33 -12
  76. package/package.json +1 -1
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Browser access to an agent's Control UI: requests waiting for approval, the
3
+ * browsers already approved, and the one-time link that opens the Control UI
4
+ * without an approval.
5
+ *
6
+ * Everything goes through the OpenClaw CLI inside the container, called with the
7
+ * async `dockerExec`, so a slow agent never holds the event loop. The CLI talks to
8
+ * the agent's own Gateway on port 3000. The token is resolved inside the container
9
+ * with the entrypoint's precedence — `/root/.agent-token`, then the container's
10
+ * `OPENCLAW_GATEWAY_TOKEN` — so it never reaches argv on the host.
11
+ *
12
+ * Browser approval exists from OpenClaw 9.x. Agents created on 2026.7.x carry
13
+ * `gateway.controlUi.dangerouslyDisableDeviceAuth: true`: they open with the token
14
+ * alone and have nothing to approve, so `requiresApproval` is false for them and
15
+ * the CLI is not called. `openclaw dashboard --json` does not exist on 2026.7.x
16
+ * either, so `resolveControlUiUrl` gives those agents the plain token link.
17
+ */
18
+ import { dockerExec, dockerExecNoFail } from '@/lib/docker-exec';
19
+ import { dockerFetch } from '@/lib/docker-socket';
20
+ import { isValidAgentId } from '@/lib/container';
21
+
22
+ const CLI_TIMEOUT_MS = 20_000;
23
+ const LINK_TIMEOUT_MS = 30_000;
24
+ const CONFIG_TIMEOUT_MS = 8_000;
25
+
26
+ /**
27
+ * Runs `openclaw "$@"` against the agent's own Gateway. Arguments stay positional
28
+ * (`sh -c <script> sh <args…>`): nothing a caller passes is interpolated into the
29
+ * script.
30
+ */
31
+ const GATEWAY_CLI = [
32
+ 'export OPENCLAW_GATEWAY_PORT=3000',
33
+ 'if [ -f /root/.agent-token ]; then OPENCLAW_GATEWAY_TOKEN=$(head -1 /root/.agent-token | tr -d "[:space:]"); export OPENCLAW_GATEWAY_TOKEN; fi',
34
+ 'exec openclaw "$@"',
35
+ ].join('; ');
36
+
37
+ const REQUEST_ID_RE = /^[A-Za-z0-9-]{8,80}$/;
38
+ const DEVICE_ID_RE = /^[A-Za-z0-9_-]{8,128}$/;
39
+ const HOSTNAME_RE = /^(\[[0-9A-Fa-f:.]+\]|[0-9A-Fa-f:.]{2,45}|[A-Za-z0-9.-]{1,253})$/;
40
+ const MAX_NAME_LENGTH = 64;
41
+
42
+ export const isRequestId = (v: unknown): v is string => typeof v === 'string' && REQUEST_ID_RE.test(v);
43
+ export const isDeviceId = (v: unknown): v is string => typeof v === 'string' && DEVICE_ID_RE.test(v);
44
+ export const isHostname = (v: unknown): v is string => typeof v === 'string' && HOSTNAME_RE.test(v);
45
+
46
+ /** A device label: trimmed, printable, at most 64 characters. Null when unusable. */
47
+ export function cleanDeviceName(v: unknown): string | null {
48
+ if (typeof v !== 'string') return null;
49
+ const name = v.trim();
50
+ if (!name || name.length > MAX_NAME_LENGTH || /[\u0000-\u001f\u007f]/.test(name)) return null;
51
+ return name;
52
+ }
53
+
54
+ export interface AgentContainer {
55
+ name: string;
56
+ running: boolean;
57
+ /** Host port published for the Gateway's port 3000, if any. */
58
+ controlPort: string | null;
59
+ }
60
+
61
+ interface DockerContainer {
62
+ Names?: string[];
63
+ State?: string;
64
+ Labels?: Record<string, string>;
65
+ Ports?: { PrivatePort?: number; PublicPort?: number }[];
66
+ }
67
+
68
+ /** The container carrying `AGENT_ID=<agentId>`, through the Docker API rather than a blocking `docker ps`. */
69
+ export async function findAgentContainer(agentId: string): Promise<AgentContainer | null> {
70
+ if (!isValidAgentId(agentId)) return null;
71
+ const filters = encodeURIComponent(JSON.stringify({ label: [`AGENT_ID=${agentId}`] }));
72
+ const list = await dockerFetch<DockerContainer[]>('GET', `/containers/json?all=true&filters=${filters}`);
73
+ const c = Array.isArray(list) ? list[0] : undefined;
74
+ if (!c) return null;
75
+ const port = (c.Ports ?? []).find((p) => p.PrivatePort === 3000 && p.PublicPort)?.PublicPort;
76
+ return {
77
+ name: (c.Names?.[0] ?? '').replace(/^\//, ''),
78
+ running: c.State === 'running',
79
+ controlPort: port ? String(port) : null,
80
+ };
81
+ }
82
+
83
+ /** Running agent containers, for the fleet summary. */
84
+ export async function listRunningAgentContainers(): Promise<{ agentId: string; name: string }[]> {
85
+ const filters = encodeURIComponent(JSON.stringify({ label: ['AGENT_ID'] }));
86
+ const list = await dockerFetch<DockerContainer[]>('GET', `/containers/json?filters=${filters}`);
87
+ return (Array.isArray(list) ? list : [])
88
+ .filter((c) => c.State === 'running' && c.Labels?.AGENT_ID)
89
+ .map((c) => ({ agentId: c.Labels!.AGENT_ID, name: (c.Names?.[0] ?? '').replace(/^\//, '') }));
90
+ }
91
+
92
+ /**
93
+ * The JSON document in the CLI's output. `docker exec` resolves with partial output
94
+ * when it hits its deadline, so a document that does not parse is treated as a
95
+ * failure, never as an empty answer.
96
+ */
97
+ function parseCliJson(output: string): unknown {
98
+ const start = output.search(/[{[]/);
99
+ const end = Math.max(output.lastIndexOf('}'), output.lastIndexOf(']'));
100
+ if (start === -1 || end < start) throw new Error('OpenClaw returned no JSON');
101
+ try {
102
+ return JSON.parse(output.slice(start, end + 1));
103
+ } catch {
104
+ throw new Error('OpenClaw returned incomplete JSON');
105
+ }
106
+ }
107
+
108
+ async function runCli(container: string, args: string[], timeoutMs = CLI_TIMEOUT_MS): Promise<unknown> {
109
+ const out = await dockerExec(container, ['sh', '-c', GATEWAY_CLI, 'sh', ...args], { timeoutMs });
110
+ return parseCliJson(out);
111
+ }
112
+
113
+ /** True when the Gateway answers `/startupz` with JSON carrying its version, which only OpenClaw 9.x does. */
114
+ async function answersStartupzWithVersion(container: string): Promise<boolean> {
115
+ const probe = await dockerExecNoFail(container, ['curl', '-s', '--max-time', '3', 'http://127.0.0.1:3000/startupz'], { timeoutMs: CONFIG_TIMEOUT_MS });
116
+ try {
117
+ return typeof (JSON.parse(probe) as { version?: unknown })?.version === 'string';
118
+ } catch {
119
+ // Not JSON: the 2026.7.x web UI answers every unknown path with HTML.
120
+ return false;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Whether browsers need approval on this agent.
126
+ *
127
+ * The OpenClaw version decides first. 9.x answers `/startupz` with a JSON document
128
+ * carrying its version, and there approval always applies: it ignores
129
+ * `dangerouslyDisableDeviceAuth`, which an agent created on 2026.7.x may still carry.
130
+ * Anything else is 2026.7.x, where that key does disable approval. Null when neither
131
+ * can be read.
132
+ */
133
+ export async function requiresBrowserApproval(container: string): Promise<boolean | null> {
134
+ if (await answersStartupzWithVersion(container)) return true;
135
+ try {
136
+ const raw = await dockerExec(container, ['cat', '/root/.openclaw/openclaw.json'], { timeoutMs: CONFIG_TIMEOUT_MS });
137
+ const cfg = JSON.parse(raw) as { gateway?: { controlUi?: { dangerouslyDisableDeviceAuth?: unknown } } };
138
+ return cfg?.gateway?.controlUi?.dangerouslyDisableDeviceAuth !== true;
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ const str = (v: unknown): string | null => (typeof v === 'string' && v.trim() ? v.trim() : null);
145
+ const num = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null);
146
+ const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []);
147
+
148
+ export interface PendingBrowser {
149
+ requestId: string;
150
+ deviceId: string;
151
+ displayName: string | null;
152
+ platform: string | null;
153
+ clientId: string | null;
154
+ clientMode: string | null;
155
+ roles: string[];
156
+ scopes: string[];
157
+ remoteIp: string | null;
158
+ browserOrigin: string | null;
159
+ /** A known device asking for a broader role or scope set. */
160
+ isRepair: boolean;
161
+ requestedAtMs: number | null;
162
+ }
163
+
164
+ export interface ApprovedBrowser {
165
+ deviceId: string;
166
+ /** Operator label, else the name the client reported. */
167
+ label: string | null;
168
+ platform: string | null;
169
+ clientId: string | null;
170
+ clientMode: string | null;
171
+ roles: string[];
172
+ scopes: string[];
173
+ remoteIp: string | null;
174
+ /** `silent` (local auto-approval), `owner` (approved by an operator), `bootstrap` (one-time link), … */
175
+ approvedVia: string | null;
176
+ createdAtMs: number | null;
177
+ approvedAtMs: number | null;
178
+ lastUsedAtMs: number | null;
179
+ }
180
+
181
+ export interface BrowserAccess {
182
+ requiresApproval: boolean | null;
183
+ pending: PendingBrowser[];
184
+ approved: ApprovedBrowser[];
185
+ }
186
+
187
+ function toPending(r: Record<string, unknown>): PendingBrowser | null {
188
+ const requestId = str(r.requestId);
189
+ const deviceId = str(r.deviceId);
190
+ if (!requestId || !deviceId) return null;
191
+ return {
192
+ requestId,
193
+ deviceId,
194
+ displayName: str(r.displayName),
195
+ platform: str(r.platform),
196
+ clientId: str(r.clientId),
197
+ clientMode: str(r.clientMode),
198
+ roles: strings(r.roles).length ? strings(r.roles) : strings([r.role]),
199
+ scopes: strings(r.scopes),
200
+ remoteIp: str(r.remoteIp),
201
+ browserOrigin: str(r.browserOrigin),
202
+ isRepair: r.isRepair === true,
203
+ requestedAtMs: num(r.ts),
204
+ };
205
+ }
206
+
207
+ function toApproved(d: Record<string, unknown>): ApprovedBrowser | null {
208
+ const deviceId = str(d.deviceId);
209
+ if (!deviceId) return null;
210
+ const tokenUse = (Array.isArray(d.tokens) ? d.tokens : [])
211
+ .map((t) => num((t as Record<string, unknown>)?.lastUsedAtMs))
212
+ .filter((t): t is number => t !== null);
213
+ return {
214
+ deviceId,
215
+ label: str(d.operatorLabel) ?? str(d.displayName),
216
+ platform: str(d.platform),
217
+ clientId: str(d.clientId),
218
+ clientMode: str(d.clientMode),
219
+ roles: strings(d.roles).length ? strings(d.roles) : strings([d.role]),
220
+ scopes: strings(d.scopes),
221
+ remoteIp: str(d.remoteIp),
222
+ approvedVia: str(d.approvedVia),
223
+ createdAtMs: num(d.createdAtMs),
224
+ approvedAtMs: num(d.approvedAtMs),
225
+ lastUsedAtMs: tokenUse.length ? Math.max(...tokenUse) : num(d.lastSeenAtMs) ?? num(d.lastConnectedAtMs),
226
+ };
227
+ }
228
+
229
+ /** Pending requests and approved browsers of one running agent. Throws when the CLI fails. */
230
+ export async function listBrowserAccess(container: string): Promise<BrowserAccess> {
231
+ const requiresApproval = await requiresBrowserApproval(container);
232
+ if (requiresApproval === false) return { requiresApproval, pending: [], approved: [] };
233
+ const raw = (await runCli(container, ['devices', 'list', '--json'])) as { pending?: unknown; paired?: unknown };
234
+ const rows = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is Record<string, unknown> => !!x && typeof x === 'object') : []);
235
+ return {
236
+ requiresApproval,
237
+ pending: rows(raw?.pending).map(toPending).filter((x): x is PendingBrowser => x !== null),
238
+ approved: rows(raw?.paired).map(toApproved).filter((x): x is ApprovedBrowser => x !== null),
239
+ };
240
+ }
241
+
242
+ export async function approveBrowser(container: string, requestId: string): Promise<void> {
243
+ await runCli(container, ['devices', 'approve', requestId, '--json']);
244
+ }
245
+
246
+ export async function rejectBrowser(container: string, requestId: string): Promise<void> {
247
+ await runCli(container, ['devices', 'reject', requestId, '--json']);
248
+ }
249
+
250
+ /** Removes the pairing: that browser needs approval again on its next connection. */
251
+ export async function removeBrowser(container: string, deviceId: string): Promise<void> {
252
+ await runCli(container, ['devices', 'remove', deviceId, '--json']);
253
+ }
254
+
255
+ export async function renameBrowser(container: string, deviceId: string, name: string): Promise<void> {
256
+ await runCli(container, ['devices', 'rename', '--device', deviceId, '--name', name, '--json']);
257
+ }
258
+
259
+ /**
260
+ * A one-time Control UI link that pairs the browser opening it, with no approval.
261
+ *
262
+ * `openclaw dashboard --json` issues a ten-minute, single-use bootstrap token and
263
+ * returns `browserUrl` aimed at the Gateway's own listener (`127.0.0.1:3000`), with
264
+ * the WebSocket destination in the `gatewayUrl` fragment parameter. Both are
265
+ * rewritten to the address the dashboard is being reached on and the agent's
266
+ * published port, which is where the browser can actually connect.
267
+ */
268
+ export async function issueControlUiLink(
269
+ container: string,
270
+ hostname: string,
271
+ port: string,
272
+ ): Promise<{ url: string; expiresAtMs: number | null }> {
273
+ const data = (await runCli(container, ['dashboard', '--json'], LINK_TIMEOUT_MS)) as Record<string, unknown>;
274
+ const browserUrl = str(data?.browserUrl);
275
+ if (data?.ok !== true || !browserUrl) throw new Error(str(data?.reason) ?? 'OpenClaw did not issue a browser link');
276
+
277
+ const host = hostForUrl(hostname);
278
+ const [base, fragment = ''] = browserUrl.split('#');
279
+ const url = new URL(base);
280
+ url.hostname = host;
281
+ url.port = port;
282
+
283
+ const params = new URLSearchParams(fragment);
284
+ const gatewayUrl = params.get('gatewayUrl');
285
+ if (gatewayUrl) {
286
+ const ws = new URL(gatewayUrl);
287
+ ws.hostname = host;
288
+ ws.port = port;
289
+ params.set('gatewayUrl', ws.toString().replace(/\/$/, ''));
290
+ }
291
+ return { url: `${url.toString()}#${params.toString()}`, expiresAtMs: num(data.browserBootstrapExpiresAtMs) };
292
+ }
293
+
294
+ /** An IPv6 literal needs brackets inside a URL. */
295
+ function hostForUrl(hostname: string): string {
296
+ return hostname.includes(':') && !hostname.startsWith('[') ? `[${hostname}]` : hostname;
297
+ }
298
+
299
+ /** The hostname in a `Host` header value — the address the dashboard was reached on. */
300
+ export function hostnameFromHostHeader(host: string | null): string | null {
301
+ if (!host) return null;
302
+ try {
303
+ const hostname = new URL(`http://${host}`).hostname;
304
+ return isHostname(hostname) ? hostname : null;
305
+ } catch {
306
+ return null;
307
+ }
308
+ }
309
+
310
+ /** The token the Gateway was started with, read inside the container with the entrypoint's precedence. */
311
+ const READ_GATEWAY_TOKEN =
312
+ 'if [ -f /root/.agent-token ]; then head -1 /root/.agent-token | tr -d "[:space:]"; else printf %s "$OPENCLAW_GATEWAY_TOKEN"; fi';
313
+
314
+ /**
315
+ * The token a plain Control UI link carries, read from the container rather than from
316
+ * Rev4a's copy, so it is the one the Gateway checks even after the shared token changed
317
+ * while the agent was stopped.
318
+ */
319
+ async function readGatewayToken(container: string): Promise<string | null> {
320
+ try {
321
+ const token = (await dockerExec(container, ['sh', '-c', READ_GATEWAY_TOKEN], { timeoutMs: CONFIG_TIMEOUT_MS })).trim();
322
+ return token && token !== 'undefined' ? token : null;
323
+ } catch {
324
+ return null;
325
+ }
326
+ }
327
+
328
+ const plainLink = (hostname: string, port: string, token: string) =>
329
+ `http://${hostForUrl(hostname)}:${port}/#token=${encodeURIComponent(token)}`;
330
+
331
+ /**
332
+ * Where "Open" should take the browser: the one-time link on OpenClaw 9.x, which pairs
333
+ * the browser with no approval, else the plain token link — on 2026.7.x, which has no
334
+ * `dashboard --json` and needs no approval, or when no one-time link could be issued.
335
+ */
336
+ export async function resolveControlUiUrl(
337
+ container: string,
338
+ hostname: string,
339
+ port: string,
340
+ ): Promise<{ url: string; kind: 'one-time' | 'token' }> {
341
+ if (await answersStartupzWithVersion(container)) {
342
+ try {
343
+ return { url: (await issueControlUiLink(container, hostname, port)).url, kind: 'one-time' };
344
+ } catch {
345
+ // Fall back to the plain link below.
346
+ }
347
+ }
348
+ const token = await readGatewayToken(container);
349
+ if (!token) throw new Error('no one-time link could be issued and no gateway token is available');
350
+ return { url: plainLink(hostname, port, token), kind: 'token' };
351
+ }
352
+
353
+ /**
354
+ * A Control UI link for someone else: the plain token link. On an agent that requires
355
+ * browser approval it gets past gateway auth only; the browser then waits for approval.
356
+ */
357
+ export async function buildInviteLink(container: string, hostname: string, port: string): Promise<string> {
358
+ const token = await readGatewayToken(container);
359
+ if (!token) throw new Error('The agent has no readable gateway token');
360
+ return plainLink(hostname, port, token);
361
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Persisted state of agent edits: the `agent_edits` table (lib/db-bootstrap.mjs).
3
+ *
4
+ * Kept apart from lib/agent-edit.ts so busy checks (lib/agent-busy.ts) can ask which
5
+ * agents are being edited without importing the edit action.
6
+ */
7
+ import { openDb } from '@/lib/db';
8
+
9
+ export type EditStatus = 'rebuilding' | 'done' | 'failed' | 'interrupted';
10
+
11
+ /** Statuses during which the agent must not be touched by anything else. */
12
+ export const ACTIVE_EDIT_STATUSES: readonly EditStatus[] = ['rebuilding'];
13
+
14
+ export interface AgentEditRow {
15
+ id: number;
16
+ agent_id: string;
17
+ status: EditStatus;
18
+ display_name: string | null;
19
+ port_range: string | null;
20
+ error: string | null;
21
+ started_at: number;
22
+ updated_at: number;
23
+ finished_at: number | null;
24
+ }
25
+
26
+ const FINAL: readonly EditStatus[] = ['done', 'failed', 'interrupted'];
27
+
28
+ export function insertEdit(agentId: string, displayName: string | null, portRange: string | null): number {
29
+ const db = openDb(false);
30
+ try {
31
+ const now = Date.now();
32
+ const info = db.prepare(
33
+ `INSERT INTO agent_edits (agent_id, status, display_name, port_range, started_at, updated_at)
34
+ VALUES (?, 'rebuilding', ?, ?, ?, ?)`,
35
+ ).run(agentId, displayName, portRange, now, now);
36
+ return Number(info.lastInsertRowid);
37
+ } finally {
38
+ db.close();
39
+ }
40
+ }
41
+
42
+ type Writable = Partial<Pick<AgentEditRow, 'status' | 'error'>>;
43
+
44
+ export function updateEditRow(id: number, fields: Writable): void {
45
+ const entries = Object.entries(fields).filter(([, v]) => v !== undefined);
46
+ const now = Date.now();
47
+ const sets = [...entries.map(([k]) => `${k} = ?`), 'updated_at = ?'];
48
+ const values: unknown[] = [...entries.map(([, v]) => v), now];
49
+ if (fields.status && FINAL.includes(fields.status)) {
50
+ sets.push('finished_at = ?');
51
+ values.push(now);
52
+ } else if (fields.status) {
53
+ sets.push('finished_at = NULL');
54
+ }
55
+ const db = openDb(false);
56
+ try {
57
+ db.prepare(`UPDATE agent_edits SET ${sets.join(', ')} WHERE id = ?`).run(...values, id);
58
+ } finally {
59
+ db.close();
60
+ }
61
+ }
62
+
63
+ export function latestEdit(agentId: string): AgentEditRow | null {
64
+ const db = openDb(true);
65
+ try {
66
+ return (db.prepare('SELECT * FROM agent_edits WHERE agent_id = ? ORDER BY id DESC LIMIT 1').get(agentId) as AgentEditRow | undefined) ?? null;
67
+ } finally {
68
+ db.close();
69
+ }
70
+ }
71
+
72
+ export function isEditActive(agentId: string): boolean {
73
+ const row = latestEdit(agentId);
74
+ return !!row && ACTIVE_EDIT_STATUSES.includes(row.status);
75
+ }
76
+
77
+ /** AGENT_IDs with an edit in an active status. */
78
+ export function activeEditAgentIds(): Set<string> {
79
+ const db = openDb(true);
80
+ try {
81
+ const placeholders = ACTIVE_EDIT_STATUSES.map(() => '?').join(', ');
82
+ const rows = db.prepare(`SELECT DISTINCT agent_id FROM agent_edits WHERE status IN (${placeholders})`).all(...ACTIVE_EDIT_STATUSES) as { agent_id: string }[];
83
+ return new Set(rows.map((r) => r.agent_id));
84
+ } finally {
85
+ db.close();
86
+ }
87
+ }
88
+
89
+ /**
90
+ * At startup no edit job can be running: a row still active was cut off by the restart.
91
+ * It becomes `interrupted`, keeping the name and port it was applying.
92
+ */
93
+ export function markInterruptedEdits(): number {
94
+ const db = openDb(false);
95
+ try {
96
+ const placeholders = ACTIVE_EDIT_STATUSES.map(() => '?').join(', ');
97
+ const now = Date.now();
98
+ const info = db.prepare(
99
+ `UPDATE agent_edits
100
+ SET error = COALESCE(error, 'Rev4a restarted while this step was running: ' || status),
101
+ status = 'interrupted', updated_at = ?, finished_at = ?
102
+ WHERE status IN (${placeholders})`,
103
+ ).run(now, now, ...ACTIVE_EDIT_STATUSES);
104
+ return info.changes;
105
+ } finally {
106
+ db.close();
107
+ }
108
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Editing an agent's display name and/or port range.
3
+ *
4
+ * The container is rebuilt on the image it already runs, with the new environment value
5
+ * (`AGENT_NAME`) and/or port bindings — no cold backup, because the persistent volume is
6
+ * never touched. The job is recorded in `agent_edits` and runs in the background, so a
7
+ * page reload or a Rev4a restart never loses track of it and a second edit (or a backup)
8
+ * cannot start on top of the first (lib/agent-busy.ts answers 409 while one runs).
9
+ */
10
+ import { isValidAgentId } from '@/lib/container';
11
+ import { portMappingArgs, validatePortInput } from '@/lib/agent-ports';
12
+ import { getUsedHostPorts } from '@/lib/agent-ports-server';
13
+ import { resolveRecreateImage, runDocker } from '@/lib/agent-images';
14
+ import { inspectAgentContainer, recreateAgentContainer } from '@/lib/agent-recreate';
15
+ import { agentBusyReason } from '@/lib/agent-busy';
16
+ import { waitForGatewayReady } from '@/lib/agent-readiness';
17
+ import { applyRuntimeConfig } from '@/lib/agent-setup';
18
+ import { patchRev4aProvider } from '@/app/api/gateway/sync';
19
+ import { activeEditAgentIds, insertEdit, isEditActive, latestEdit, markInterruptedEdits, updateEditRow, type AgentEditRow } from '@/lib/agent-edit-state';
20
+
21
+ const EDIT_READY_TIMEOUT_MS = 5 * 60 * 1000;
22
+
23
+ export class EditRefusedError extends Error {}
24
+
25
+ /**
26
+ * Port bindings for a range, validated with the same rules and messages as agent
27
+ * creation. `ownPorts` are the agent's own published host ports: they are excluded from
28
+ * the "already in use" check, so keeping or shifting its own block is not a conflict
29
+ * with itself — while a conflict with another agent still fails **before** the container
30
+ * is removed.
31
+ */
32
+ function parsePortArgs(portRange: string, ownPorts: number[]): string[] {
33
+ const used = getUsedHostPorts();
34
+ for (const p of ownPorts) used.delete(p);
35
+ const result = validatePortInput(portRange, used);
36
+ if (!result.valid) throw new EditRefusedError(result.error);
37
+ return portMappingArgs(result.start, result.end);
38
+ }
39
+
40
+ /** The agent's own published host ports, from its container inspect. */
41
+ function ownHostPorts(container: { HostConfig?: { PortBindings?: Record<string, { HostPort?: string }[] | null> } }): number[] {
42
+ return Object.values(container.HostConfig?.PortBindings ?? {})
43
+ .flatMap((bindings) => bindings ?? [])
44
+ .map((b) => Number(b?.HostPort))
45
+ .filter((n) => Number.isInteger(n));
46
+ }
47
+
48
+ /** Edit jobs running in this process, so a second start for the same agent is refused. */
49
+ const running = new Set<string>();
50
+
51
+ export interface EditOptions {
52
+ displayName?: string;
53
+ portRange?: string;
54
+ }
55
+
56
+ /** Start an edit. Returns once the job is recorded; the rebuild continues in the background. */
57
+ export async function startAgentEdit(agentId: string, opts: EditOptions): Promise<number> {
58
+ if (!isValidAgentId(agentId)) throw new EditRefusedError('Invalid agent id');
59
+
60
+ const displayName = opts.displayName === undefined ? undefined : (opts.displayName.trim() || null);
61
+ if (opts.displayName !== undefined && !displayName) throw new EditRefusedError('Display name must not be empty');
62
+ if (!displayName && !opts.portRange) throw new EditRefusedError('No fields to update');
63
+ const portRange = opts.portRange?.trim() || null;
64
+
65
+ if (running.has(agentId) || isEditActive(agentId)) throw new EditRefusedError('An edit of this agent is already running');
66
+ // Claimed synchronously, before the first await, so two requests cannot both pass.
67
+ running.add(agentId);
68
+ try {
69
+ // One source for every long operation: an update, recreate, restore, edit or backup.
70
+ const busy = await agentBusyReason(agentId);
71
+ if (busy) throw new EditRefusedError(busy);
72
+
73
+ const container = await inspectAgentContainer(agentId);
74
+ if (!container) throw new EditRefusedError(`No container found with AGENT_ID '${agentId}'`);
75
+ // A bad range is refused before any state is written, and before the container is
76
+ // removed — the only failure mode that could leave the agent without one.
77
+ const portArgs = opts.portRange ? parsePortArgs(opts.portRange, ownHostPorts(container)) : undefined;
78
+
79
+ const id = insertEdit(agentId, displayName ?? null, portRange);
80
+ void runEdit(id, agentId, container, displayName ?? null, portArgs).finally(() => running.delete(agentId));
81
+ return id;
82
+ } catch (e) {
83
+ running.delete(agentId);
84
+ throw e;
85
+ }
86
+ }
87
+
88
+ async function runEdit(id: number, agentId: string, container: Awaited<ReturnType<typeof inspectAgentContainer>>, displayName: string | null, portArgs?: string[]): Promise<void> {
89
+ try {
90
+ if (!container) throw new Error(`No container found with AGENT_ID '${agentId}'`);
91
+ const image = await resolveRecreateImage(container);
92
+
93
+ await recreateAgentContainer(agentId, container, image, {
94
+ env: displayName ? { AGENT_NAME: displayName } : undefined,
95
+ portArgs,
96
+ });
97
+
98
+ if (!(await waitForGatewayReady(agentId, EDIT_READY_TIMEOUT_MS))) {
99
+ throw new Error(`The gateway did not finish starting within ${EDIT_READY_TIMEOUT_MS / 60_000} minutes`);
100
+ }
101
+ await applyRuntimeConfig(agentId);
102
+ try {
103
+ patchRev4aProvider(agentId);
104
+ } catch (e) {
105
+ console.error('[agent:edit] provider patch failed:', (e as Error).message);
106
+ }
107
+
108
+ updateEditRow(id, { status: 'done' });
109
+ } catch (e) {
110
+ updateEditRow(id, { status: 'failed', error: (e as Error)?.message ?? String(e) });
111
+ }
112
+ }
113
+
114
+ export interface AgentEditView {
115
+ id: number;
116
+ agentId: string;
117
+ status: AgentEditRow['status'];
118
+ displayName: string | null;
119
+ portRange: string | null;
120
+ error: string | null;
121
+ startedAtMs: number;
122
+ finishedAtMs: number | null;
123
+ }
124
+
125
+ /** The latest edit of an agent, so the panel can resume after a reload. */
126
+ export function agentEditView(agentId: string): AgentEditView | null {
127
+ const row = latestEdit(agentId);
128
+ if (!row) return null;
129
+ return {
130
+ id: row.id,
131
+ agentId: row.agent_id,
132
+ status: row.status,
133
+ displayName: row.display_name,
134
+ portRange: row.port_range,
135
+ error: row.error,
136
+ startedAtMs: row.started_at,
137
+ finishedAtMs: row.finished_at,
138
+ };
139
+ }
140
+
141
+ /**
142
+ * At startup no edit job can be running: rows still active were cut off by the restart.
143
+ * They become `interrupted`; a container left stopped is started again, best-effort.
144
+ */
145
+ export async function recoverInterruptedEdits(): Promise<number> {
146
+ const ids = activeEditAgentIds();
147
+ if (ids.size === 0) return 0;
148
+ const interrupted = markInterruptedEdits();
149
+ for (const agentId of ids) {
150
+ const container = await inspectAgentContainer(agentId).catch(() => null);
151
+ const name = container?.Name?.replace(/^\//, '');
152
+ if (name && !container?.State?.Running) {
153
+ await runDocker(['start', name], { timeoutMs: 60_000 }).catch(() => {});
154
+ }
155
+ }
156
+ return interrupted;
157
+ }