@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
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent base images: the OpenClaw versions downloaded here, the agents using each,
|
|
3
|
+
* the supported versions the registry publishes, and pulling one.
|
|
4
|
+
*
|
|
5
|
+
* Images are addressed by version tag, `openclaw-agent-base:<version>`, never by
|
|
6
|
+
* `:latest`. An agent's OpenClaw version decides its data schema, and a moving shared
|
|
7
|
+
* tag would move every new or recreated agent with it. Create uses the newest
|
|
8
|
+
* supported version downloaded; recreate keeps the agent's own version.
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from 'child_process';
|
|
11
|
+
import { dockerFetch } from '@/lib/docker-socket';
|
|
12
|
+
import { dockerExecNoFail } from '@/lib/docker-exec';
|
|
13
|
+
import {
|
|
14
|
+
LOCAL_IMAGE_REPOSITORY,
|
|
15
|
+
compareVersions,
|
|
16
|
+
isSupportedVersion,
|
|
17
|
+
localImageRef,
|
|
18
|
+
parseOpenClawVersion,
|
|
19
|
+
registryRepository,
|
|
20
|
+
} from '@/lib/agent-versions';
|
|
21
|
+
|
|
22
|
+
const VERSION_LABEL = 'org.opencontainers.image.version';
|
|
23
|
+
const REMOTE_TTL_MS = 10 * 60 * 1000;
|
|
24
|
+
const REGISTRY_TIMEOUT_MS = 10_000;
|
|
25
|
+
const PULL_TIMEOUT_MS = 30 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
export interface LocalAgentImage {
|
|
28
|
+
id: string;
|
|
29
|
+
tags: string[];
|
|
30
|
+
/** From the version label, else from a version tag; null for an unlabelled, untagged image. */
|
|
31
|
+
version: string | null;
|
|
32
|
+
sizeBytes: number;
|
|
33
|
+
/** AGENT_ID of every container, running or stopped, created from this image. */
|
|
34
|
+
usedBy: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface DockerImage {
|
|
38
|
+
Id: string;
|
|
39
|
+
RepoTags?: string[] | null;
|
|
40
|
+
Labels?: Record<string, string> | null;
|
|
41
|
+
Size?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface DockerContainer {
|
|
45
|
+
ImageID?: string;
|
|
46
|
+
Labels?: Record<string, string> | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const tagPrefix = `${LOCAL_IMAGE_REPOSITORY}:`;
|
|
50
|
+
|
|
51
|
+
function versionFromTags(tags: string[]): string | null {
|
|
52
|
+
for (const tag of tags) {
|
|
53
|
+
if (!tag.startsWith(tagPrefix)) continue;
|
|
54
|
+
const version = parseOpenClawVersion(tag.slice(tagPrefix.length));
|
|
55
|
+
if (version) return version;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Local `openclaw-agent-base` images, through the Docker API. */
|
|
61
|
+
export async function listLocalAgentImages(): Promise<LocalAgentImage[]> {
|
|
62
|
+
const imageFilter = encodeURIComponent(JSON.stringify({ reference: [LOCAL_IMAGE_REPOSITORY] }));
|
|
63
|
+
const agentFilter = encodeURIComponent(JSON.stringify({ label: ['AGENT_ID'] }));
|
|
64
|
+
const [images, containers] = await Promise.all([
|
|
65
|
+
dockerFetch<DockerImage[]>('GET', `/images/json?filters=${imageFilter}`),
|
|
66
|
+
dockerFetch<DockerContainer[]>('GET', `/containers/json?all=true&filters=${agentFilter}`),
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
const usedBy = new Map<string, string[]>();
|
|
70
|
+
for (const c of Array.isArray(containers) ? containers : []) {
|
|
71
|
+
const agentId = c.Labels?.AGENT_ID;
|
|
72
|
+
if (!c.ImageID || !agentId) continue;
|
|
73
|
+
usedBy.set(c.ImageID, [...(usedBy.get(c.ImageID) ?? []), agentId]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (Array.isArray(images) ? images : []).map((img) => {
|
|
77
|
+
const tags = (img.RepoTags ?? []).filter((t) => t !== '<none>:<none>');
|
|
78
|
+
return {
|
|
79
|
+
id: img.Id,
|
|
80
|
+
tags,
|
|
81
|
+
version: parseOpenClawVersion(img.Labels?.[VERSION_LABEL]) ?? versionFromTags(tags),
|
|
82
|
+
sizeBytes: img.Size ?? 0,
|
|
83
|
+
usedBy: usedBy.get(img.Id) ?? [],
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Supported versions present locally under their version tag, newest first. */
|
|
89
|
+
export function localSupportedVersions(images: LocalAgentImage[]): string[] {
|
|
90
|
+
const versions = new Set<string>();
|
|
91
|
+
for (const img of images) {
|
|
92
|
+
for (const tag of img.tags) {
|
|
93
|
+
if (tag.startsWith(tagPrefix) && isSupportedVersion(tag.slice(tagPrefix.length))) {
|
|
94
|
+
versions.add(tag.slice(tagPrefix.length));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return [...versions].sort((a, b) => compareVersions(b, a));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const newestLocalSupportedVersion = (images: LocalAgentImage[]): string | null =>
|
|
102
|
+
localSupportedVersions(images)[0] ?? null;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Whether an image reference (a tag or an id) exists locally. `dockerFetch` resolves
|
|
106
|
+
* Docker's error bodies too, so a 404 is told apart by the missing `Id`.
|
|
107
|
+
*/
|
|
108
|
+
async function imageExists(reference: string): Promise<boolean> {
|
|
109
|
+
try {
|
|
110
|
+
const info = await dockerFetch<{ Id?: unknown }>('GET', `/images/${encodeURIComponent(reference)}/json`);
|
|
111
|
+
return typeof info?.Id === 'string';
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Whether `openclaw-agent-base:<version>` exists locally. */
|
|
118
|
+
export const localVersionExists = (version: string): Promise<boolean> => imageExists(localImageRef(version));
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Versions read from inside containers, keyed by image id: an image never changes
|
|
122
|
+
* version. A failed read is remembered for a few minutes too, so a container whose
|
|
123
|
+
* image is not OpenClaw does not cost a `docker exec` on every agent list poll.
|
|
124
|
+
*/
|
|
125
|
+
const versionByImageId = new Map<string, { version: string | null; at: number }>();
|
|
126
|
+
const UNKNOWN_VERSION_TTL_MS = 5 * 60 * 1000;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The OpenClaw version a container runs: its image's label or version tag, else
|
|
130
|
+
* `openclaw --version` inside the running container, for images built before the
|
|
131
|
+
* label existed (2026.7.1-2 published as `:latest` only). Null for a stopped container
|
|
132
|
+
* whose image carries neither.
|
|
133
|
+
*/
|
|
134
|
+
export async function containerOpenClawVersion(
|
|
135
|
+
container: { name: string; imageId: string; running: boolean },
|
|
136
|
+
images: LocalAgentImage[],
|
|
137
|
+
): Promise<string | null> {
|
|
138
|
+
const fromImage = images.find((img) => img.id === container.imageId)?.version;
|
|
139
|
+
if (fromImage) return fromImage;
|
|
140
|
+
const cached = versionByImageId.get(container.imageId);
|
|
141
|
+
if (cached && (cached.version || Date.now() - cached.at < UNKNOWN_VERSION_TTL_MS)) return cached.version;
|
|
142
|
+
if (!container.running) return null;
|
|
143
|
+
const version = parseOpenClawVersion(
|
|
144
|
+
await dockerExecNoFail(container.name, ['openclaw', '--version'], { timeoutMs: 30_000 }),
|
|
145
|
+
);
|
|
146
|
+
versionByImageId.set(container.imageId, { version, at: Date.now() });
|
|
147
|
+
return version;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Registry ─────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
function splitRepository(repository: string): { host: string; path: string } {
|
|
153
|
+
const [first, ...rest] = repository.split('/');
|
|
154
|
+
if (rest.length && (first.includes('.') || first.includes(':') || first === 'localhost')) {
|
|
155
|
+
return { host: first, path: rest.join('/') };
|
|
156
|
+
}
|
|
157
|
+
return { host: 'registry-1.docker.io', path: repository.includes('/') ? repository : `library/${repository}` };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const isLocalRegistryHost = (host: string) => /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(host);
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Tags of a repository through the registry HTTP API. A public registry such as ghcr
|
|
164
|
+
* answers 401 with a Bearer challenge; an anonymous token for `pull` is then fetched
|
|
165
|
+
* from the realm it names. A registry on localhost is plain HTTP.
|
|
166
|
+
*/
|
|
167
|
+
async function fetchRegistryTags(repository: string): Promise<string[]> {
|
|
168
|
+
const { host, path } = splitRepository(repository);
|
|
169
|
+
const url = `${isLocalRegistryHost(host) ? 'http' : 'https'}://${host}/v2/${path}/tags/list?n=1000`;
|
|
170
|
+
const get = (target: string | URL, headers?: Record<string, string>) =>
|
|
171
|
+
fetch(target, { headers, cache: 'no-store', signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS) });
|
|
172
|
+
|
|
173
|
+
let res = await get(url);
|
|
174
|
+
if (res.status === 401) {
|
|
175
|
+
const challenge = res.headers.get('www-authenticate') ?? '';
|
|
176
|
+
const params = Object.fromEntries([...challenge.matchAll(/(\w+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
|
|
177
|
+
if (!params.realm) throw new Error('the registry asked for credentials');
|
|
178
|
+
const tokenUrl = new URL(params.realm);
|
|
179
|
+
if (params.service) tokenUrl.searchParams.set('service', params.service);
|
|
180
|
+
tokenUrl.searchParams.set('scope', params.scope ?? `repository:${path}:pull`);
|
|
181
|
+
const tokenRes = await get(tokenUrl);
|
|
182
|
+
if (!tokenRes.ok) throw new Error(`registry token request failed (HTTP ${tokenRes.status})`);
|
|
183
|
+
const body = (await tokenRes.json()) as { token?: string; access_token?: string };
|
|
184
|
+
const token = body.token ?? body.access_token;
|
|
185
|
+
if (!token) throw new Error('the registry returned no token');
|
|
186
|
+
res = await get(url, { Authorization: `Bearer ${token}` });
|
|
187
|
+
}
|
|
188
|
+
if (!res.ok) throw new Error(`registry tag list failed (HTTP ${res.status})`);
|
|
189
|
+
return ((await res.json()) as { tags?: string[] | null }).tags ?? [];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let remoteCache: { repository: string; versions: string[] | null; at: number } | null = null;
|
|
193
|
+
let remoteInFlight: Promise<string[] | null> | null = null;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Supported versions the registry publishes, newest first; null when it cannot be
|
|
197
|
+
* reached. Cached for ten minutes, and concurrent callers share one lookup, so a
|
|
198
|
+
* banner polling every two seconds costs one request per TTL.
|
|
199
|
+
*/
|
|
200
|
+
export async function remoteSupportedVersions(): Promise<string[] | null> {
|
|
201
|
+
const repository = registryRepository();
|
|
202
|
+
if (remoteCache && remoteCache.repository === repository && Date.now() - remoteCache.at < REMOTE_TTL_MS) {
|
|
203
|
+
return remoteCache.versions;
|
|
204
|
+
}
|
|
205
|
+
if (remoteInFlight) return remoteInFlight;
|
|
206
|
+
|
|
207
|
+
remoteInFlight = (async () => {
|
|
208
|
+
try {
|
|
209
|
+
const tags = await fetchRegistryTags(repository);
|
|
210
|
+
return tags.filter(isSupportedVersion).sort((a, b) => compareVersions(b, a));
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
})();
|
|
215
|
+
try {
|
|
216
|
+
const versions = await remoteInFlight;
|
|
217
|
+
remoteCache = { repository, versions, at: Date.now() };
|
|
218
|
+
return versions;
|
|
219
|
+
} finally {
|
|
220
|
+
remoteInFlight = null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Pull ─────────────────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Runs `docker <args>`, streaming output lines; rejects with the last error line.
|
|
228
|
+
*
|
|
229
|
+
* `env` is for container environment on `docker run`: each name is inserted as `-e KEY`
|
|
230
|
+
* right after the subcommand, and the value reaches docker through its own
|
|
231
|
+
* environment, so secrets stay out of the process table.
|
|
232
|
+
*/
|
|
233
|
+
export function runDocker(
|
|
234
|
+
args: string[],
|
|
235
|
+
opts: { onLine?: (line: string) => void; signal?: AbortSignal; timeoutMs: number; env?: Record<string, string> },
|
|
236
|
+
): Promise<void> {
|
|
237
|
+
return new Promise((resolve, reject) => {
|
|
238
|
+
const envNames = Object.keys(opts.env ?? {});
|
|
239
|
+
const argv = envNames.length ? [args[0], ...envNames.flatMap((k) => ['-e', k]), ...args.slice(1)] : args;
|
|
240
|
+
const child = spawn('docker', argv, {
|
|
241
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
242
|
+
...(envNames.length ? { env: { ...process.env, ...opts.env } } : {}),
|
|
243
|
+
});
|
|
244
|
+
let lastError = '';
|
|
245
|
+
let firstError = '';
|
|
246
|
+
const onData = (isErr: boolean) => (chunk: Buffer) => {
|
|
247
|
+
for (const line of chunk.toString().split(/\r?\n/)) {
|
|
248
|
+
if (!line.trim()) continue;
|
|
249
|
+
if (isErr) {
|
|
250
|
+
// Docker prints the cause first and "Run 'docker run --help' …" last; keep
|
|
251
|
+
// both ends so the rejection can carry the cause, not the usage pointer.
|
|
252
|
+
firstError ||= line.trim();
|
|
253
|
+
lastError = line.trim();
|
|
254
|
+
}
|
|
255
|
+
opts.onLine?.(line);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
child.stdout.on('data', onData(false));
|
|
259
|
+
child.stderr.on('data', onData(true));
|
|
260
|
+
|
|
261
|
+
const kill = () => child.kill('SIGKILL');
|
|
262
|
+
const timer = setTimeout(() => {
|
|
263
|
+
kill();
|
|
264
|
+
reject(new Error(`docker ${args[0]} timed out`));
|
|
265
|
+
}, opts.timeoutMs);
|
|
266
|
+
opts.signal?.addEventListener('abort', kill, { once: true });
|
|
267
|
+
|
|
268
|
+
child.on('error', (err) => {
|
|
269
|
+
clearTimeout(timer);
|
|
270
|
+
opts.signal?.removeEventListener('abort', kill);
|
|
271
|
+
reject(err);
|
|
272
|
+
});
|
|
273
|
+
child.on('close', (code) => {
|
|
274
|
+
clearTimeout(timer);
|
|
275
|
+
opts.signal?.removeEventListener('abort', kill);
|
|
276
|
+
if (code === 0) resolve();
|
|
277
|
+
else reject(new Error(firstError && firstError !== lastError ? `${firstError} (${lastError})` : lastError || `docker ${args[0]} exited with code ${code}`));
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Pull `<registry>:<version>` and tag it `openclaw-agent-base:<version>`. The registry
|
|
284
|
+
* reference is removed afterwards (an untag, the layers stay), so local images list
|
|
285
|
+
* under the version tag only.
|
|
286
|
+
*/
|
|
287
|
+
export async function pullAgentImage(
|
|
288
|
+
version: string,
|
|
289
|
+
opts: { onLine?: (line: string) => void; signal?: AbortSignal } = {},
|
|
290
|
+
): Promise<void> {
|
|
291
|
+
if (!isSupportedVersion(version)) throw new Error(`OpenClaw ${version} is not supported by this Rev4a`);
|
|
292
|
+
const remote = `${registryRepository()}:${version}`;
|
|
293
|
+
await runDocker(['pull', remote], { ...opts, timeoutMs: PULL_TIMEOUT_MS });
|
|
294
|
+
await runDocker(['tag', remote, localImageRef(version)], { ...opts, timeoutMs: 30_000 });
|
|
295
|
+
await runDocker(['rmi', remote], { timeoutMs: 30_000 }).catch(() => {});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Retention: remove agent images nothing needs.
|
|
300
|
+
*
|
|
301
|
+
* Kept: every image a container uses, running or stopped, and the newest and the
|
|
302
|
+
* previous OpenClaw version present — the previous one is what a rollback runs. Other
|
|
303
|
+
* images lose all their tags. A kept image loses its extra tags (`latest`,
|
|
304
|
+
* `2026.9.3-local`) when it has a version tag. Nothing is forced: Docker refuses to
|
|
305
|
+
* remove an image a container uses, the last safety net. A removed version can be
|
|
306
|
+
* pulled again while the registry publishes it. Returns the references removed.
|
|
307
|
+
*/
|
|
308
|
+
export async function pruneAgentImages(): Promise<string[]> {
|
|
309
|
+
const images = await listLocalAgentImages();
|
|
310
|
+
const versions = [...new Set(images.map((img) => img.version).filter((v): v is string => !!v))]
|
|
311
|
+
.sort((a, b) => compareVersions(b, a));
|
|
312
|
+
const keepVersions = new Set(versions.slice(0, 2));
|
|
313
|
+
|
|
314
|
+
const removed: string[] = [];
|
|
315
|
+
for (const img of images) {
|
|
316
|
+
const keep = img.usedBy.length > 0 || (img.version !== null && keepVersions.has(img.version));
|
|
317
|
+
const versionTags = img.tags.filter((t) => {
|
|
318
|
+
if (!t.startsWith(tagPrefix)) return false;
|
|
319
|
+
const tag = t.slice(tagPrefix.length);
|
|
320
|
+
return parseOpenClawVersion(tag) === tag;
|
|
321
|
+
});
|
|
322
|
+
const targets = keep ? (versionTags.length ? img.tags.filter((t) => !versionTags.includes(t)) : []) : img.tags;
|
|
323
|
+
for (const tag of targets) {
|
|
324
|
+
try {
|
|
325
|
+
await runDocker(['rmi', tag], { timeoutMs: 120_000 });
|
|
326
|
+
removed.push(tag);
|
|
327
|
+
} catch {
|
|
328
|
+
// In use, or already gone.
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return removed;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The image a recreate uses: the agent's own OpenClaw version under its version tag.
|
|
337
|
+
* A recreate never changes version.
|
|
338
|
+
*
|
|
339
|
+
* When the tag is missing but the container's image is still here (an agent created
|
|
340
|
+
* from `:latest` before version tags), that image gets the version tag. When the image
|
|
341
|
+
* is gone too, a supported version is pulled. An agent whose version cannot be read
|
|
342
|
+
* keeps the exact image id it runs.
|
|
343
|
+
*/
|
|
344
|
+
export async function resolveRecreateImage(container: {
|
|
345
|
+
Name?: string;
|
|
346
|
+
Image?: string;
|
|
347
|
+
Config?: { Image?: string };
|
|
348
|
+
State?: { Running?: boolean };
|
|
349
|
+
}): Promise<string> {
|
|
350
|
+
const name = (container.Name ?? '').replace(/^\//, '');
|
|
351
|
+
const imageId = container.Image ?? '';
|
|
352
|
+
const images = await listLocalAgentImages();
|
|
353
|
+
const version = await containerOpenClawVersion(
|
|
354
|
+
{ name, imageId, running: container.State?.Running === true },
|
|
355
|
+
images,
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
if (version) {
|
|
359
|
+
const ref = localImageRef(version);
|
|
360
|
+
if (await localVersionExists(version)) return ref;
|
|
361
|
+
if (imageId && (await imageExists(imageId))) {
|
|
362
|
+
await runDocker(['tag', imageId, ref], { timeoutMs: 30_000 });
|
|
363
|
+
return ref;
|
|
364
|
+
}
|
|
365
|
+
if (isSupportedVersion(version)) {
|
|
366
|
+
await pullAgentImage(version);
|
|
367
|
+
return ref;
|
|
368
|
+
}
|
|
369
|
+
throw new Error(`The image for OpenClaw ${version} is not available locally`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (imageId && (await imageExists(imageId))) return imageId;
|
|
373
|
+
if (container.Config?.Image) return container.Config.Image;
|
|
374
|
+
throw new Error('Cannot tell which image this agent runs');
|
|
375
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side host-port lookup for lib/agent-ports.ts. Split out because it uses the
|
|
3
|
+
* Docker socket, which client components cannot reach.
|
|
4
|
+
*
|
|
5
|
+
* This is the only place that answers "which host ports are taken": agent creation,
|
|
6
|
+
* the create form, the edit (rename/ports) flow and every container rebuild (update,
|
|
7
|
+
* recreate, rollback) all read their numbers from here.
|
|
8
|
+
*
|
|
9
|
+
* The numbers come from each container's `HostConfig.PortBindings`, the only source
|
|
10
|
+
* that also covers containers that are not running. Neither
|
|
11
|
+
* `docker ps -a --format '{{.Ports}}'` nor the API's container list reports ports for
|
|
12
|
+
* a stopped container (verified on Docker 29: both return nothing), while the
|
|
13
|
+
* allocation is still held — starting that container again fails with "port is
|
|
14
|
+
* already allocated". Reading the bindings is what stops a new agent from being given
|
|
15
|
+
* the block of a stopped one.
|
|
16
|
+
*/
|
|
17
|
+
import { dockerFetch } from '@/lib/docker-socket';
|
|
18
|
+
|
|
19
|
+
export interface HostPortHolder {
|
|
20
|
+
/** Container name; agent containers are named after their id. */
|
|
21
|
+
name: string;
|
|
22
|
+
/** The container's `AGENT_NAME`, the name the panel shows; null for non-agent containers. */
|
|
23
|
+
displayName: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ContainerInspect {
|
|
27
|
+
Id?: string;
|
|
28
|
+
Name?: string;
|
|
29
|
+
Config?: { Env?: string[] };
|
|
30
|
+
HostConfig?: { PortBindings?: Record<string, { HostPort?: string }[] | null> };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Host ports held by containers, running or stopped, each mapped to the container that
|
|
35
|
+
* holds it. `excludeContainerId` drops one container from the result: the block of the
|
|
36
|
+
* container a rebuild is about to replace is not a conflict with itself.
|
|
37
|
+
*
|
|
38
|
+
* Best-effort: an unreachable daemon yields an empty map, as the previous shell-based
|
|
39
|
+
* lookup did, rather than failing the caller.
|
|
40
|
+
*/
|
|
41
|
+
export async function getHostPortHolders(opts: { excludeContainerId?: string } = {}): Promise<Map<number, HostPortHolder>> {
|
|
42
|
+
const holders = new Map<number, HostPortHolder>();
|
|
43
|
+
let list: { Id?: string }[];
|
|
44
|
+
try {
|
|
45
|
+
list = await dockerFetch<{ Id?: string }[]>('GET', '/containers/json?all=true');
|
|
46
|
+
} catch {
|
|
47
|
+
return holders;
|
|
48
|
+
}
|
|
49
|
+
const ids = (Array.isArray(list) ? list : [])
|
|
50
|
+
.map((c) => c.Id)
|
|
51
|
+
.filter((id): id is string => typeof id === 'string' && id !== opts.excludeContainerId);
|
|
52
|
+
const infos = await Promise.all(
|
|
53
|
+
ids.map((id) => dockerFetch<ContainerInspect>('GET', `/containers/${id}/json`).catch(() => null)),
|
|
54
|
+
);
|
|
55
|
+
for (const info of infos) {
|
|
56
|
+
if (!info) continue;
|
|
57
|
+
const name = (info.Name ?? '').replace(/^\//, '') || (info.Id ?? '').slice(0, 12);
|
|
58
|
+
const agentName = (info.Config?.Env ?? []).find((entry) => entry.startsWith('AGENT_NAME='));
|
|
59
|
+
const holder: HostPortHolder = { name, displayName: agentName ? agentName.slice('AGENT_NAME='.length) : null };
|
|
60
|
+
for (const bindings of Object.values(info.HostConfig?.PortBindings ?? {})) {
|
|
61
|
+
for (const binding of bindings ?? []) {
|
|
62
|
+
const port = Number(binding?.HostPort);
|
|
63
|
+
if (Number.isInteger(port)) holders.set(port, holder);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return holders;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The port set the validators in lib/agent-ports.ts take. */
|
|
71
|
+
export async function getUsedHostPorts(opts: { excludeContainerId?: string } = {}): Promise<Set<number>> {
|
|
72
|
+
return new Set((await getHostPortHolders(opts)).keys());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** How a holder reads in an error: `agent_24a68ac9 (Drill Wake)`, or the name alone. */
|
|
76
|
+
export function describeHolder(holder: HostPortHolder): string {
|
|
77
|
+
return holder.displayName ? `${holder.name} (${holder.displayName})` : holder.name;
|
|
78
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-port helpers shared by agent creation and the edit (rename/ports) flow, so both
|
|
3
|
+
* validate a range the same way and produce the same error messages. Pure functions only:
|
|
4
|
+
* this module is imported by client components too. The Docker lookup lives in
|
|
5
|
+
* `lib/agent-ports-server.ts`.
|
|
6
|
+
*
|
|
7
|
+
* The mapping convention is one block starting at the container's gateway port 3000:
|
|
8
|
+
* `3700` publishes `3700:3000`, and `3700-3709` publishes `3700-3709:3000-3009`.
|
|
9
|
+
* OpenClaw shifts its derived ports (browser, canvas) from 3000, which is why agents are
|
|
10
|
+
* created with a block rather than a single mapping.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Rev4a's own port: never offer it to an agent. */
|
|
14
|
+
export const REV4A_PORT = 3740;
|
|
15
|
+
/** Agents publish this many consecutive host ports by default. */
|
|
16
|
+
export const DEFAULT_BLOCK_SIZE = 10;
|
|
17
|
+
|
|
18
|
+
/** An available host block, from 3700 up, skipping Rev4a's port. */
|
|
19
|
+
export function findAvailablePortBlock(usedPorts: ReadonlySet<number> = new Set(), blockSize = DEFAULT_BLOCK_SIZE): number {
|
|
20
|
+
for (let base = 3700; base + blockSize - 1 <= 3799; base += DEFAULT_BLOCK_SIZE) {
|
|
21
|
+
if (base <= REV4A_PORT && REV4A_PORT <= base + blockSize - 1) continue;
|
|
22
|
+
const block = Array.from({ length: blockSize }, (_, i) => base + i);
|
|
23
|
+
if (block.every((p) => !usedPorts.has(p))) return base;
|
|
24
|
+
}
|
|
25
|
+
let base = 3800;
|
|
26
|
+
while (true) {
|
|
27
|
+
const block = Array.from({ length: blockSize }, (_, i) => base + i);
|
|
28
|
+
if (block.every((p) => !usedPorts.has(p))) return base;
|
|
29
|
+
base += DEFAULT_BLOCK_SIZE;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type PortInputResult =
|
|
34
|
+
| { valid: true; isBlock: boolean; start: number; end: number }
|
|
35
|
+
| { valid: false; error: string };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Validate `"3700"` or `"3700-3709"` against the ports already published (pass
|
|
39
|
+
* `getUsedHostPorts()` on the server; the client passes what it was given).
|
|
40
|
+
*/
|
|
41
|
+
export function validatePortInput(input: string, usedPorts: ReadonlySet<number> = new Set()): PortInputResult {
|
|
42
|
+
const trimmed = input.trim();
|
|
43
|
+
if (trimmed.includes('-')) {
|
|
44
|
+
const m = trimmed.match(/^(\d{1,5})-(\d{1,5})$/);
|
|
45
|
+
if (!m) return { valid: false, error: 'Invalid format. Use e.g. 3700-3709' };
|
|
46
|
+
const start = parseInt(m[1], 10);
|
|
47
|
+
const end = parseInt(m[2], 10);
|
|
48
|
+
if (start < 1 || end > 65535) return { valid: false, error: 'Ports must be between 1 and 65535' };
|
|
49
|
+
if (end <= start) return { valid: false, error: 'End port must be greater than start port' };
|
|
50
|
+
if (start <= REV4A_PORT && REV4A_PORT <= end) return { valid: false, error: `Port ${REV4A_PORT} is reserved for Rev4a` };
|
|
51
|
+
const conflicts: number[] = [];
|
|
52
|
+
for (let p = start; p <= end; p++) { if (usedPorts.has(p)) conflicts.push(p); }
|
|
53
|
+
if (conflicts.length > 0) return { valid: false, error: `Ports already in use: ${conflicts.join(', ')}` };
|
|
54
|
+
return { valid: true, isBlock: true, start, end };
|
|
55
|
+
}
|
|
56
|
+
const m = trimmed.match(/^(\d{1,5})$/);
|
|
57
|
+
if (!m) return { valid: false, error: 'Use a number (e.g. 3700) or a range (e.g. 3700-3709)' };
|
|
58
|
+
const port = parseInt(m[1], 10);
|
|
59
|
+
if (port < 1 || port > 65535) return { valid: false, error: 'Port must be between 1 and 65535' };
|
|
60
|
+
if (port === REV4A_PORT) return { valid: false, error: `Port ${REV4A_PORT} is reserved for Rev4a` };
|
|
61
|
+
if (usedPorts.has(port)) return { valid: false, error: `Port ${port} is already in use` };
|
|
62
|
+
return { valid: true, isBlock: false, start: port, end: port };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `docker run` port arguments for a validated range, mapping onto the container 3000. */
|
|
66
|
+
export function portMappingArgs(start: number, end: number): string[] {
|
|
67
|
+
return ['-p', end === start ? `${start}:3000` : `${start}-${end}:3000-${3000 + (end - start)}`];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The host ports a container publishes, from its inspect (`HostConfig.PortBindings`). */
|
|
71
|
+
export function hostPortsFromBindings(container: {
|
|
72
|
+
HostConfig?: { PortBindings?: Record<string, { HostPort?: string }[] | null> };
|
|
73
|
+
}): number[] {
|
|
74
|
+
return Object.values(container.HostConfig?.PortBindings ?? {})
|
|
75
|
+
.flatMap((bindings) => bindings ?? [])
|
|
76
|
+
.map((b) => Number(b?.HostPort))
|
|
77
|
+
.filter((n) => Number.isInteger(n));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The host ports a `docker run` argument list publishes: `3700:3000` or `3700-3709:3000-3009`. */
|
|
81
|
+
export function hostPortsFromArgs(portArgs: readonly string[]): number[] {
|
|
82
|
+
const ports: number[] = [];
|
|
83
|
+
for (let i = 0; i < portArgs.length - 1; i++) {
|
|
84
|
+
if (portArgs[i] !== '-p') continue;
|
|
85
|
+
const host = (portArgs[i + 1] ?? '').split(':')[0];
|
|
86
|
+
const [start, end] = host.split('-').map(Number);
|
|
87
|
+
if (!Number.isInteger(start)) continue;
|
|
88
|
+
for (let p = start; p <= (Number.isInteger(end) ? end : start); p++) ports.push(p);
|
|
89
|
+
}
|
|
90
|
+
return ports;
|
|
91
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted state of agent recreates: the `agent_recreates` table (lib/db-bootstrap.mjs).
|
|
3
|
+
*
|
|
4
|
+
* Kept apart from lib/agent-recreate.ts so busy checks (lib/agent-busy.ts, the provider
|
|
5
|
+
* sync) can ask which agents are being recreated without importing the recreate action.
|
|
6
|
+
*/
|
|
7
|
+
import { openDb } from '@/lib/db';
|
|
8
|
+
|
|
9
|
+
export type RecreateStatus = 'backing_up' | 'recreating' | 'done' | 'failed' | 'interrupted';
|
|
10
|
+
|
|
11
|
+
/** Statuses during which the agent must not be touched by anything else. */
|
|
12
|
+
export const ACTIVE_RECREATE_STATUSES: readonly RecreateStatus[] = ['backing_up', 'recreating'];
|
|
13
|
+
|
|
14
|
+
export interface AgentRecreateRow {
|
|
15
|
+
id: number;
|
|
16
|
+
agent_id: string;
|
|
17
|
+
status: RecreateStatus;
|
|
18
|
+
image: string | null;
|
|
19
|
+
backup_file: string | null;
|
|
20
|
+
error: string | null;
|
|
21
|
+
started_at: number;
|
|
22
|
+
updated_at: number;
|
|
23
|
+
finished_at: number | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const FINAL: readonly RecreateStatus[] = ['done', 'failed', 'interrupted'];
|
|
27
|
+
|
|
28
|
+
export function insertRecreate(agentId: string, image: string): number {
|
|
29
|
+
const db = openDb(false);
|
|
30
|
+
try {
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
const info = db.prepare(
|
|
33
|
+
`INSERT INTO agent_recreates (agent_id, status, image, started_at, updated_at)
|
|
34
|
+
VALUES (?, 'backing_up', ?, ?, ?)`,
|
|
35
|
+
).run(agentId, image, now, now);
|
|
36
|
+
return Number(info.lastInsertRowid);
|
|
37
|
+
} finally {
|
|
38
|
+
db.close();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type Writable = Partial<Pick<AgentRecreateRow, 'status' | 'backup_file' | 'error'>>;
|
|
43
|
+
|
|
44
|
+
export function updateRecreateRow(id: number, fields: Writable): void {
|
|
45
|
+
const entries = Object.entries(fields).filter(([, v]) => v !== undefined);
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
const sets = [...entries.map(([k]) => `${k} = ?`), 'updated_at = ?'];
|
|
48
|
+
const values: unknown[] = [...entries.map(([, v]) => v), now];
|
|
49
|
+
if (fields.status && FINAL.includes(fields.status)) {
|
|
50
|
+
sets.push('finished_at = ?');
|
|
51
|
+
values.push(now);
|
|
52
|
+
} else if (fields.status) {
|
|
53
|
+
sets.push('finished_at = NULL');
|
|
54
|
+
}
|
|
55
|
+
const db = openDb(false);
|
|
56
|
+
try {
|
|
57
|
+
db.prepare(`UPDATE agent_recreates SET ${sets.join(', ')} WHERE id = ?`).run(...values, id);
|
|
58
|
+
} finally {
|
|
59
|
+
db.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function latestRecreate(agentId: string): AgentRecreateRow | null {
|
|
64
|
+
const db = openDb(true);
|
|
65
|
+
try {
|
|
66
|
+
return (db.prepare('SELECT * FROM agent_recreates WHERE agent_id = ? ORDER BY id DESC LIMIT 1').get(agentId) as AgentRecreateRow | undefined) ?? null;
|
|
67
|
+
} finally {
|
|
68
|
+
db.close();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function isRecreateActive(agentId: string): boolean {
|
|
73
|
+
const row = latestRecreate(agentId);
|
|
74
|
+
return !!row && ACTIVE_RECREATE_STATUSES.includes(row.status);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** AGENT_IDs with a recreate in an active status. */
|
|
78
|
+
export function activeRecreateAgentIds(): Set<string> {
|
|
79
|
+
const db = openDb(true);
|
|
80
|
+
try {
|
|
81
|
+
const placeholders = ACTIVE_RECREATE_STATUSES.map(() => '?').join(', ');
|
|
82
|
+
const rows = db.prepare(`SELECT DISTINCT agent_id FROM agent_recreates WHERE status IN (${placeholders})`).all(...ACTIVE_RECREATE_STATUSES) as { agent_id: string }[];
|
|
83
|
+
return new Set(rows.map((r) => r.agent_id));
|
|
84
|
+
} finally {
|
|
85
|
+
db.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* At startup no recreate job can be running: any row still active was cut off by the
|
|
91
|
+
* restart. It becomes `interrupted`, keeping the step it was in.
|
|
92
|
+
*/
|
|
93
|
+
export function markInterruptedRecreates(): number {
|
|
94
|
+
const db = openDb(false);
|
|
95
|
+
try {
|
|
96
|
+
const placeholders = ACTIVE_RECREATE_STATUSES.map(() => '?').join(', ');
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
const info = db.prepare(
|
|
99
|
+
`UPDATE agent_recreates
|
|
100
|
+
SET error = COALESCE(error, 'Rev4a restarted while this step was running: ' || status),
|
|
101
|
+
status = 'interrupted', updated_at = ?, finished_at = ?
|
|
102
|
+
WHERE status IN (${placeholders})`,
|
|
103
|
+
).run(now, now, ...ACTIVE_RECREATE_STATUSES);
|
|
104
|
+
return info.changes;
|
|
105
|
+
} finally {
|
|
106
|
+
db.close();
|
|
107
|
+
}
|
|
108
|
+
}
|