@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.
- package/README.md +3 -1
- package/agent-templates/README.md +42 -22
- package/agent-templates/base-image/Dockerfile +42 -33
- package/agent-templates/base-image/entrypoint.sh +67 -12
- package/app/agents/BrowserAccessSection.tsx +510 -0
- package/app/agents/ChannelManager.tsx +19 -11
- package/app/agents/ImageDownloadBanner.tsx +53 -19
- package/app/agents/ModelSection.tsx +316 -0
- package/app/agents/PageClient.tsx +708 -167
- package/app/agents/UpdateSection.tsx +300 -0
- package/app/agents/create/PageClient.tsx +11 -49
- package/app/agents/create/page.tsx +8 -21
- package/app/api/agents/[id]/backup/route.ts +26 -69
- package/app/api/agents/[id]/channels/pairing/route.ts +3 -3
- package/app/api/agents/[id]/channels/telegram/route.ts +2 -2
- package/app/api/agents/[id]/cold-backup/route.ts +56 -0
- package/app/api/agents/[id]/devices/route.ts +126 -0
- package/app/api/agents/[id]/invite-link/route.ts +53 -0
- package/app/api/agents/[id]/lifecycle/route.ts +3 -0
- package/app/api/agents/[id]/model/route.ts +113 -0
- package/app/api/agents/[id]/open-control-ui/route.ts +58 -0
- package/app/api/agents/[id]/recreate/route.ts +33 -187
- package/app/api/agents/[id]/restart/route.ts +5 -0
- package/app/api/agents/[id]/restore/route.ts +40 -70
- package/app/api/agents/[id]/route.ts +38 -169
- package/app/api/agents/[id]/update/rollback/route.ts +30 -0
- package/app/api/agents/[id]/update/route.ts +50 -0
- package/app/api/agents/activity-summary/route.ts +67 -0
- package/app/api/agents/create/route.ts +91 -145
- package/app/api/agents/devices-summary/route.ts +37 -0
- package/app/api/agents/download-image/route.ts +16 -9
- package/app/api/agents/image-status/route.ts +31 -111
- package/app/api/agents/models-summary/route.ts +163 -0
- package/app/api/agents/route.ts +25 -49
- package/app/api/agents/token/route.ts +33 -10
- package/app/api/assistant/route.ts +37 -16
- package/app/api/gateway/agent/route.ts +37 -6
- package/app/api/gateway/provider/balance/route.ts +5 -2
- package/app/api/gateway/provider/keys.ts +13 -1
- package/app/api/gateway/provider/route.ts +43 -12
- package/app/api/gateway/sync.ts +335 -76
- package/app/api/models/route.ts +28 -34
- package/app/api/provider/auth.ts +65 -0
- package/app/api/provider/upstream.ts +9 -2
- package/app/api/provider/v1/chat/completions/route.ts +22 -16
- package/app/api/provider/v1/models/route.ts +26 -133
- package/app/api/setup/agent-image/route.ts +14 -42
- package/app/components/DashboardToolbar.tsx +1 -1
- package/app/components/PulseChat.tsx +25 -39
- package/app/components/ui/RemoveButton.tsx +46 -0
- package/app/components/ui/Select.tsx +3 -2
- package/app/components/ui/index.ts +1 -0
- package/app/credentials/PageClient.tsx +2 -2
- package/app/gateway/PageClient.tsx +253 -674
- package/app/globals.css +8 -0
- package/app/lib/models-context.tsx +43 -7
- package/app/wizard/useWizard.ts +6 -1
- package/bin/rev4a.js +116 -50
- package/daemon.js +6 -6
- package/docs/ARCHITECTURE.md +110 -12
- package/docs/FRONTEND-ARCHITECTURE.md +31 -2
- package/docs/REV4A.md +93 -33
- package/docs/dev/API-REFERENCE.md +723 -178
- package/docs/dev/DATABASE.md +96 -0
- package/docs/dev/GATEWAY.md +250 -93
- package/docs/dev/PROVIDERS.md +26 -13
- package/docs/rag/DATA-FRESHNESS.md +59 -28
- package/docs/rag/GLOSSARY.md +27 -16
- package/docs/rag/REV4A-OVERVIEW.md +37 -25
- package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -8
- package/instrumentation.ts +52 -1
- package/lib/agent-busy.ts +21 -0
- package/lib/agent-devices.ts +361 -0
- package/lib/agent-edit-state.ts +108 -0
- package/lib/agent-edit.ts +157 -0
- package/lib/agent-images.ts +375 -0
- package/lib/agent-ports-server.ts +27 -0
- package/lib/agent-ports.ts +68 -0
- package/lib/agent-readiness.ts +110 -0
- package/lib/agent-recreate-state.ts +108 -0
- package/lib/agent-recreate.ts +305 -0
- package/lib/agent-restore-state.ts +107 -0
- package/lib/agent-restore.ts +135 -0
- package/lib/agent-setup.ts +66 -17
- package/lib/agent-update-state.ts +122 -0
- package/lib/agent-update.ts +448 -0
- package/lib/agent-versions.json +14 -0
- package/lib/agent-versions.ts +80 -0
- package/lib/buildAgentImage.ts +88 -290
- package/lib/channelManager.ts +153 -64
- package/lib/cold-backup.ts +354 -0
- package/lib/container-file.ts +27 -0
- package/lib/credentials/delivery.ts +3 -3
- package/lib/db-bootstrap.mjs +76 -0
- package/lib/docker-utils.ts +3 -3
- package/lib/model-catalogue.ts +140 -27
- package/lib/provider-balance.ts +33 -12
- package/lib/rev4a-paths.ts +0 -21
- package/model-pricing.json +118 -110
- package/models.config.json +27 -12
- package/package.json +1 -1
- package/app/api/gateway/route.ts +0 -191
|
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
|
|
|
2
2
|
import { execSync } from 'child_process';
|
|
3
3
|
import { resolveAgentedContainer, isValidAgentId } from '@/lib/container';
|
|
4
4
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
5
|
+
import { agentBusyReason } from '@/lib/agent-busy';
|
|
5
6
|
|
|
6
7
|
export const dynamic = 'force-dynamic';
|
|
7
8
|
|
|
@@ -23,6 +24,10 @@ export async function POST(request: Request,
|
|
|
23
24
|
return NextResponse.json({ error: `No container found with AGENT_ID '${id}'` }, { status: 404 });
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
// A restart during an update, recreate, restore, edit or backup would cut it in half.
|
|
28
|
+
const busy = await agentBusyReason(id);
|
|
29
|
+
if (busy) return NextResponse.json({ error: busy }, { status: 409 });
|
|
30
|
+
|
|
26
31
|
execSync(`docker restart ${containerName}`, { timeout: 30000 });
|
|
27
32
|
|
|
28
33
|
return NextResponse.json({ success: true });
|
|
@@ -1,81 +1,51 @@
|
|
|
1
|
-
import { NextResponse } from 'next/server';
|
|
2
|
-
import { execSync } from 'child_process';
|
|
3
|
-
import { resolveAgentedContainer, isValidAgentId } from '@/lib/container';
|
|
4
|
-
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
5
|
-
|
|
6
|
-
export const dynamic = 'force-dynamic';
|
|
7
|
-
|
|
8
|
-
const BACKUP_VOLUME = 'rev4a-backups';
|
|
9
|
-
|
|
10
1
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
2
|
+
* Restore one agent's volume from a backup archive (lib/agent-restore.ts).
|
|
3
|
+
*
|
|
4
|
+
* POST ?file=… start: the container is stopped, the volume cleared and the archive
|
|
5
|
+
* extracted (up to 30 minutes), then the container is started again even
|
|
6
|
+
* when the extract failed. 202 once the job has started; the steps
|
|
7
|
+
* continue in the background and are recorded in `agent_restores`.
|
|
8
|
+
* GET `{ restore }`: the running or last job; null when none.
|
|
9
|
+
*
|
|
10
|
+
* While it runs, anything else that would touch the agent answers 409 (lib/agent-busy.ts).
|
|
14
11
|
*/
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
try {
|
|
20
|
-
const { id } = await params;
|
|
21
|
-
if (!id) return NextResponse.json({ error: 'Agent name required' }, { status: 400 });
|
|
22
|
-
if (!isValidAgentId(id)) return NextResponse.json({ error: 'Invalid agent id' }, { status: 400 });
|
|
23
|
-
|
|
24
|
-
const url = new URL(request.url);
|
|
25
|
-
const file = url.searchParams.get('file');
|
|
26
|
-
if (!file) return NextResponse.json({ error: 'file query param required' }, { status: 400 });
|
|
27
|
-
|
|
28
|
-
// Safety: only allow restoring agent-specific files, reject path traversal
|
|
29
|
-
if (!file.startsWith(`agent-${id}-`) || !file.endsWith('.tar.gz') || file.includes('/') || file.includes('..')) {
|
|
30
|
-
return NextResponse.json({ error: 'Invalid backup file name' }, { status: 400 });
|
|
31
|
-
}
|
|
12
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
13
|
+
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
14
|
+
import { isValidAgentId } from '@/lib/container';
|
|
15
|
+
import { agentRestoreView, RestoreRefusedError, startAgentRestore } from '@/lib/agent-restore';
|
|
32
16
|
|
|
33
|
-
|
|
17
|
+
export const dynamic = 'force-dynamic';
|
|
34
18
|
|
|
35
|
-
|
|
36
|
-
const containerName = resolveAgentedContainer(id);
|
|
19
|
+
type Ctx = { params: Promise<{ id: string }> };
|
|
37
20
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
).trim();
|
|
43
|
-
if (check !== 'ok') {
|
|
44
|
-
return NextResponse.json({ error: `Backup file '${file}' not found` }, { status: 404 });
|
|
45
|
-
}
|
|
21
|
+
async function agentId(ctx: Ctx): Promise<string | NextResponse> {
|
|
22
|
+
const { id } = await ctx.params;
|
|
23
|
+
return isValidAgentId(id) ? id : NextResponse.json({ error: 'Invalid agent id' }, { status: 400 });
|
|
24
|
+
}
|
|
46
25
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
26
|
+
export async function POST(request: NextRequest, ctx: Ctx): Promise<NextResponse> {
|
|
27
|
+
const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
|
|
28
|
+
const id = await agentId(ctx);
|
|
29
|
+
if (id instanceof NextResponse) return id;
|
|
51
30
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
// dotfiles/dotdirs like .openclaw, .config, .local — the .[!.]* glob is
|
|
55
|
-
// required, otherwise the restore only merges on top of stale state), then
|
|
56
|
-
// extract the backup. Always restart the container afterwards, even on failure.
|
|
57
|
-
try {
|
|
58
|
-
execSync(
|
|
59
|
-
`docker run --rm ` +
|
|
60
|
-
`-v ${volume}:/target ` +
|
|
61
|
-
`-v ${BACKUP_VOLUME}:/backup ` +
|
|
62
|
-
`alpine sh -c 'rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null; tar xzf /backup/${file} -C /target'`,
|
|
63
|
-
{ timeout: 30000, stdio: 'pipe' },
|
|
64
|
-
);
|
|
65
|
-
} finally {
|
|
66
|
-
// Restart the container regardless of extract outcome so it never stays down.
|
|
67
|
-
if (containerName) {
|
|
68
|
-
try {
|
|
69
|
-
execSync(`docker start ${containerName}`, { timeout: 15000, stdio: 'pipe' });
|
|
70
|
-
} catch {
|
|
71
|
-
// container start failure is surfaced separately; nothing else to do here
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
31
|
+
const file = new URL(request.url).searchParams.get('file');
|
|
32
|
+
if (!file) return NextResponse.json({ error: 'file query param required' }, { status: 400 });
|
|
75
33
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return NextResponse.json({
|
|
34
|
+
try {
|
|
35
|
+
const rowId = await startAgentRestore(id, file);
|
|
36
|
+
return NextResponse.json({ started: true, id: rowId }, { status: 202 });
|
|
37
|
+
} catch (e) {
|
|
38
|
+
const message = (e as Error).message;
|
|
39
|
+
const status = e instanceof RestoreRefusedError
|
|
40
|
+
? (/Invalid backup file name|Invalid agent id/.test(message) ? 400 : /not found/.test(message) ? 404 : 409)
|
|
41
|
+
: 500;
|
|
42
|
+
return NextResponse.json({ error: (e as Error).message }, { status });
|
|
79
43
|
}
|
|
80
44
|
}
|
|
81
45
|
|
|
46
|
+
export async function GET(request: NextRequest, ctx: Ctx): Promise<NextResponse> {
|
|
47
|
+
const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
|
|
48
|
+
const id = await agentId(ctx);
|
|
49
|
+
if (id instanceof NextResponse) return id;
|
|
50
|
+
return NextResponse.json({ restore: agentRestoreView(id) }, { headers: { 'Cache-Control': 'no-store' } });
|
|
51
|
+
}
|
|
@@ -3,8 +3,8 @@ import { execSync } from 'child_process';
|
|
|
3
3
|
import * as fs from 'fs';
|
|
4
4
|
import * as path from 'path';
|
|
5
5
|
import { resolveAgentedContainer, isValidAgentId } from '@/lib/container';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { agentBusyReason } from '@/lib/agent-busy';
|
|
7
|
+
import { agentEditView, EditRefusedError, startAgentEdit } from '@/lib/agent-edit';
|
|
8
8
|
import { DB_FILE } from '@/lib/rev4a-paths';
|
|
9
9
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
10
10
|
|
|
@@ -29,6 +29,9 @@ export async function DELETE(request: Request,
|
|
|
29
29
|
return NextResponse.json({ success: false, error: 'Invalid agent id' }, { status: 400 });
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
const busy = await agentBusyReason(id);
|
|
33
|
+
if (busy) return NextResponse.json({ error: busy }, { status: 409 });
|
|
34
|
+
|
|
32
35
|
// Only allow deletion of containers that have a matching AGENT_ID label
|
|
33
36
|
const containerName = resolveAgentedContainer(id);
|
|
34
37
|
if (!containerName) {
|
|
@@ -86,31 +89,14 @@ export async function DELETE(request: Request,
|
|
|
86
89
|
}
|
|
87
90
|
}
|
|
88
91
|
|
|
89
|
-
function waitForContainerReady(name: string, timeoutMs = 60000): boolean {
|
|
90
|
-
const start = Date.now();
|
|
91
|
-
while (Date.now() - start < timeoutMs) {
|
|
92
|
-
try {
|
|
93
|
-
execSync(
|
|
94
|
-
`docker exec ${name} sh -c 'curl -sf http://127.0.0.1:3000/health >/dev/null 2>&1 || wget -qO- http://127.0.0.1:3000/health >/dev/null 2>&1'`,
|
|
95
|
-
{ timeout: 5000 },
|
|
96
|
-
);
|
|
97
|
-
return true;
|
|
98
|
-
} catch { /* not ready */ }
|
|
99
|
-
execSync('sleep 2', { timeout: 3000 });
|
|
100
|
-
}
|
|
101
|
-
return false;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function shellQuote(value: string): string {
|
|
105
|
-
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
92
|
/**
|
|
109
93
|
* PATCH /api/agents/[id]
|
|
110
94
|
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
95
|
+
* Edit an agent's display name and/or port range. The container is rebuilt on the image
|
|
96
|
+
* it already runs — **no backup**, the persistent volume is never touched — and the job
|
|
97
|
+
* runs in the background (`lib/agent-edit.ts`): `202` once it has started, then
|
|
98
|
+
* `GET /api/agents/[id]` reports the running or last edit so the panel can resume.
|
|
99
|
+
* While it runs, anything else that would touch the agent answers `409`.
|
|
114
100
|
*/
|
|
115
101
|
export async function PATCH(
|
|
116
102
|
request: Request,
|
|
@@ -129,154 +115,37 @@ export async function PATCH(
|
|
|
129
115
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
130
116
|
}
|
|
131
117
|
|
|
132
|
-
const { displayName, portRange }
|
|
133
|
-
|
|
134
|
-
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
if (displayName !== undefined && (!displayName || displayName.trim().length < 1)) {
|
|
138
|
-
return NextResponse.json({ error: 'Display name must not be empty' }, { status: 400 });
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Resolve the container
|
|
142
|
-
const containerName = resolveAgentedContainer(id);
|
|
143
|
-
if (!containerName) {
|
|
144
|
-
return NextResponse.json({ error: `No container found with AGENT_ID '${id}'` }, { status: 404 });
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Inspect existing container
|
|
148
|
-
let inspect: string;
|
|
149
|
-
try {
|
|
150
|
-
inspect = execSync(`docker inspect ${containerName}`, { timeout: 10000, encoding: 'utf-8' }).trim();
|
|
151
|
-
} catch {
|
|
152
|
-
return NextResponse.json({ error: `Container '${containerName}' not found` }, { status: 404 });
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const info = JSON.parse(inspect);
|
|
156
|
-
const [container] = info;
|
|
157
|
-
const image = container.Config?.Image || 'openclaw-agent-base:latest';
|
|
158
|
-
const currentEnv: string[] = container.Config?.Env || [];
|
|
159
|
-
|
|
160
|
-
// Update AGENT_NAME in env vars
|
|
161
|
-
let env = [...currentEnv];
|
|
162
|
-
if (displayName) {
|
|
163
|
-
const idx = env.findIndex((e: string) => e.startsWith('AGENT_NAME='));
|
|
164
|
-
const newEntry = `AGENT_NAME=${displayName.trim()}`;
|
|
165
|
-
if (idx >= 0) {
|
|
166
|
-
env[idx] = newEntry;
|
|
167
|
-
} else {
|
|
168
|
-
env.push(newEntry);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Keep only relevant env vars
|
|
173
|
-
const keepPrefixes = ['AGENT_', 'MODEL_', 'OPENCLAW_', 'TZ='];
|
|
174
|
-
const envOpts = env
|
|
175
|
-
.filter((e: string) => keepPrefixes.some((p) => e.startsWith(p)))
|
|
176
|
-
.map((e: string) => `-e ${shellQuote(e)}`)
|
|
177
|
-
.join(' ');
|
|
178
|
-
|
|
179
|
-
// Labels
|
|
180
|
-
const labels = container.Config?.Labels || {};
|
|
181
|
-
const keepLabelKeys = ['AGENT_ID', 'traefik.', 'description', 'maintainer'];
|
|
182
|
-
const labelOpts = Object.entries(labels)
|
|
183
|
-
.filter(([k]) => keepLabelKeys.some((p) => k === p || k.startsWith(p)))
|
|
184
|
-
.map(([k, v]) => `-l ${shellQuote(`${k}=${v}`)}`)
|
|
185
|
-
.join(' ');
|
|
186
|
-
|
|
187
|
-
// Network
|
|
188
|
-
const networks = container.NetworkSettings?.Networks || {};
|
|
189
|
-
const network = Object.keys(networks)[0];
|
|
190
|
-
if (!network) {
|
|
191
|
-
return NextResponse.json({ error: 'Cannot determine network' }, { status: 500 });
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Port mappings
|
|
195
|
-
const portBindings = (container.HostConfig?.PortBindings || {}) as Record<string, { HostPort: string }[]>;
|
|
196
|
-
let portOpts = Object.entries(portBindings)
|
|
197
|
-
.map(([containerPort, bindings]) => {
|
|
198
|
-
const hostPort = bindings[0]?.HostPort;
|
|
199
|
-
if (!hostPort) return '';
|
|
200
|
-
const proto = containerPort.includes('/udp') ? '/udp' : '';
|
|
201
|
-
return `-p ${hostPort}:${containerPort.replace(/\/.*$/, '')}${proto}`;
|
|
202
|
-
})
|
|
203
|
-
.filter(Boolean)
|
|
204
|
-
.join(' ');
|
|
205
|
-
|
|
206
|
-
// If portRange is provided, replace the port mapping
|
|
207
|
-
if (portRange) {
|
|
208
|
-
const parts = portRange.trim().split('-');
|
|
209
|
-
const start = parseInt(parts[0], 10);
|
|
210
|
-
const end = parts[1] ? parseInt(parts[1], 10) : start;
|
|
211
|
-
if (isNaN(start) || isNaN(end) || start < 1 || end > 65535 || end < start) {
|
|
212
|
-
return NextResponse.json({ error: 'Invalid port range' }, { status: 400 });
|
|
213
|
-
}
|
|
214
|
-
if (end === start) {
|
|
215
|
-
portOpts = `-p ${start}:3000`;
|
|
216
|
-
} else {
|
|
217
|
-
portOpts = `-p ${start}-${end}:3000-${3000 + (end - start)}`;
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const volumeName = `agent-${id}-data`;
|
|
222
|
-
|
|
223
|
-
// Stop & remove old container (keep volume)
|
|
224
|
-
try {
|
|
225
|
-
execSync(`docker rm -f ${containerName}`, { timeout: 15000 });
|
|
226
|
-
} catch (rmErr) {
|
|
227
|
-
return NextResponse.json(
|
|
228
|
-
{ error: `Failed to remove old container: ${(rmErr as Error).message}` },
|
|
229
|
-
{ status: 500 },
|
|
230
|
-
);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Run new container with same volume
|
|
234
|
-
const cmd = [
|
|
235
|
-
`docker run -d`,
|
|
236
|
-
`--name "${id}"`,
|
|
237
|
-
`--network "${network}"`,
|
|
238
|
-
`--restart unless-stopped`,
|
|
239
|
-
`--add-host host.docker.internal:host-gateway`,
|
|
240
|
-
`-v ${volumeName}:/root`,
|
|
241
|
-
...getMountFlags(),
|
|
242
|
-
labelOpts,
|
|
243
|
-
envOpts,
|
|
244
|
-
portOpts,
|
|
245
|
-
`"${image}"`,
|
|
246
|
-
].filter(Boolean).join(' \\\n ');
|
|
247
|
-
|
|
248
|
-
execSync(cmd, { timeout: 60000, encoding: 'utf-8' });
|
|
249
|
-
|
|
250
|
-
// Wait for healthy
|
|
251
|
-
const ready = waitForContainerReady(id);
|
|
252
|
-
if (!ready) {
|
|
253
|
-
return NextResponse.json(
|
|
254
|
-
{ error: 'Container started but gateway did not become healthy within 60s.' },
|
|
255
|
-
{ status: 500 },
|
|
256
|
-
);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// Apply runtime config
|
|
260
|
-
try {
|
|
261
|
-
applyRuntimeConfig(id);
|
|
262
|
-
const providerConfig = buildRev4aProviderConfig();
|
|
263
|
-
const providerJson = JSON.stringify({ models: { providers: { rev4a: providerConfig } } });
|
|
264
|
-
execSync(
|
|
265
|
-
`docker exec -i ${id} sh -c 'openclaw config patch --stdin'`,
|
|
266
|
-
{ timeout: 15000, stdio: 'pipe', input: providerJson },
|
|
267
|
-
);
|
|
268
|
-
// No gateway restart: `config patch` reports "Change will apply without
|
|
269
|
-
// restarting the gateway" for a models-only patch, and the Gateway watches
|
|
270
|
-
// openclaw.json with gateway.reload defaulting to hybrid.
|
|
271
|
-
} catch { /* non-fatal */ }
|
|
272
|
-
|
|
273
|
-
return NextResponse.json({
|
|
274
|
-
success: true,
|
|
275
|
-
displayName: displayName?.trim() || null,
|
|
276
|
-
});
|
|
118
|
+
const rowId = await startAgentEdit(id, { displayName: body.displayName, portRange: body.portRange });
|
|
119
|
+
return NextResponse.json({ started: true, id: rowId }, { status: 202 });
|
|
277
120
|
} catch (e: unknown) {
|
|
121
|
+
if (e instanceof EditRefusedError) {
|
|
122
|
+
const message = e.message;
|
|
123
|
+
// The shared port validator's messages (lib/agent-ports.ts) are validation errors:
|
|
124
|
+
// they answer 400, like the create route. Everything else is a busy refusal (409).
|
|
125
|
+
const status = /Invalid agent id|Display name must not be empty|No fields to update|Invalid format|Ports must be between|End port must be greater|reserved for Rev4a|already in use|Use a number/.test(message)
|
|
126
|
+
? 400
|
|
127
|
+
: 409;
|
|
128
|
+
return NextResponse.json({ error: message }, { status });
|
|
129
|
+
}
|
|
278
130
|
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
|
|
279
131
|
}
|
|
280
132
|
}
|
|
281
133
|
|
|
134
|
+
/**
|
|
135
|
+
* GET /api/agents/[id]
|
|
136
|
+
*
|
|
137
|
+
* The running or last edit of an agent (`null` when it was never edited): what the edit
|
|
138
|
+
* panel polls to resume after a reload.
|
|
139
|
+
*/
|
|
140
|
+
export async function GET(
|
|
141
|
+
request: Request,
|
|
142
|
+
{ params }: { params: Promise<{ id: string }> },
|
|
143
|
+
): Promise<NextResponse> {
|
|
144
|
+
const denied = await requireAuthJWT(request); if (denied) return denied as any;
|
|
145
|
+
const { id } = await params;
|
|
146
|
+
if (!isValidAgentId(id)) return NextResponse.json({ error: 'Invalid agent id' }, { status: 400 });
|
|
147
|
+
return NextResponse.json({ edit: agentEditView(id) }, { headers: { 'Cache-Control': 'no-store' } });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
282
151
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/agents/[id]/update/rollback
|
|
3
|
+
*
|
|
4
|
+
* Roll back the agent's latest update (lib/agent-update.ts): its pre-update backup is
|
|
5
|
+
* restored and the container recreated on the version it ran before. Everything the
|
|
6
|
+
* agent did after that backup is lost. 202 with the update; the steps continue in the
|
|
7
|
+
* background, `GET /api/agents/[id]/update` follows them.
|
|
8
|
+
*/
|
|
9
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
10
|
+
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
11
|
+
import { isValidAgentId } from '@/lib/container';
|
|
12
|
+
import { UpdateRefusedError, agentUpdateView, startAgentRollback } from '@/lib/agent-update';
|
|
13
|
+
|
|
14
|
+
export const dynamic = 'force-dynamic';
|
|
15
|
+
|
|
16
|
+
export async function POST(
|
|
17
|
+
request: NextRequest,
|
|
18
|
+
{ params }: { params: Promise<{ id: string }> },
|
|
19
|
+
): Promise<NextResponse> {
|
|
20
|
+
const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
|
|
21
|
+
const { id } = await params;
|
|
22
|
+
if (!isValidAgentId(id)) return NextResponse.json({ error: 'Invalid agent id' }, { status: 400 });
|
|
23
|
+
try {
|
|
24
|
+
await startAgentRollback(id);
|
|
25
|
+
return NextResponse.json({ update: await agentUpdateView(id) }, { status: 202 });
|
|
26
|
+
} catch (e) {
|
|
27
|
+
const status = e instanceof UpdateRefusedError ? 409 : 500;
|
|
28
|
+
return NextResponse.json({ error: (e as Error).message }, { status });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update one agent to a newer supported OpenClaw version (lib/agent-update.ts).
|
|
3
|
+
*
|
|
4
|
+
* GET `{ currentVersion, candidate, update }`: the version the agent runs, the
|
|
5
|
+
* `{ fromVersion, toVersion }` an update would do (null when none is newer),
|
|
6
|
+
* and the latest update with its status and live backup progress.
|
|
7
|
+
* POST `{ version? }` — start an update, by default to the newest supported version
|
|
8
|
+
* downloaded. 202 with the new update; the steps continue in the background.
|
|
9
|
+
*/
|
|
10
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
11
|
+
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
12
|
+
import { isValidAgentId } from '@/lib/container';
|
|
13
|
+
import { isSupportedVersion } from '@/lib/agent-versions';
|
|
14
|
+
import { UpdateRefusedError, agentUpdateView, startAgentUpdate, updateCandidate } from '@/lib/agent-update';
|
|
15
|
+
|
|
16
|
+
export const dynamic = 'force-dynamic';
|
|
17
|
+
|
|
18
|
+
type Ctx = { params: Promise<{ id: string }> };
|
|
19
|
+
|
|
20
|
+
export async function GET(request: NextRequest, { params }: Ctx): Promise<NextResponse> {
|
|
21
|
+
const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
|
|
22
|
+
const { id } = await params;
|
|
23
|
+
if (!isValidAgentId(id)) return NextResponse.json({ error: 'Invalid agent id' }, { status: 400 });
|
|
24
|
+
try {
|
|
25
|
+
const { currentVersion, candidate } = await updateCandidate(id);
|
|
26
|
+
return NextResponse.json(
|
|
27
|
+
{ currentVersion, candidate, update: await agentUpdateView(id) },
|
|
28
|
+
{ headers: { 'Cache-Control': 'no-store' } },
|
|
29
|
+
);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
return NextResponse.json({ error: (e as Error).message }, { status: 503 });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function POST(request: NextRequest, { params }: Ctx): Promise<NextResponse> {
|
|
36
|
+
const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
|
|
37
|
+
const { id } = await params;
|
|
38
|
+
if (!isValidAgentId(id)) return NextResponse.json({ error: 'Invalid agent id' }, { status: 400 });
|
|
39
|
+
const body = (await request.json().catch(() => null)) as { version?: unknown } | null;
|
|
40
|
+
if (body?.version !== undefined && body.version !== null && !isSupportedVersion(body.version)) {
|
|
41
|
+
return NextResponse.json({ error: 'version must be an OpenClaw version this Rev4a supports' }, { status: 400 });
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
await startAgentUpdate(id, (body?.version as string | undefined) ?? undefined);
|
|
45
|
+
return NextResponse.json({ update: await agentUpdateView(id) }, { status: 202 });
|
|
46
|
+
} catch (e) {
|
|
47
|
+
const status = e instanceof UpdateRefusedError ? 409 : 500;
|
|
48
|
+
return NextResponse.json({ error: (e as Error).message }, { status });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/agents/activity-summary
|
|
3
|
+
*
|
|
4
|
+
* The long operation each agent is in the middle of, if any — the chip on the agent
|
|
5
|
+
* cards: `BACKUP nn%`, `RESTORING`, `RECREATING`, `UPDATING`.
|
|
6
|
+
*
|
|
7
|
+
* One Docker list call plus, per running cold backup, its status read. Idle agents are
|
|
8
|
+
* not listed at all, so an idle system costs one call.
|
|
9
|
+
*/
|
|
10
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
11
|
+
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
12
|
+
import { coldBackupStatus, listRunningColdBackups } from '@/lib/cold-backup';
|
|
13
|
+
import { activeRestoreAgentIds, latestRestore } from '@/lib/agent-restore-state';
|
|
14
|
+
import { activeRecreateAgentIds, latestRecreate } from '@/lib/agent-recreate-state';
|
|
15
|
+
import { activeUpdateAgentIds, latestUpdate } from '@/lib/agent-update-state';
|
|
16
|
+
import { activeEditAgentIds, latestEdit } from '@/lib/agent-edit-state';
|
|
17
|
+
|
|
18
|
+
export const dynamic = 'force-dynamic';
|
|
19
|
+
|
|
20
|
+
export type AgentActivityKind = 'backup' | 'restore' | 'recreate' | 'update' | 'edit';
|
|
21
|
+
|
|
22
|
+
interface AgentActivity {
|
|
23
|
+
agentId: string;
|
|
24
|
+
kind: AgentActivityKind;
|
|
25
|
+
file?: string | null;
|
|
26
|
+
version?: string | null;
|
|
27
|
+
percent?: number | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
31
|
+
const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
|
|
32
|
+
|
|
33
|
+
const byAgent = new Map<string, AgentActivity>();
|
|
34
|
+
|
|
35
|
+
// Update and recreate first: a running cold backup then overrides them, because while
|
|
36
|
+
// its helper archives the volume that is the phase actually in progress.
|
|
37
|
+
for (const agentId of activeUpdateAgentIds()) {
|
|
38
|
+
const row = latestUpdate(agentId);
|
|
39
|
+
if (row && (row.status === 'migrating' || row.status === 'verifying' || row.status === 'rolling_back')) {
|
|
40
|
+
byAgent.set(agentId, { agentId, kind: 'update', version: row.to_version });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const agentId of activeRecreateAgentIds()) {
|
|
44
|
+
const row = latestRecreate(agentId);
|
|
45
|
+
if (row && row.status === 'recreating') {
|
|
46
|
+
byAgent.set(agentId, { agentId, kind: 'recreate', version: row.image });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const agentId of activeRestoreAgentIds()) {
|
|
50
|
+
byAgent.set(agentId, { agentId, kind: 'restore', file: latestRestore(agentId)?.file ?? null });
|
|
51
|
+
}
|
|
52
|
+
for (const agentId of activeEditAgentIds()) {
|
|
53
|
+
const row = latestEdit(agentId);
|
|
54
|
+
if (row && row.status === 'rebuilding') {
|
|
55
|
+
byAgent.set(agentId, { agentId, kind: 'edit', file: row.display_name });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const backupIds = await listRunningColdBackups().catch(() => []);
|
|
60
|
+
await Promise.all(backupIds.map(async (agentId) => {
|
|
61
|
+
const job = await coldBackupStatus(agentId).catch(() => null);
|
|
62
|
+
if (!job || job.status !== 'running') return;
|
|
63
|
+
byAgent.set(agentId, { agentId, kind: 'backup', file: job.file, percent: job.percent });
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
return NextResponse.json({ agents: [...byAgent.values()] }, { headers: { 'Cache-Control': 'no-store' } });
|
|
67
|
+
}
|