@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.
- 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/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 +32 -88
- 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/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 +95 -9
- package/docs/FRONTEND-ARCHITECTURE.md +8 -1
- package/docs/REV4A.md +54 -17
- package/docs/dev/API-REFERENCE.md +554 -100
- 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 +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-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 +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
|
@@ -1,135 +1,55 @@
|
|
|
1
|
-
import { type NextRequest } from "next/server";
|
|
2
1
|
/**
|
|
3
2
|
* GET /api/agents/image-status
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Which OpenClaw versions of the agent base image are downloaded, whether the
|
|
5
|
+
* registry publishes a newer supported version, and whether a download is running.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* downloading in-memory flag free
|
|
13
|
-
* error local free
|
|
14
|
-
* exists one `docker image inspect` ~0.5s
|
|
15
|
-
* needsUpdate `docker manifest inspect` over the net ~3.4s
|
|
16
|
-
*
|
|
17
|
-
* The route used to compute all of them, synchronously, on every poll — a 2s
|
|
18
|
-
* interval driving a ~5s blocking call, so the whole server sat frozen for as
|
|
19
|
-
* long as the agents page stayed open. It also ran `docker version` three times
|
|
20
|
-
* per request (once inside each helper) just to ask whether Docker was up, at
|
|
21
|
-
* ~2s a call, and inspected the same image three times over.
|
|
22
|
-
*
|
|
23
|
-
* Now: Docker liveness comes from the socket check in lib/docker-socket (a
|
|
24
|
-
* cached filesystem stat), the image is inspected once, everything is async, and
|
|
25
|
-
* the registry lookup — the only genuinely expensive part — is cached. The base
|
|
26
|
-
* image changes when a release is published, not every two seconds.
|
|
7
|
+
* ImageDownloadBanner polls this every 2 s. The local part is two Docker API calls;
|
|
8
|
+
* the registry lookup is cached for ten minutes and shared by concurrent requests
|
|
9
|
+
* (lib/agent-images.ts), so polling does not hit the network.
|
|
27
10
|
*/
|
|
28
|
-
import { NextResponse } from 'next/server';
|
|
29
|
-
import {
|
|
30
|
-
import { promisify } from 'util';
|
|
31
|
-
import { getIsDownloading } from '@/lib/buildAgentImage';
|
|
11
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
12
|
+
import { getDownloadingVersion, getIsDownloading } from '@/lib/buildAgentImage';
|
|
32
13
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
33
14
|
import { dockerAvailable } from '@/lib/docker-socket';
|
|
34
|
-
|
|
35
|
-
|
|
15
|
+
import { listLocalAgentImages, localSupportedVersions, remoteSupportedVersions, type LocalAgentImage } from '@/lib/agent-images';
|
|
16
|
+
import { compareVersions } from '@/lib/agent-versions';
|
|
36
17
|
|
|
37
18
|
export const dynamic = 'force-dynamic';
|
|
38
19
|
|
|
39
|
-
const REGISTRY_IMAGE = 'ghcr.io/flame0510/rev4a/openclaw-agent-base:latest';
|
|
40
|
-
const LOCAL_IMAGE = 'openclaw-agent-base:latest';
|
|
41
|
-
const REGISTRY_PREFIX = 'ghcr.io/flame0510/rev4a/openclaw-agent-base@';
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* How long a registry lookup is reused. Long enough that a 2s poll doesn't hit
|
|
45
|
-
* the network, short enough that a freshly published base image surfaces on its
|
|
46
|
-
* own. The cost of being stale here is a banner appearing a few minutes late.
|
|
47
|
-
*/
|
|
48
|
-
const REMOTE_TTL_MS = 10 * 60 * 1000;
|
|
49
|
-
|
|
50
|
-
let remoteCache: { digest: string | null; at: number } | null = null;
|
|
51
|
-
/** Shared across concurrent requests so a burst of polls triggers one lookup. */
|
|
52
|
-
let remoteInFlight: Promise<string | null> | null = null;
|
|
53
|
-
|
|
54
|
-
/** The local image's ID and its registry digest, from a single inspect. */
|
|
55
|
-
async function inspectLocalImage(): Promise<{ id: string; registryDigest: string | null } | null> {
|
|
56
|
-
try {
|
|
57
|
-
const { stdout } = await execFileAsync(
|
|
58
|
-
'docker',
|
|
59
|
-
['image', 'inspect', LOCAL_IMAGE, '--format', '{{json .}}'],
|
|
60
|
-
{ encoding: 'utf-8', timeout: 5000 },
|
|
61
|
-
);
|
|
62
|
-
const info = JSON.parse(stdout) as { Id?: string; RepoDigests?: string[] };
|
|
63
|
-
const fromRegistry = (info.RepoDigests ?? []).find((d) => d.startsWith(REGISTRY_PREFIX));
|
|
64
|
-
return {
|
|
65
|
-
id: info.Id ?? '',
|
|
66
|
-
registryDigest: fromRegistry ? fromRegistry.split('@')[1] ?? null : null,
|
|
67
|
-
};
|
|
68
|
-
} catch {
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Manifest digest of the published image, cached for REMOTE_TTL_MS. */
|
|
74
|
-
async function getRemoteDigest(): Promise<string | null> {
|
|
75
|
-
if (remoteCache && Date.now() - remoteCache.at < REMOTE_TTL_MS) return remoteCache.digest;
|
|
76
|
-
if (remoteInFlight) return remoteInFlight;
|
|
77
|
-
|
|
78
|
-
remoteInFlight = (async () => {
|
|
79
|
-
try {
|
|
80
|
-
const { stdout } = await execFileAsync(
|
|
81
|
-
'docker',
|
|
82
|
-
['manifest', 'inspect', REGISTRY_IMAGE, '--verbose'],
|
|
83
|
-
{ encoding: 'utf-8', timeout: 15000 },
|
|
84
|
-
);
|
|
85
|
-
const manifest = JSON.parse(stdout) as { Descriptor?: { digest?: string } };
|
|
86
|
-
// The descriptor digest is what RepoDigests can be compared against;
|
|
87
|
-
// config.digest is a different hash entirely.
|
|
88
|
-
return manifest?.Descriptor?.digest ?? null;
|
|
89
|
-
} catch {
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
})();
|
|
93
|
-
|
|
94
|
-
try {
|
|
95
|
-
const digest = await remoteInFlight;
|
|
96
|
-
remoteCache = { digest, at: Date.now() };
|
|
97
|
-
return digest;
|
|
98
|
-
} finally {
|
|
99
|
-
remoteInFlight = null;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
20
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
104
21
|
const denied = await requireAuthJWT(request);
|
|
105
22
|
if (denied) return denied;
|
|
106
23
|
|
|
107
|
-
const
|
|
24
|
+
const base = { downloading: getIsDownloading(), downloadingVersion: getDownloadingVersion() };
|
|
108
25
|
|
|
109
26
|
if (!dockerAvailable()) {
|
|
110
|
-
return NextResponse.json({
|
|
27
|
+
return NextResponse.json({
|
|
28
|
+
...base, exists: false, needsUpdate: true, localVersions: [], newestLocal: null, available: null, registryReachable: false,
|
|
29
|
+
});
|
|
111
30
|
}
|
|
112
31
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
32
|
+
let images: LocalAgentImage[] = [];
|
|
33
|
+
try {
|
|
34
|
+
images = await listLocalAgentImages();
|
|
35
|
+
} catch {
|
|
36
|
+
// Docker answered nothing usable: report no local image.
|
|
116
37
|
}
|
|
38
|
+
const localVersions = localSupportedVersions(images);
|
|
39
|
+
const newestLocal = localVersions[0] ?? null;
|
|
117
40
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
let needsUpdate = false;
|
|
123
|
-
if (remoteDigest) {
|
|
124
|
-
// No registry digest locally means the image was built here and never
|
|
125
|
-
// pulled, so it can't be compared — treat it as updatable.
|
|
126
|
-
needsUpdate = local.registryDigest ? local.registryDigest !== remoteDigest : true;
|
|
127
|
-
}
|
|
41
|
+
const remote = await remoteSupportedVersions();
|
|
42
|
+
const available = remote?.find(
|
|
43
|
+
(v) => !localVersions.includes(v) && (!newestLocal || compareVersions(v, newestLocal) > 0),
|
|
44
|
+
) ?? null;
|
|
128
45
|
|
|
129
46
|
return NextResponse.json({
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
47
|
+
...base,
|
|
48
|
+
exists: newestLocal !== null,
|
|
49
|
+
needsUpdate: newestLocal === null || available !== null,
|
|
50
|
+
localVersions,
|
|
51
|
+
newestLocal,
|
|
52
|
+
available,
|
|
53
|
+
registryReachable: remote !== null,
|
|
134
54
|
});
|
|
135
55
|
}
|
package/app/api/agents/route.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
|
-
import * as fs from 'fs';
|
|
3
|
-
import * as path from 'path';
|
|
4
|
-
import { AGENTS_TOKEN_FILE } from '@/lib/rev4a-paths';
|
|
5
2
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
6
3
|
import { dockerAvailable, dockerFetch } from '@/lib/docker-socket';
|
|
4
|
+
import { containerOpenClawVersion, listLocalAgentImages, newestLocalSupportedVersion, type LocalAgentImage } from '@/lib/agent-images';
|
|
5
|
+
import { LOCAL_IMAGE_REPOSITORY, compareVersions } from '@/lib/agent-versions';
|
|
7
6
|
|
|
8
7
|
export const dynamic = 'force-dynamic';
|
|
9
8
|
|
|
@@ -21,14 +20,15 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
21
20
|
'/containers/json?all=true&filters={"label":["AGENT_ID"]}',
|
|
22
21
|
);
|
|
23
22
|
|
|
24
|
-
//
|
|
25
|
-
|
|
23
|
+
// Local agent images: each container's OpenClaw version, and the newest supported
|
|
24
|
+
// version downloaded, which decides `updateAvailable`.
|
|
25
|
+
let images: LocalAgentImage[] = [];
|
|
26
26
|
try {
|
|
27
|
-
|
|
28
|
-
currentImageId = imageInspect.Id || '';
|
|
27
|
+
images = await listLocalAgentImages();
|
|
29
28
|
} catch {
|
|
30
|
-
//
|
|
29
|
+
// No image list: versions come from inside running containers only.
|
|
31
30
|
}
|
|
31
|
+
const newestLocal = newestLocalSupportedVersion(images);
|
|
32
32
|
|
|
33
33
|
const agents = await Promise.all(
|
|
34
34
|
containers.map(async (c: any) => {
|
|
@@ -36,7 +36,6 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
36
36
|
let ip: string | null = null;
|
|
37
37
|
let created: string | null = null;
|
|
38
38
|
let env: string[] = [];
|
|
39
|
-
let authToken: string | null = null;
|
|
40
39
|
let controlPort: string | null = null;
|
|
41
40
|
let portBindings: Record<string, Array<{ HostIp: string; HostPort: string }>> | null = null;
|
|
42
41
|
|
|
@@ -59,42 +58,9 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
59
58
|
// Ports from inspect (HostConfig) — works even when container is stopped
|
|
60
59
|
portBindings = inspect.HostConfig?.PortBindings || null;
|
|
61
60
|
|
|
62
|
-
// Read auth token — always prefer the shared agents-token.json (user-managed)
|
|
63
|
-
try {
|
|
64
|
-
const tokenRaw = fs.readFileSync(
|
|
65
|
-
AGENTS_TOKEN_FILE,
|
|
66
|
-
'utf-8'
|
|
67
|
-
);
|
|
68
|
-
const tokenData = JSON.parse(tokenRaw);
|
|
69
|
-
if (tokenData.token) {
|
|
70
|
-
authToken = tokenData.token;
|
|
71
|
-
}
|
|
72
|
-
} catch {
|
|
73
|
-
// agents-token.json unavailable
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Fallback when the shared file is missing: the container's own token,
|
|
77
|
-
// taken from the inspect data already fetched above.
|
|
78
|
-
//
|
|
79
|
-
// This used to `docker exec … grep OPENCLAW_GATEWAY_TOKEN /root/….env`
|
|
80
|
-
// — a path containing a literal U+2026 ellipsis, so not a path at all.
|
|
81
|
-
// Correcting the character wouldn't have helped: agent containers have
|
|
82
|
-
// no .env file, and no file under /root/ holds that variable (verified
|
|
83
|
-
// across the production fleet). The token exists only in the container
|
|
84
|
-
// environment, which is right here in Config.Env. So the fallback spawned
|
|
85
|
-
// a blocking process per agent to read a file that cannot exist, failed
|
|
86
|
-
// silently, and left every "Open" button without its token — while the
|
|
87
|
-
// value sat in memory, already fetched.
|
|
88
|
-
if (!authToken) {
|
|
89
|
-
const fromEnv = ((inspect.Config?.Env || []) as string[])
|
|
90
|
-
.find((e) => e.startsWith('OPENCLAW_GATEWAY_TOKEN='));
|
|
91
|
-
const token = fromEnv?.slice('OPENCLAW_GATEWAY_TOKEN='.length).trim();
|
|
92
|
-
if (token && token !== 'undefined') authToken = token;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
61
|
// The host port mapped to the agent's Control UI (container port 3000).
|
|
96
|
-
//
|
|
97
|
-
//
|
|
62
|
+
// No link and no token are returned: the Open buttons go through
|
|
63
|
+
// /api/agents/[id]/open-control-ui, which builds the link server-side.
|
|
98
64
|
// Read from portBindings (works on a stopped container) with a fallback
|
|
99
65
|
// to c.Ports, which is only populated while running.
|
|
100
66
|
const port3000Binding = portBindings?.['3000/tcp'];
|
|
@@ -109,7 +75,17 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
109
75
|
// Falls back to 'custom' if not set (agent created before this feature).
|
|
110
76
|
const image: string = c.Image || '';
|
|
111
77
|
let template: string | null = null;
|
|
112
|
-
|
|
78
|
+
// Every container here carries AGENT_ID, so its version is looked up even when
|
|
79
|
+
// its image is not tagged `openclaw-agent-base` (an image left untagged, or
|
|
80
|
+
// pinned by id). The image name alone would miss those.
|
|
81
|
+
const openclawVersion = await containerOpenClawVersion(
|
|
82
|
+
{ name: (c.Names?.[0] || '').replace(/^\//, ''), imageId: c.ImageID || '', running: c.State === 'running' },
|
|
83
|
+
images,
|
|
84
|
+
);
|
|
85
|
+
const isAgentBase = image.startsWith(LOCAL_IMAGE_REPOSITORY)
|
|
86
|
+
|| images.some((img) => img.id === c.ImageID)
|
|
87
|
+
|| openclawVersion !== null;
|
|
88
|
+
if (isAgentBase) {
|
|
113
89
|
const templateLine = env.find((e: string) => e.startsWith('AGENT_TEMPLATE='));
|
|
114
90
|
template = templateLine ? templateLine.split('=').slice(1).join('=') : 'custom';
|
|
115
91
|
}
|
|
@@ -147,10 +123,10 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
147
123
|
created,
|
|
148
124
|
env,
|
|
149
125
|
controlPort,
|
|
150
|
-
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
126
|
+
openclawVersion,
|
|
127
|
+
// A newer supported OpenClaw version is downloaded. Recreate keeps the agent's
|
|
128
|
+
// version; moving to the newer one is a separate, backed-up update.
|
|
129
|
+
updateAvailable: !!(openclawVersion && newestLocal && compareVersions(newestLocal, openclawVersion) > 0),
|
|
154
130
|
};
|
|
155
131
|
}),
|
|
156
132
|
);
|
|
@@ -3,6 +3,7 @@ import * as fs from 'fs';
|
|
|
3
3
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
4
4
|
import { AGENTS_TOKEN_FILE } from '@/lib/rev4a-paths';
|
|
5
5
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
6
|
+
import { agentBusyReason } from '@/lib/agent-busy';
|
|
6
7
|
|
|
7
8
|
export const dynamic = 'force-dynamic';
|
|
8
9
|
|
|
@@ -57,37 +58,59 @@ export async function PUT(request: NextRequest): Promise<NextResponse> {
|
|
|
57
58
|
fs.writeFileSync(AGENTS_TOKEN_FILE, JSON.stringify(payload, null, 2));
|
|
58
59
|
|
|
59
60
|
let containersUpdated = 0;
|
|
60
|
-
|
|
61
|
+
/** Containers left alone because an operation is running on them. */
|
|
62
|
+
const skipped: string[] = [];
|
|
63
|
+
/** Containers that could not be updated, with the reason. */
|
|
64
|
+
const failed: { container: string; error: string }[] = [];
|
|
65
|
+
let containers: { name: string; agentId: string }[] = [];
|
|
61
66
|
|
|
62
67
|
try {
|
|
63
68
|
const raw = execSync(
|
|
64
|
-
`docker ps --filter "label=AGENT_ID" --format
|
|
69
|
+
`docker ps --filter "label=AGENT_ID" --format '{{.Names}} {{.Label "AGENT_ID"}}'`,
|
|
65
70
|
{ encoding: 'utf-8', timeout: 5000 },
|
|
66
71
|
).trim();
|
|
67
|
-
|
|
72
|
+
containers = raw
|
|
73
|
+
? raw.split('\n').map((line) => {
|
|
74
|
+
const [name, agentId] = line.trim().split(/\s+/);
|
|
75
|
+
return { name: (name ?? '').trim(), agentId: (agentId ?? '').trim() };
|
|
76
|
+
}).filter((c) => c.name)
|
|
77
|
+
: [];
|
|
68
78
|
} catch {
|
|
69
|
-
|
|
79
|
+
containers = [];
|
|
70
80
|
}
|
|
71
81
|
|
|
72
|
-
for (const
|
|
82
|
+
for (const { name, agentId } of containers) {
|
|
73
83
|
try {
|
|
84
|
+
// Restarting an agent mid-update, mid-recreate, mid-restore or mid-edit would cut
|
|
85
|
+
// the operation in half: leave it and say so.
|
|
86
|
+
if (agentId && (await agentBusyReason(agentId))) {
|
|
87
|
+
skipped.push(name);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
74
90
|
// Write token to /root/.agent-token (entrypoint reads this, overrides env var)
|
|
75
91
|
execSync(
|
|
76
|
-
`docker exec ${shellQuote(
|
|
92
|
+
`docker exec ${shellQuote(name)} sh -c 'echo ${shellQuote(token)} > /root/.agent-token'`,
|
|
77
93
|
{ encoding: 'utf-8', timeout: 10000 },
|
|
78
94
|
);
|
|
79
95
|
// Restart to pick up new token
|
|
80
|
-
execSync(`docker restart ${shellQuote(
|
|
96
|
+
execSync(`docker restart ${shellQuote(name)}`, {
|
|
81
97
|
encoding: 'utf-8',
|
|
82
98
|
timeout: 20000,
|
|
83
99
|
});
|
|
84
100
|
containersUpdated += 1;
|
|
85
|
-
} catch {
|
|
86
|
-
//
|
|
101
|
+
} catch (e) {
|
|
102
|
+
// Report the failure instead of swallowing it: a token that reached only some
|
|
103
|
+
// agents used to look like a full success.
|
|
104
|
+
failed.push({ container: name, error: (e as Error)?.message ?? String(e) });
|
|
87
105
|
}
|
|
88
106
|
}
|
|
89
107
|
|
|
90
|
-
return NextResponse.json({
|
|
108
|
+
return NextResponse.json({
|
|
109
|
+
success: failed.length === 0,
|
|
110
|
+
containersUpdated,
|
|
111
|
+
...(skipped.length ? { skipped } : {}),
|
|
112
|
+
...(failed.length ? { failed } : {}),
|
|
113
|
+
});
|
|
91
114
|
} catch (e: unknown) {
|
|
92
115
|
return NextResponse.json(
|
|
93
116
|
{ success: false, error: (e as Error).message || 'Unknown error' },
|
|
@@ -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) {
|