@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.
- package/README.md +1 -0
- 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 +4 -1
- package/app/agents/PageClient.tsx +629 -167
- package/app/agents/UpdateSection.tsx +300 -0
- package/app/agents/create/PageClient.tsx +11 -49
- package/app/agents/create/page.tsx +14 -28
- 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]/open-control-ui/route.ts +58 -0
- package/app/api/agents/[id]/recreate/route.ts +33 -163
- 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 -150
- 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 +38 -92
- 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/route.ts +25 -49
- package/app/api/agents/token/route.ts +33 -10
- package/app/api/assistant/route.ts +2 -2
- package/app/api/gateway/agent/route.ts +14 -0
- package/app/api/gateway/provider/balance/route.ts +5 -2
- package/app/api/gateway/sync.ts +97 -14
- package/app/api/setup/agent-image/route.ts +14 -42
- package/app/api/version/route.ts +2 -1
- package/app/components/DashboardToolbar.tsx +1 -1
- package/app/gateway/PageClient.tsx +27 -32
- package/bin/rev4a.js +43 -41
- package/daemon.js +6 -6
- package/docs/ARCHITECTURE.md +107 -9
- package/docs/FRONTEND-ARCHITECTURE.md +8 -1
- package/docs/REV4A.md +54 -17
- package/docs/dev/API-REFERENCE.md +573 -105
- package/docs/dev/DATABASE.md +96 -0
- package/docs/dev/GATEWAY.md +21 -6
- package/docs/rag/DATA-FRESHNESS.md +6 -4
- package/docs/rag/GLOSSARY.md +12 -3
- package/docs/rag/REV4A-OVERVIEW.md +18 -5
- package/docs/rag/WHAT-I-CAN-ANSWER.md +6 -2
- package/instrumentation.ts +43 -0
- 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 +149 -0
- package/lib/agent-images.ts +375 -0
- package/lib/agent-ports-server.ts +78 -0
- package/lib/agent-ports.ts +91 -0
- package/lib/agent-recreate-state.ts +108 -0
- package/lib/agent-recreate.ts +359 -0
- package/lib/agent-restore-state.ts +107 -0
- package/lib/agent-restore.ts +141 -0
- package/lib/agent-setup.ts +66 -17
- package/lib/agent-update-state.ts +122 -0
- package/lib/agent-update.ts +456 -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 +149 -102
- package/lib/cold-backup.ts +354 -0
- package/lib/credentials/delivery.ts +3 -3
- package/lib/db-bootstrap.mjs +76 -0
- package/lib/docker-utils.ts +3 -3
- package/lib/provider-balance.ts +33 -12
- package/package.json +1 -1
|
@@ -25,8 +25,8 @@ const PAGE_CONTEXT: Record<string, PageContext> = {
|
|
|
25
25
|
'/agents': {
|
|
26
26
|
title: 'Agents',
|
|
27
27
|
description: 'List of all Docker containers that run OpenClaw agents. Shows status (running/exited/paused), image, ports, IP, and creation time. Includes a top banner for the agent base image status: yellow warning when outdated or missing, animated progress bar when downloading from registry.',
|
|
28
|
-
actions: ['Filter by status: all, running, or exited', 'Click an agent to see its details (environment variables,
|
|
29
|
-
hints: 'The status bullet tells you at a glance if an agent is running (green), exited (red), or paused (yellow). The base image banner shows when
|
|
28
|
+
actions: ['Filter by status: all, running, or exited', 'Click an agent to see its details (environment variables, backups, Telegram channels, and the Model section that sets its primary model and fallbacks)', 'Create a new agent using the "Create Agent" button', 'Download the agent base image when the banner shows it missing or outdated'],
|
|
29
|
+
hints: 'The status bullet tells you at a glance if an agent is running (green), exited (red), or paused (yellow). The base image banner shows when a newer supported version is published on the registry — downloading makes it available to create and update agents on; it changes no agent by itself.',
|
|
30
30
|
},
|
|
31
31
|
'/agents/create': {
|
|
32
32
|
title: 'Create Agent',
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* { "containerName": "openclaw-atlas", "model": "rev4a/deepseek-flash", "fallbacks": ["rev4a/deepseek-v4-pro"] }
|
|
9
9
|
*/
|
|
10
10
|
import { NextRequest, NextResponse } from 'next/server';
|
|
11
|
+
import { agentBusyReason } from '@/lib/agent-busy';
|
|
11
12
|
import { execSync } from 'child_process';
|
|
12
13
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
13
14
|
|
|
@@ -90,6 +91,19 @@ export async function PUT(request: NextRequest) {
|
|
|
90
91
|
return NextResponse.json({ status: 'error', error: 'model is required' }, { status: 400 });
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
// Refuse while a long operation is running on this agent: the write would race the
|
|
95
|
+
// recreate/migration that is replacing the container underneath it.
|
|
96
|
+
try {
|
|
97
|
+
const agentId = execSync(
|
|
98
|
+
`docker inspect --format '{{index .Config.Labels "AGENT_ID"}}' ${safeContainer}`,
|
|
99
|
+
{ encoding: 'utf-8', timeout: 5000 },
|
|
100
|
+
).trim();
|
|
101
|
+
if (agentId) {
|
|
102
|
+
const busy = await agentBusyReason(agentId);
|
|
103
|
+
if (busy) return NextResponse.json({ status: 'error', error: busy }, { status: 409 });
|
|
104
|
+
}
|
|
105
|
+
} catch { /* no container or no label: the rest of the route reports it */ }
|
|
106
|
+
|
|
93
107
|
// Read current config
|
|
94
108
|
const raw = readFileFromContainer(safeContainer, '/root/.openclaw/openclaw.json');
|
|
95
109
|
if (!raw) {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Supported providers: deepseek, openrouter, glm
|
|
8
8
|
*/
|
|
9
9
|
import { NextRequest, NextResponse } from 'next/server';
|
|
10
|
-
import { fetchProviderBalance, type BalanceProvider } from '@/lib/provider-balance';
|
|
10
|
+
import { fetchProviderBalance, BalanceUnsupportedError, type BalanceProvider } from '@/lib/provider-balance';
|
|
11
11
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
12
12
|
|
|
13
13
|
export const dynamic = 'force-dynamic';
|
|
@@ -33,6 +33,9 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
33
33
|
return NextResponse.json({ provider, ...result });
|
|
34
34
|
} catch (e: unknown) {
|
|
35
35
|
const message = e instanceof Error ? e.message : String(e);
|
|
36
|
-
|
|
36
|
+
// A permanent "no balance API for this account" is reported distinctly, so
|
|
37
|
+
// the UI can hide the badge instead of offering a retry that never works.
|
|
38
|
+
const reason = e instanceof BalanceUnsupportedError ? 'no-balance-api' : undefined;
|
|
39
|
+
return NextResponse.json({ provider, error: message, ...(reason && { reason }) }, { status: 502 });
|
|
37
40
|
}
|
|
38
41
|
}
|
package/app/api/gateway/sync.ts
CHANGED
|
@@ -19,6 +19,11 @@ import { readProviderKeys } from './provider/keys';
|
|
|
19
19
|
import { bundledCatalogueStatus, loadOfferedModels, type ModelConfigEntry } from '@/lib/model-catalogue';
|
|
20
20
|
import { singleFileFromTar } from '@/lib/container-file';
|
|
21
21
|
import { detectProviderGatewayUrl } from '@/lib/docker-utils';
|
|
22
|
+
import { isSupportedVersion, modelInputFor, parseOpenClawVersion } from '@/lib/agent-versions';
|
|
23
|
+
import { activeUpdateAgentIds } from '@/lib/agent-update-state';
|
|
24
|
+
import { activeRecreateAgentIds } from '@/lib/agent-recreate-state';
|
|
25
|
+
import { activeRestoreAgentIds } from '@/lib/agent-restore-state';
|
|
26
|
+
import { activeEditAgentIds } from '@/lib/agent-edit-state';
|
|
22
27
|
|
|
23
28
|
const PROVIDER_GATEWAY_BASE_URL = detectProviderGatewayUrl();
|
|
24
29
|
|
|
@@ -31,19 +36,57 @@ const PROVIDER_GATEWAY_BASE_URL = detectProviderGatewayUrl();
|
|
|
31
36
|
* OpenClaw `input` array. Filters out values not in the allowed set
|
|
32
37
|
* (text | image | video | audio).
|
|
33
38
|
*/
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Model `input` values an agent's OpenClaw version accepts. The list is per version
|
|
41
|
+
* (lib/agent-versions.json): 7.1-2 knows only `text | image`, and one value it does not
|
|
42
|
+
* know invalidates the whole generated catalogue ("model catalog load issue").
|
|
43
|
+
*/
|
|
44
|
+
function modalityToInput(modality: string, version?: string | null): string[] {
|
|
45
|
+
const allowed = new Set(modelInputFor(version));
|
|
37
46
|
const arrowIdx = modality.indexOf('->');
|
|
38
47
|
const left = arrowIdx >= 0 ? modality.slice(0, arrowIdx) : modality;
|
|
39
|
-
return left.split('+').map((s) => s.trim()).filter((s) =>
|
|
48
|
+
return left.split('+').map((s) => s.trim()).filter((s) => allowed.has(s));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Where the image entrypoint records the version an agent last started on. */
|
|
52
|
+
const LAST_VERSION_PATH = '/root/.openclaw/.last-version';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The OpenClaw version a container runs, for its model `input` list. Best-effort and
|
|
56
|
+
* synchronous: the image tag when it names a version, else the `.last-version` file the
|
|
57
|
+
* entrypoint writes (through `docker exec` when running, `docker cp` when stopped).
|
|
58
|
+
* Null when nothing says — the caller then falls back to the newest version's list.
|
|
59
|
+
*/
|
|
60
|
+
function containerVersionSync(name: string): string | null {
|
|
61
|
+
try {
|
|
62
|
+
const image = execFileSync('docker', ['inspect', '--format', '{{.Config.Image}}', name], {
|
|
63
|
+
timeout: 5000, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
64
|
+
}).trim();
|
|
65
|
+
const fromTag = parseOpenClawVersion(image);
|
|
66
|
+
if (fromTag && isSupportedVersion(fromTag)) return fromTag;
|
|
67
|
+
} catch { /* no such container or no image: fall through */ }
|
|
68
|
+
try {
|
|
69
|
+
const raw = execFileSync('docker', ['exec', name, 'cat', LAST_VERSION_PATH], {
|
|
70
|
+
timeout: 5000, maxBuffer: 64 * 1024, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
71
|
+
});
|
|
72
|
+
const running = parseOpenClawVersion(raw);
|
|
73
|
+
if (running && isSupportedVersion(running)) return running;
|
|
74
|
+
} catch { /* not running or no file: try the stopped path */ }
|
|
75
|
+
try {
|
|
76
|
+
const file = singleFileFromTar(execFileSync('docker', ['cp', `${name}:${LAST_VERSION_PATH}`, '-'], {
|
|
77
|
+
timeout: 5000, maxBuffer: 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
|
|
78
|
+
}));
|
|
79
|
+
const stopped = parseOpenClawVersion(file.body.toString('utf-8'));
|
|
80
|
+
if (stopped && isSupportedVersion(stopped)) return stopped;
|
|
81
|
+
} catch { /* nothing to read */ }
|
|
82
|
+
return null;
|
|
40
83
|
}
|
|
41
84
|
|
|
42
|
-
function getActiveRev4aModels(): { id: string; name: string; input?: string[] }[] {
|
|
85
|
+
function getActiveRev4aModels(version?: string | null): { id: string; name: string; input?: string[] }[] {
|
|
43
86
|
return loadOfferedModels()
|
|
44
87
|
.map((m) => {
|
|
45
88
|
const entry: { id: string; name: string; input?: string[] } = { id: m.id, name: m.name };
|
|
46
|
-
if (m.modality) entry.input = modalityToInput(m.modality);
|
|
89
|
+
if (m.modality) entry.input = modalityToInput(m.modality, version);
|
|
47
90
|
return entry;
|
|
48
91
|
});
|
|
49
92
|
}
|
|
@@ -56,7 +99,23 @@ function firstLine(e: unknown): string {
|
|
|
56
99
|
}
|
|
57
100
|
|
|
58
101
|
/** A container carrying an AGENT_ID label, and whether it is running. */
|
|
59
|
-
interface AgentContainer { name: string; running: boolean }
|
|
102
|
+
interface AgentContainer { name: string; running: boolean; agentId: string }
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* AGENT_IDs whose volume is being archived by a cold backup (lib/cold-backup.ts). Their
|
|
106
|
+
* config must not change while tar reads the volume; the next sync catches them up.
|
|
107
|
+
*/
|
|
108
|
+
function agentsUnderBackup(): Set<string> {
|
|
109
|
+
try {
|
|
110
|
+
const raw = execSync(
|
|
111
|
+
`docker ps --filter "label=rev4a.backup.agent" --format '{{.Label "rev4a.backup.agent"}}'`,
|
|
112
|
+
{ timeout: 5000, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },
|
|
113
|
+
).trim();
|
|
114
|
+
return new Set(raw ? raw.split('\n').map((s) => s.trim()).filter(Boolean) : []);
|
|
115
|
+
} catch {
|
|
116
|
+
return new Set();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
60
119
|
|
|
61
120
|
/**
|
|
62
121
|
* Every container carrying an AGENT_ID label, running or stopped.
|
|
@@ -74,7 +133,7 @@ function getAgentContainers(): AgentContainer[] {
|
|
|
74
133
|
let raw: string;
|
|
75
134
|
try {
|
|
76
135
|
raw = execSync(
|
|
77
|
-
`docker ps -a --filter "label=AGENT_ID" --format '{{.Names}}\t{{.State}}'`,
|
|
136
|
+
`docker ps -a --filter "label=AGENT_ID" --format '{{.Names}}\t{{.State}}\t{{.Label "AGENT_ID"}}'`,
|
|
78
137
|
{ timeout: 5000, maxBuffer: 64 * 1024, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },
|
|
79
138
|
).trim();
|
|
80
139
|
} catch (e) {
|
|
@@ -83,8 +142,8 @@ function getAgentContainers(): AgentContainer[] {
|
|
|
83
142
|
if (!raw) return [];
|
|
84
143
|
return raw.split('\n')
|
|
85
144
|
.map((line) => {
|
|
86
|
-
const [name, state] = line.split('\t');
|
|
87
|
-
return { name: (name ?? '').trim(), running: (state ?? '').trim() === 'running' };
|
|
145
|
+
const [name, state, agentId] = line.split('\t');
|
|
146
|
+
return { name: (name ?? '').trim(), running: (state ?? '').trim() === 'running', agentId: (agentId ?? '').trim() };
|
|
88
147
|
})
|
|
89
148
|
.filter((c) => c.name);
|
|
90
149
|
}
|
|
@@ -184,10 +243,10 @@ function writeContainerJson(container: AgentContainer, remotePath: string, data:
|
|
|
184
243
|
* Shared between the create route and syncAllAgents — single source of truth
|
|
185
244
|
* for the provider block injected into every agent container.
|
|
186
245
|
*/
|
|
187
|
-
export function buildRev4aProviderConfig(): { baseUrl: string; apiKey: string; api: string; models: { id: string; name: string; input?: string[] }[] } {
|
|
246
|
+
export function buildRev4aProviderConfig(version?: string | null): { baseUrl: string; apiKey: string; api: string; models: { id: string; name: string; input?: string[] }[] } {
|
|
188
247
|
const providerKeys = readProviderKeys();
|
|
189
248
|
const rev4aApiKey = providerKeys['rev4a'] || '';
|
|
190
|
-
const activeModels = getActiveRev4aModels();
|
|
249
|
+
const activeModels = getActiveRev4aModels(version);
|
|
191
250
|
return {
|
|
192
251
|
baseUrl: PROVIDER_GATEWAY_BASE_URL,
|
|
193
252
|
apiKey: rev4aApiKey,
|
|
@@ -211,7 +270,8 @@ export function patchRev4aProvider(container: string): void {
|
|
|
211
270
|
if (bundledCatalogueStatus() === 'unavailable') {
|
|
212
271
|
throw new Error('models.config.json could not be read; models.providers.rev4a left unchanged');
|
|
213
272
|
}
|
|
214
|
-
|
|
273
|
+
// The agent's own version: an older release rejects `input` values it does not know.
|
|
274
|
+
const input = JSON.stringify({ models: { providers: { rev4a: buildRev4aProviderConfig(containerVersionSync(container)) } } });
|
|
215
275
|
execFileSync('docker', ['exec', '-i', container, 'sh', '-c', 'openclaw config patch --stdin'], {
|
|
216
276
|
timeout: 15000,
|
|
217
277
|
stdio: 'pipe',
|
|
@@ -293,9 +353,32 @@ export function syncAllAgents(): SyncOutcome {
|
|
|
293
353
|
}
|
|
294
354
|
|
|
295
355
|
outcome.total = containers.length;
|
|
356
|
+
const underBackup = agentsUnderBackup();
|
|
357
|
+
// Any long operation replaces or rewrites the container/config: skip the agent.
|
|
358
|
+
let busyAgents = new Set<string>();
|
|
359
|
+
try {
|
|
360
|
+
busyAgents = new Set([
|
|
361
|
+
...activeUpdateAgentIds(),
|
|
362
|
+
...activeRecreateAgentIds(),
|
|
363
|
+
...activeRestoreAgentIds(),
|
|
364
|
+
...activeEditAgentIds(),
|
|
365
|
+
]);
|
|
366
|
+
} catch {
|
|
367
|
+
// No operation state readable: nothing to skip on that account.
|
|
368
|
+
}
|
|
296
369
|
for (const container of containers) {
|
|
370
|
+
if (busyAgents.has(container.agentId)) {
|
|
371
|
+
outcome.failed.push({ container: container.name, error: 'an operation of this agent is running; it applies Rev4a config itself' });
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (underBackup.has(container.agentId)) {
|
|
375
|
+
outcome.failed.push({ container: container.name, error: 'a cold backup of this agent is running; sync again once it finishes' });
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
297
378
|
try {
|
|
298
|
-
|
|
379
|
+
// Per agent: the `input` list follows the version that agent runs, so a 7.x
|
|
380
|
+
// release never receives a modality value it would reject.
|
|
381
|
+
syncContainer(container, buildRev4aProviderConfig(containerVersionSync(container.name)));
|
|
299
382
|
outcome.synced.push(container.name);
|
|
300
383
|
if (!container.running) outcome.stopped.push(container.name);
|
|
301
384
|
} catch (e: unknown) {
|
|
@@ -1,60 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* GET /api/setup/agent-image
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* Returns
|
|
4
|
+
* Whether a supported OpenClaw version of the agent base image is downloaded.
|
|
5
|
+
* Returns `{ exists, version }`, the version being the newest one present.
|
|
6
6
|
*
|
|
7
7
|
* POST /api/setup/agent-image
|
|
8
8
|
*
|
|
9
|
-
* Downloads
|
|
10
|
-
*
|
|
9
|
+
* Downloads the newest supported version the registry publishes, and streams
|
|
10
|
+
* progress via SSE so the setup wizard can show a live progress bar. Nothing is
|
|
11
|
+
* downloaded when that version is already here.
|
|
11
12
|
*
|
|
12
13
|
* Only one download at a time — returns 409 if already running.
|
|
13
|
-
* Cleans up the docker process if the client disconnects.
|
|
14
14
|
*/
|
|
15
15
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
16
16
|
import { downloadAgentImage, getIsDownloading } from '@/lib/buildAgentImage';
|
|
17
|
-
import { execSync } from 'child_process';
|
|
18
17
|
import { requireAuthIfConfigured } from '@/lib/rev4a-auth';
|
|
18
|
+
import { dockerAvailable } from '@/lib/docker-socket';
|
|
19
|
+
import { listLocalAgentImages, newestLocalSupportedVersion } from '@/lib/agent-images';
|
|
19
20
|
|
|
20
21
|
export const dynamic = 'force-dynamic';
|
|
21
22
|
|
|
22
|
-
function dockerOk(): boolean {
|
|
23
|
-
try {
|
|
24
|
-
execSync('docker version --format "{{.Client.Version}}" 2>/dev/null', {
|
|
25
|
-
encoding: 'utf-8',
|
|
26
|
-
timeout: 2000,
|
|
27
|
-
});
|
|
28
|
-
return true;
|
|
29
|
-
} catch {
|
|
30
|
-
return false;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
23
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
35
24
|
const denied = await requireAuthIfConfigured(request);
|
|
36
25
|
if (denied) return denied;
|
|
37
|
-
if (!
|
|
38
|
-
return NextResponse.json({ exists: false });
|
|
26
|
+
if (!dockerAvailable()) {
|
|
27
|
+
return NextResponse.json({ exists: false, version: null });
|
|
39
28
|
}
|
|
40
29
|
try {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
exec(
|
|
44
|
-
'docker image inspect openclaw-agent-base:latest --format "{{.ID}}" 2>/dev/null',
|
|
45
|
-
{ timeout: 5000 },
|
|
46
|
-
(err, stdout) => {
|
|
47
|
-
if (err) return reject(err);
|
|
48
|
-
resolve(stdout.trim());
|
|
49
|
-
},
|
|
50
|
-
);
|
|
51
|
-
});
|
|
52
|
-
if (result) {
|
|
53
|
-
return NextResponse.json({ exists: true, imageId: result });
|
|
54
|
-
}
|
|
55
|
-
return NextResponse.json({ exists: false });
|
|
30
|
+
const version = newestLocalSupportedVersion(await listLocalAgentImages());
|
|
31
|
+
return NextResponse.json({ exists: version !== null, version });
|
|
56
32
|
} catch {
|
|
57
|
-
return NextResponse.json({ exists: false });
|
|
33
|
+
return NextResponse.json({ exists: false, version: null });
|
|
58
34
|
}
|
|
59
35
|
}
|
|
60
36
|
|
|
@@ -81,11 +57,7 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|
|
81
57
|
const ctrl = controller as ReadableStreamDefaultController & { signal?: AbortSignal };
|
|
82
58
|
|
|
83
59
|
try {
|
|
84
|
-
await downloadAgentImage({
|
|
85
|
-
force: false, // setup: skip if already exists
|
|
86
|
-
onEvent: send,
|
|
87
|
-
signal: ctrl.signal,
|
|
88
|
-
});
|
|
60
|
+
await downloadAgentImage({ onEvent: send, signal: ctrl.signal });
|
|
89
61
|
} catch (e: unknown) {
|
|
90
62
|
send(JSON.stringify({
|
|
91
63
|
type: 'error',
|
|
@@ -94,7 +66,7 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|
|
94
66
|
}
|
|
95
67
|
controller.close();
|
|
96
68
|
},
|
|
97
|
-
cancel() { /* downloadAgentImage
|
|
69
|
+
cancel() { /* downloadAgentImage stops the docker process through the signal */ },
|
|
98
70
|
});
|
|
99
71
|
|
|
100
72
|
return new Response(stream, {
|
package/app/api/version/route.ts
CHANGED
|
@@ -15,7 +15,8 @@ export const dynamic = 'force-dynamic';
|
|
|
15
15
|
* GET /api/version
|
|
16
16
|
*
|
|
17
17
|
* Returns the installed Rev4a version from package.json.
|
|
18
|
-
*
|
|
18
|
+
*
|
|
19
|
+
* Auth: the rev4a_token cookie or Authorization: Bearer <REV4A_TOKEN>.
|
|
19
20
|
*/
|
|
20
21
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
21
22
|
const denied = await requireAuthJWT(request); if (denied) return denied as any;
|
|
@@ -32,7 +32,7 @@ export default function DashboardToolbar({ filter, onChange, agents = ['all'] }:
|
|
|
32
32
|
className={`agent-btn ${filter.showOnlyActive ? 'active' : ''}`}
|
|
33
33
|
onClick={() => onChange({ showOnlyActive: !filter.showOnlyActive })}
|
|
34
34
|
>
|
|
35
|
-
|
|
35
|
+
active only
|
|
36
36
|
</button>
|
|
37
37
|
|
|
38
38
|
<button
|
|
@@ -88,7 +88,7 @@ export default function GatewayPageClient() {
|
|
|
88
88
|
const [modelSearch, setModelSearch] = useState<Record<string, string>>({});
|
|
89
89
|
|
|
90
90
|
// Balance fetch state (per provider)
|
|
91
|
-
const [balances, setBalances] = useState<Record<string, { balance: string; label?: string; loading?: boolean; error?: string } | null>>({});
|
|
91
|
+
const [balances, setBalances] = useState<Record<string, { balance: string; label?: string; loading?: boolean; error?: string; unsupported?: boolean } | null>>({});
|
|
92
92
|
const [fetchingBalance, setFetchingBalance] = useState<Record<string, boolean>>({});
|
|
93
93
|
|
|
94
94
|
// Accordion state — which provider cards are expanded
|
|
@@ -135,6 +135,9 @@ export default function GatewayPageClient() {
|
|
|
135
135
|
const bJson = await bRes.json();
|
|
136
136
|
if (bRes.ok && bJson.balance) {
|
|
137
137
|
setBalances((prev) => ({ ...prev, [bp]: { balance: bJson.balance, label: bJson.label } }));
|
|
138
|
+
} else if (bJson.reason === 'no-balance-api') {
|
|
139
|
+
// Permanent: this provider has no balance API for the account — hide the badge.
|
|
140
|
+
setBalances((prev) => ({ ...prev, [bp]: { balance: '', unsupported: true } }));
|
|
138
141
|
} else {
|
|
139
142
|
setBalances((prev) => ({ ...prev, [bp]: { balance: '—', error: bJson.error || 'Failed' } }));
|
|
140
143
|
}
|
|
@@ -198,6 +201,8 @@ export default function GatewayPageClient() {
|
|
|
198
201
|
const json = await res.json();
|
|
199
202
|
if (res.ok && json.balance) {
|
|
200
203
|
setBalances((prev) => ({ ...prev, [provider]: { balance: json.balance, label: json.label } }));
|
|
204
|
+
} else if (json.reason === 'no-balance-api') {
|
|
205
|
+
setBalances((prev) => ({ ...prev, [provider]: { balance: '', unsupported: true } }));
|
|
201
206
|
} else {
|
|
202
207
|
setBalances((prev) => ({ ...prev, [provider]: { balance: '—', error: json.error || 'Failed' } }));
|
|
203
208
|
}
|
|
@@ -519,43 +524,33 @@ export default function GatewayPageClient() {
|
|
|
519
524
|
<span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--text-dim)' }}>
|
|
520
525
|
{p.models.length} models
|
|
521
526
|
</span>
|
|
522
|
-
{/* Balance badge — auto-loaded for supported providers with configured key
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
style={{
|
|
543
|
-
fontSize: 11,
|
|
544
|
-
color: 'var(--text-dim)',
|
|
545
|
-
opacity: 0.5,
|
|
546
|
-
cursor: 'pointer',
|
|
547
|
-
}}
|
|
548
|
-
>
|
|
549
|
-
{fetchingBalance[p.provider] ? '…' : '…'}
|
|
550
|
-
</span>
|
|
551
|
-
)
|
|
527
|
+
{/* Balance badge — auto-loaded for supported providers with configured key.
|
|
528
|
+
Same squared chip in every state: "..." while loading, "—" (dimmed, tooltip) on error.
|
|
529
|
+
Hidden entirely when the provider has no balance API for the account (permanent). */}
|
|
530
|
+
{p.configured && ['deepseek','openrouter','glm'].includes(p.provider) && !balances[p.provider]?.unsupported && (
|
|
531
|
+
<button
|
|
532
|
+
type="button"
|
|
533
|
+
title={balances[p.provider]?.error || balances[p.provider]?.label || ''}
|
|
534
|
+
onClick={(e) => { e.stopPropagation(); fetchBalance(p.provider); }}
|
|
535
|
+
style={{
|
|
536
|
+
fontSize: 11, fontWeight: 600,
|
|
537
|
+
color: 'var(--green)',
|
|
538
|
+
background: 'rgba(var(--green-rgb,34,197,94),0.1)',
|
|
539
|
+
padding: '2px 8px', borderRadius: 0,
|
|
540
|
+
border: '1px solid rgba(var(--green-rgb,34,197,94),0.2)',
|
|
541
|
+
cursor: 'pointer',
|
|
542
|
+
opacity: balances[p.provider]?.error ? 0.5 : 1,
|
|
543
|
+
}}
|
|
544
|
+
>
|
|
545
|
+
{balances[p.provider] && !balances[p.provider]?.error ? balances[p.provider]!.balance : balances[p.provider]?.error ? '—' : '...'}
|
|
546
|
+
</button>
|
|
552
547
|
)}
|
|
553
548
|
</div>
|
|
554
549
|
|
|
555
550
|
{/* Accordion body — expandable */}
|
|
556
551
|
{isExpanded && (
|
|
557
552
|
<div style={{ padding: '0 18px 16px 18px', display: 'grid', gap: 14, borderTop: '1px solid var(--border)', paddingTop: 14 }}>
|
|
558
|
-
{/* API Key input
|
|
553
|
+
{/* API Key input with an inline show/hide toggle */}
|
|
559
554
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
560
555
|
<PasswordInput
|
|
561
556
|
value={apiKeyInputs[p.provider] ?? ''}
|
package/bin/rev4a.js
CHANGED
|
@@ -41,8 +41,8 @@ const DATA_DIR = join(CONFIG_DIR, 'data');
|
|
|
41
41
|
const ENV_SOURCE = join(CONFIG_DIR, '.env');
|
|
42
42
|
const ENV_LINK = join(ROOT, '.env');
|
|
43
43
|
const DOCKERFILE = join(ROOT, 'agent-templates', 'base-image', 'Dockerfile');
|
|
44
|
-
|
|
45
|
-
const
|
|
44
|
+
// Supported OpenClaw versions and image repositories, shared with the server.
|
|
45
|
+
const AGENT_VERSIONS = require(join(ROOT, 'lib', 'agent-versions.json'));
|
|
46
46
|
const DOCKER_NETWORK = 'rev4a-network';
|
|
47
47
|
// How long `serve` waits for the Docker daemon before starting without it. Like
|
|
48
48
|
// REV4A_DATA_DIR it is read from the real process environment (a systemd
|
|
@@ -204,55 +204,57 @@ function ensureDocker() {
|
|
|
204
204
|
function ensureAgentImage() {
|
|
205
205
|
if (!hasCmd('docker')) return; // Docker not available — skip, warn later
|
|
206
206
|
|
|
207
|
-
//
|
|
207
|
+
// The newest OpenClaw version this release supports, kept under its version tag.
|
|
208
|
+
// `:latest` is never used: an agent's version decides its data schema.
|
|
209
|
+
const version = AGENT_VERSIONS.supported[0].version;
|
|
210
|
+
const localRef = `${AGENT_VERSIONS.localRepository}:${version}`;
|
|
211
|
+
const registry = (process.env.REV4A_AGENT_IMAGE_REGISTRY || '').trim() || AGENT_VERSIONS.registryRepository;
|
|
212
|
+
const remoteRef = `${registry}:${version}`;
|
|
213
|
+
|
|
208
214
|
try {
|
|
209
|
-
const r = execSync(
|
|
210
|
-
`docker image inspect ${AGENT_IMAGE} --format '{{.Id}}'`,
|
|
211
|
-
{ stdio: 'pipe', timeout: 10_000 },
|
|
212
|
-
);
|
|
215
|
+
const r = execSync(`docker image inspect ${localRef} --format '{{.Id}}'`, { stdio: 'pipe', timeout: 10_000 });
|
|
213
216
|
if (r.toString().trim()) {
|
|
214
|
-
log('IMAGE', `Agent base image ${
|
|
217
|
+
log('IMAGE', `Agent base image ${localRef} already exists`);
|
|
215
218
|
return;
|
|
216
219
|
}
|
|
217
220
|
} catch {
|
|
218
|
-
//
|
|
221
|
+
// Not downloaded yet
|
|
219
222
|
}
|
|
220
223
|
|
|
221
|
-
log('IMAGE', `Pulling agent base image
|
|
222
|
-
|
|
224
|
+
log('IMAGE', `Pulling agent base image ${remoteRef}…`);
|
|
223
225
|
try {
|
|
224
|
-
execSync(`docker pull ${
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
timeout: 10_000,
|
|
232
|
-
});
|
|
233
|
-
log('IMAGE', `Agent base image pulled and tagged as ${AGENT_IMAGE}`);
|
|
226
|
+
execSync(`docker pull ${remoteRef}`, { stdio: 'inherit', timeout: 1_800_000 });
|
|
227
|
+
execSync(`docker tag ${remoteRef} ${localRef}`, { stdio: 'inherit', timeout: 10_000 });
|
|
228
|
+
try {
|
|
229
|
+
execSync(`docker rmi ${remoteRef}`, { stdio: 'pipe', timeout: 30_000 }); // untag; the layers stay
|
|
230
|
+
} catch { /* keeping the registry reference is harmless */ }
|
|
231
|
+
log('IMAGE', `Agent base image pulled and tagged as ${localRef}`);
|
|
232
|
+
return;
|
|
234
233
|
} catch {
|
|
235
|
-
log('IMAGE',
|
|
236
|
-
|
|
237
|
-
if (!existsSync(DOCKERFILE)) {
|
|
238
|
-
log('IMAGE', `⚠ Agent base image Dockerfile not found at ${DOCKERFILE}`);
|
|
239
|
-
log('IMAGE', ' Agent creation will be unavailable until the image is available.');
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
234
|
+
log('IMAGE', '⚠ Pull from registry failed');
|
|
235
|
+
}
|
|
242
236
|
|
|
243
|
-
|
|
244
|
-
|
|
237
|
+
// A local build is only the same image when this Dockerfile builds that version.
|
|
238
|
+
const dockerfileVersion = existsSync(DOCKERFILE)
|
|
239
|
+
? (/^ARG OPENCLAW_VERSION=(\S+)/m.exec(readFileSync(DOCKERFILE, 'utf-8')) || [])[1] || null
|
|
240
|
+
: null;
|
|
241
|
+
if (dockerfileVersion !== version) {
|
|
242
|
+
log('IMAGE', ` The Dockerfile here builds ${dockerfileVersion || 'no version'}, not ${version}: no local build.`);
|
|
243
|
+
log('IMAGE', ' Agent creation will be unavailable until the image is downloaded from the Agents page.');
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
245
246
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
})
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
247
|
+
log('IMAGE', `Building agent base image ${localRef} (this will take a few minutes)…`);
|
|
248
|
+
log('IMAGE', ' → pulling node:24-bookworm-slim + installing OpenClaw + tooling');
|
|
249
|
+
try {
|
|
250
|
+
execSync(
|
|
251
|
+
`docker build --build-arg OPENCLAW_VERSION=${version} -t ${localRef} -f "${DOCKERFILE}" "${join(DOCKERFILE, '..')}"`,
|
|
252
|
+
{ stdio: 'inherit', timeout: 3_600_000 },
|
|
253
|
+
);
|
|
254
|
+
log('IMAGE', 'Agent base image built successfully');
|
|
255
|
+
} catch {
|
|
256
|
+
log('IMAGE', '⚠ Agent base image build failed. Agent creation will be unavailable.');
|
|
257
|
+
log('IMAGE', ` Pull it manually: docker pull ${remoteRef} && docker tag ${remoteRef} ${localRef}`);
|
|
256
258
|
}
|
|
257
259
|
}
|
|
258
260
|
|
|
@@ -639,7 +641,7 @@ EXAMPLES:
|
|
|
639
641
|
On first run, rev4a serve automatically:
|
|
640
642
|
• Generates .env (JWT secret + API token)
|
|
641
643
|
• Installs Docker if missing (requires root)
|
|
642
|
-
•
|
|
644
|
+
• Downloads the agent base image for the newest supported OpenClaw version
|
|
643
645
|
• Builds the Next.js app if no build is found
|
|
644
646
|
• Starts Next.js, daemon, and terminal WebSocket as a single process group
|
|
645
647
|
• Cleanly stops all services on Ctrl+C / SIGTERM
|
package/daemon.js
CHANGED
|
@@ -668,12 +668,12 @@ function pollSessions() {
|
|
|
668
668
|
// ── Timeout Detection ────────────────────────────────────────────────────
|
|
669
669
|
// Find sub-agents that were 'working' in the last snapshot but are no longer
|
|
670
670
|
// present in the current output and have been missing for more than 10 minutes.
|
|
671
|
-
//
|
|
672
|
-
const TIMEOUT_THRESHOLD_MS = 10 * 60 * 1000; // 10
|
|
671
|
+
// These are recorded as 'timeout' in the events table.
|
|
672
|
+
const TIMEOUT_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
|
|
673
673
|
for (const [session_id, snap] of knownSessions) {
|
|
674
674
|
// Only sub-agents (contain 'subagent' in the key)
|
|
675
675
|
if (!session_id.includes('subagent')) continue;
|
|
676
|
-
//
|
|
676
|
+
// Only if they were working in the last poll
|
|
677
677
|
if (snap.status !== 'working') continue;
|
|
678
678
|
// Only if not present in the current poll
|
|
679
679
|
if (currentIds.has(session_id)) continue;
|
|
@@ -694,10 +694,10 @@ function pollSessions() {
|
|
|
694
694
|
data: JSON.stringify({
|
|
695
695
|
missing_for_ms: missingFor,
|
|
696
696
|
last_status: snap.status,
|
|
697
|
-
message: `
|
|
697
|
+
message: `Subagent missing for ${Math.round(missingFor / 60000)} min without completing`,
|
|
698
698
|
}),
|
|
699
699
|
});
|
|
700
|
-
log(`[TIMEOUT] ${session_id} missing
|
|
700
|
+
log(`[TIMEOUT] ${session_id} missing for ${Math.round(missingFor / 60000)} min`);
|
|
701
701
|
|
|
702
702
|
// Notify Michele via openclaw message (only if openclaw is available)
|
|
703
703
|
try {
|
|
@@ -708,7 +708,7 @@ function pollSessions() {
|
|
|
708
708
|
'message', 'send',
|
|
709
709
|
'--account', 'ops',
|
|
710
710
|
'--target', '297086793',
|
|
711
|
-
'--text', `⚠️ Rev4a: agent timeout\n\`${session_id.slice(-36)}\`\nMissing for ${Math.round(missingFor / 60000)} min
|
|
711
|
+
'--text', `⚠️ Rev4a: agent timeout\n\`${session_id.slice(-36)}\`\nMissing for ${Math.round(missingFor / 60000)} min without completing.`,
|
|
712
712
|
], { encoding: 'utf8', timeout: 10_000, killSignal: 'SIGKILL' });
|
|
713
713
|
} catch (e) {
|
|
714
714
|
log(`[TIMEOUT] Telegram notification failed: ${e.message}`);
|