@flame0510/project-aether 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/agent-templates/README.md +42 -22
- package/agent-templates/base-image/Dockerfile +42 -33
- package/agent-templates/base-image/entrypoint.sh +67 -12
- package/app/agents/BrowserAccessSection.tsx +510 -0
- package/app/agents/ChannelManager.tsx +19 -11
- package/app/agents/ImageDownloadBanner.tsx +53 -19
- package/app/agents/ModelSection.tsx +316 -0
- package/app/agents/PageClient.tsx +708 -167
- package/app/agents/UpdateSection.tsx +300 -0
- package/app/agents/create/PageClient.tsx +11 -49
- package/app/agents/create/page.tsx +8 -21
- package/app/api/agents/[id]/backup/route.ts +26 -69
- package/app/api/agents/[id]/channels/pairing/route.ts +3 -3
- package/app/api/agents/[id]/channels/telegram/route.ts +2 -2
- package/app/api/agents/[id]/cold-backup/route.ts +56 -0
- package/app/api/agents/[id]/devices/route.ts +126 -0
- package/app/api/agents/[id]/invite-link/route.ts +53 -0
- package/app/api/agents/[id]/lifecycle/route.ts +3 -0
- package/app/api/agents/[id]/model/route.ts +113 -0
- package/app/api/agents/[id]/open-control-ui/route.ts +58 -0
- package/app/api/agents/[id]/recreate/route.ts +33 -187
- package/app/api/agents/[id]/restart/route.ts +5 -0
- package/app/api/agents/[id]/restore/route.ts +40 -70
- package/app/api/agents/[id]/route.ts +38 -169
- package/app/api/agents/[id]/update/rollback/route.ts +30 -0
- package/app/api/agents/[id]/update/route.ts +50 -0
- package/app/api/agents/activity-summary/route.ts +67 -0
- package/app/api/agents/create/route.ts +91 -145
- package/app/api/agents/devices-summary/route.ts +37 -0
- package/app/api/agents/download-image/route.ts +16 -9
- package/app/api/agents/image-status/route.ts +31 -111
- package/app/api/agents/models-summary/route.ts +163 -0
- package/app/api/agents/route.ts +25 -49
- package/app/api/agents/token/route.ts +33 -10
- package/app/api/assistant/route.ts +37 -16
- package/app/api/gateway/agent/route.ts +37 -6
- package/app/api/gateway/provider/balance/route.ts +5 -2
- package/app/api/gateway/provider/keys.ts +13 -1
- package/app/api/gateway/provider/route.ts +43 -12
- package/app/api/gateway/sync.ts +335 -76
- package/app/api/models/route.ts +28 -34
- package/app/api/provider/auth.ts +65 -0
- package/app/api/provider/upstream.ts +9 -2
- package/app/api/provider/v1/chat/completions/route.ts +22 -16
- package/app/api/provider/v1/models/route.ts +26 -133
- package/app/api/setup/agent-image/route.ts +14 -42
- package/app/components/DashboardToolbar.tsx +1 -1
- package/app/components/PulseChat.tsx +25 -39
- package/app/components/ui/RemoveButton.tsx +46 -0
- package/app/components/ui/Select.tsx +3 -2
- package/app/components/ui/index.ts +1 -0
- package/app/credentials/PageClient.tsx +2 -2
- package/app/gateway/PageClient.tsx +253 -674
- package/app/globals.css +8 -0
- package/app/lib/models-context.tsx +43 -7
- package/app/wizard/useWizard.ts +6 -1
- package/bin/rev4a.js +116 -50
- package/daemon.js +6 -6
- package/docs/ARCHITECTURE.md +110 -12
- package/docs/FRONTEND-ARCHITECTURE.md +31 -2
- package/docs/REV4A.md +93 -33
- package/docs/dev/API-REFERENCE.md +723 -178
- package/docs/dev/DATABASE.md +96 -0
- package/docs/dev/GATEWAY.md +250 -93
- package/docs/dev/PROVIDERS.md +26 -13
- package/docs/rag/DATA-FRESHNESS.md +59 -28
- package/docs/rag/GLOSSARY.md +27 -16
- package/docs/rag/REV4A-OVERVIEW.md +37 -25
- package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -8
- package/instrumentation.ts +52 -1
- package/lib/agent-busy.ts +21 -0
- package/lib/agent-devices.ts +361 -0
- package/lib/agent-edit-state.ts +108 -0
- package/lib/agent-edit.ts +157 -0
- package/lib/agent-images.ts +375 -0
- package/lib/agent-ports-server.ts +27 -0
- package/lib/agent-ports.ts +68 -0
- package/lib/agent-readiness.ts +110 -0
- package/lib/agent-recreate-state.ts +108 -0
- package/lib/agent-recreate.ts +305 -0
- package/lib/agent-restore-state.ts +107 -0
- package/lib/agent-restore.ts +135 -0
- package/lib/agent-setup.ts +66 -17
- package/lib/agent-update-state.ts +122 -0
- package/lib/agent-update.ts +448 -0
- package/lib/agent-versions.json +14 -0
- package/lib/agent-versions.ts +80 -0
- package/lib/buildAgentImage.ts +88 -290
- package/lib/channelManager.ts +153 -64
- package/lib/cold-backup.ts +354 -0
- package/lib/container-file.ts +27 -0
- package/lib/credentials/delivery.ts +3 -3
- package/lib/db-bootstrap.mjs +76 -0
- package/lib/docker-utils.ts +3 -3
- package/lib/model-catalogue.ts +140 -27
- package/lib/provider-balance.ts +33 -12
- package/lib/rev4a-paths.ts +0 -21
- package/model-pricing.json +118 -110
- package/models.config.json +27 -12
- package/package.json +1 -1
- package/app/api/gateway/route.ts +0 -191
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { NextRequest, NextResponse } from 'next/server';
|
|
9
9
|
import { execSync } from 'child_process';
|
|
10
10
|
import { readProviderKeys, writeProviderKeys } from './keys';
|
|
11
|
-
import { syncAllAgents } from '../sync';
|
|
11
|
+
import { dockerUnreachableReason, syncAllAgents, type SyncOutcome } from '../sync';
|
|
12
12
|
import { detectProviderGatewayUrl } from '@/lib/docker-utils';
|
|
13
13
|
import { getPricing } from '@/lib/model-pricing';
|
|
14
14
|
import { loadModelsConfig, toggleModelOverride, type ModelConfigEntry } from '@/lib/model-catalogue';
|
|
@@ -192,6 +192,20 @@ export async function GET(request: NextRequest) {
|
|
|
192
192
|
return NextResponse.json({ providers });
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Run the sync without letting an unexpected throw turn a committed change into a
|
|
197
|
+
* 500. `syncAllAgents` already reports Docker and per-container failures as data;
|
|
198
|
+
* this only guards the rest.
|
|
199
|
+
*/
|
|
200
|
+
function runSync(): SyncOutcome {
|
|
201
|
+
try {
|
|
202
|
+
return syncAllAgents();
|
|
203
|
+
} catch (e) {
|
|
204
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
205
|
+
return { ok: false, activeModels: 0, total: 0, synced: [], stopped: [], failed: [], configError: msg, summary: `Not applied to any agent: ${msg}.` };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
195
209
|
export async function POST(request: NextRequest) {
|
|
196
210
|
const denied = await requireAuthJWT(request);
|
|
197
211
|
if (denied) return denied;
|
|
@@ -199,8 +213,12 @@ export async function POST(request: NextRequest) {
|
|
|
199
213
|
const body: { provider?: string; apiKey?: string; _syncOnly?: boolean } = await request.json();
|
|
200
214
|
|
|
201
215
|
if (body._syncOnly) {
|
|
202
|
-
|
|
203
|
-
|
|
216
|
+
// Here the sync *is* the operation, so its outcome is the status.
|
|
217
|
+
const sync = runSync();
|
|
218
|
+
return NextResponse.json(
|
|
219
|
+
sync.ok ? { status: 'ok', sync } : { status: 'error', error: sync.summary, sync },
|
|
220
|
+
{ status: sync.ok ? 200 : 502 },
|
|
221
|
+
);
|
|
204
222
|
}
|
|
205
223
|
|
|
206
224
|
const providerName = body.provider;
|
|
@@ -210,15 +228,11 @@ export async function POST(request: NextRequest) {
|
|
|
210
228
|
const apiKeyValue = body.apiKey;
|
|
211
229
|
const isRemove = !apiKeyValue || apiKeyValue.trim() === '';
|
|
212
230
|
setProviderApiKey(providerName, isRemove ? null : apiKeyValue!.trim());
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
syncResults = syncAllAgents();
|
|
216
|
-
} catch (se) {
|
|
217
|
-
syncResults = [`Sync error: ${se instanceof Error ? se.message : String(se)}`];
|
|
218
|
-
}
|
|
231
|
+
// The key is saved whatever the sync does next; report the two separately.
|
|
232
|
+
const sync = runSync();
|
|
219
233
|
return NextResponse.json({
|
|
220
234
|
status: 'ok', provider: providerName, configured: !isRemove,
|
|
221
|
-
sync
|
|
235
|
+
sync,
|
|
222
236
|
});
|
|
223
237
|
} catch (e: unknown) {
|
|
224
238
|
return NextResponse.json({ status: 'error', error: e instanceof Error ? e.message : String(e) }, { status: 500 });
|
|
@@ -238,9 +252,26 @@ export async function PUT(request: NextRequest) {
|
|
|
238
252
|
const idx = models.findIndex((m) => m.id === modelId);
|
|
239
253
|
if (idx === -1) return NextResponse.json({ status: 'error', error: `Model not found: ${modelId}` }, { status: 404 });
|
|
240
254
|
|
|
255
|
+
// Refuse up front when the sync cannot happen at all. Saving anyway would leave
|
|
256
|
+
// the checkbox on /gateway saying one thing and every agent holding another.
|
|
257
|
+
// Only a whole-fleet outage is refused this way: once Docker answers, a container
|
|
258
|
+
// that fails individually is reported in `sync`, because undoing the override
|
|
259
|
+
// would not undo the containers that did receive it. Provider key saves are not
|
|
260
|
+
// gated like this — the first-run wizard saves keys without reading the response
|
|
261
|
+
// and may run before Docker is up, so a refusal there would be silent.
|
|
262
|
+
const unreachable = dockerUnreachableReason();
|
|
263
|
+
if (unreachable) {
|
|
264
|
+
return NextResponse.json({
|
|
265
|
+
status: 'error',
|
|
266
|
+
error: `Not changed: Docker is unreachable (${unreachable}). Nothing was saved, so the Gateway still matches the agents.`,
|
|
267
|
+
}, { status: 503 });
|
|
268
|
+
}
|
|
269
|
+
|
|
241
270
|
toggleModelOverride(modelId, enabled);
|
|
242
|
-
|
|
243
|
-
|
|
271
|
+
// `status` is the toggle, committed at this point. `sync` is whether the fleet
|
|
272
|
+
// got it — reported alongside, not folded into `status`.
|
|
273
|
+
const sync = runSync();
|
|
274
|
+
return NextResponse.json({ status: 'ok', modelId, enabled, provider: models[idx].provider, sync });
|
|
244
275
|
} catch (e: unknown) {
|
|
245
276
|
return NextResponse.json({ status: 'error', error: e instanceof Error ? e.message : String(e) }, { status: 500 });
|
|
246
277
|
}
|
package/app/api/gateway/sync.ts
CHANGED
|
@@ -2,17 +2,28 @@
|
|
|
2
2
|
* Agent Gateway Sync
|
|
3
3
|
*
|
|
4
4
|
* Syncs the models.providers.rev4a config block to all agent containers
|
|
5
|
-
*
|
|
5
|
+
* without touching model references (primary/fallback).
|
|
6
6
|
*
|
|
7
7
|
* Import and call:
|
|
8
8
|
* syncAllAgents() — sync all containers with AGENT_ID label
|
|
9
|
-
*
|
|
9
|
+
*
|
|
10
|
+
* A single-container `syncAgent()` used to be exported too. Nothing called it —
|
|
11
|
+
* the create route builds the block with `buildRev4aProviderConfig()` and writes
|
|
12
|
+
* it with `openclaw config patch` itself — so it was removed.
|
|
10
13
|
*/
|
|
11
|
-
import { execSync } from 'child_process';
|
|
14
|
+
import { execSync, execFileSync } from 'child_process';
|
|
12
15
|
import * as fs from 'fs';
|
|
16
|
+
import * as os from 'os';
|
|
17
|
+
import * as path from 'path';
|
|
13
18
|
import { readProviderKeys } from './provider/keys';
|
|
14
|
-
import {
|
|
19
|
+
import { bundledCatalogueStatus, loadOfferedModels, type ModelConfigEntry } from '@/lib/model-catalogue';
|
|
20
|
+
import { singleFileFromTar } from '@/lib/container-file';
|
|
15
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';
|
|
16
27
|
|
|
17
28
|
const PROVIDER_GATEWAY_BASE_URL = detectProviderGatewayUrl();
|
|
18
29
|
|
|
@@ -25,74 +36,217 @@ const PROVIDER_GATEWAY_BASE_URL = detectProviderGatewayUrl();
|
|
|
25
36
|
* OpenClaw `input` array. Filters out values not in the allowed set
|
|
26
37
|
* (text | image | video | audio).
|
|
27
38
|
*/
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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));
|
|
31
46
|
const arrowIdx = modality.indexOf('->');
|
|
32
47
|
const left = arrowIdx >= 0 ? modality.slice(0, arrowIdx) : modality;
|
|
33
|
-
return left.split('+').map((s) => s.trim()).filter((s) =>
|
|
48
|
+
return left.split('+').map((s) => s.trim()).filter((s) => allowed.has(s));
|
|
34
49
|
}
|
|
35
50
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function getActiveRev4aModels(version?: string | null): { id: string; name: string; input?: string[] }[] {
|
|
86
|
+
return loadOfferedModels()
|
|
41
87
|
.map((m) => {
|
|
42
88
|
const entry: { id: string; name: string; input?: string[] } = { id: m.id, name: m.name };
|
|
43
|
-
if (m.modality) entry.input = modalityToInput(m.modality);
|
|
89
|
+
if (m.modality) entry.input = modalityToInput(m.modality, version);
|
|
44
90
|
return entry;
|
|
45
91
|
});
|
|
46
92
|
}
|
|
47
93
|
|
|
48
|
-
|
|
94
|
+
/** A readable cause from a failed child process: its stderr if any, else the message. */
|
|
95
|
+
function firstLine(e: unknown): string {
|
|
96
|
+
const err = e as { stderr?: string | Buffer; message?: string };
|
|
97
|
+
const text = (err.stderr ? err.stderr.toString() : '') || err.message || 'unknown error';
|
|
98
|
+
return text.trim().split('\n')[0];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A container carrying an AGENT_ID label, and whether it is running. */
|
|
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> {
|
|
49
109
|
try {
|
|
50
110
|
const raw = execSync(
|
|
51
|
-
`docker ps --filter "label=
|
|
52
|
-
{ timeout: 5000,
|
|
111
|
+
`docker ps --filter "label=rev4a.backup.agent" --format '{{.Label "rev4a.backup.agent"}}'`,
|
|
112
|
+
{ timeout: 5000, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },
|
|
53
113
|
).trim();
|
|
54
|
-
|
|
55
|
-
return raw.split('\n');
|
|
114
|
+
return new Set(raw ? raw.split('\n').map((s) => s.trim()).filter(Boolean) : []);
|
|
56
115
|
} catch {
|
|
57
|
-
return
|
|
116
|
+
return new Set();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Every container carrying an AGENT_ID label, running or stopped.
|
|
122
|
+
*
|
|
123
|
+
* Throws when Docker cannot be asked. It used to catch that and return `[]`,
|
|
124
|
+
* which made "the Docker daemon is not running" indistinguishable from "there are
|
|
125
|
+
* no agents": a model toggle made while Docker was stopped synced to nobody and
|
|
126
|
+
* still reported success. An empty list now means only the second.
|
|
127
|
+
*
|
|
128
|
+
* Stopped agents are included on purpose. This used to run `docker ps` without
|
|
129
|
+
* `-a`, so a catalogue change made while an agent was stopped skipped it, and
|
|
130
|
+
* starting it later brought back the old catalogue until someone ran Sync All.
|
|
131
|
+
*/
|
|
132
|
+
function getAgentContainers(): AgentContainer[] {
|
|
133
|
+
let raw: string;
|
|
134
|
+
try {
|
|
135
|
+
raw = execSync(
|
|
136
|
+
`docker ps -a --filter "label=AGENT_ID" --format '{{.Names}}\t{{.State}}\t{{.Label "AGENT_ID"}}'`,
|
|
137
|
+
{ timeout: 5000, maxBuffer: 64 * 1024, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },
|
|
138
|
+
).trim();
|
|
139
|
+
} catch (e) {
|
|
140
|
+
throw new Error(firstLine(e));
|
|
58
141
|
}
|
|
142
|
+
if (!raw) return [];
|
|
143
|
+
return raw.split('\n')
|
|
144
|
+
.map((line) => {
|
|
145
|
+
const [name, state, agentId] = line.split('\t');
|
|
146
|
+
return { name: (name ?? '').trim(), running: (state ?? '').trim() === 'running', agentId: (agentId ?? '').trim() };
|
|
147
|
+
})
|
|
148
|
+
.filter((c) => c.name);
|
|
59
149
|
}
|
|
60
150
|
|
|
61
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Why Docker cannot be asked right now, or undefined when it can.
|
|
153
|
+
*
|
|
154
|
+
* For callers that must decide *before* committing a change whether its sync can
|
|
155
|
+
* happen at all. A model toggle made while Docker was unreachable used to be saved
|
|
156
|
+
* and then reach no container, leaving the Gateway's checkboxes out of step with
|
|
157
|
+
* what the agents hold; checking first lets it be refused cleanly instead.
|
|
158
|
+
*/
|
|
159
|
+
export function dockerUnreachableReason(): string | undefined {
|
|
62
160
|
try {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
);
|
|
67
|
-
|
|
161
|
+
getAgentContainers();
|
|
162
|
+
return undefined;
|
|
163
|
+
} catch (e) {
|
|
164
|
+
return (e as Error).message;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Read and parse a container's JSON config, running or stopped.
|
|
171
|
+
*
|
|
172
|
+
* Throws on any failure. It used to return `{}`, and the caller wrote that back
|
|
173
|
+
* with only the provider block added — so a single timed-out read replaced an
|
|
174
|
+
* agent's whole config (agents, gateway, channels) with `{ models: { providers:
|
|
175
|
+
* { rev4a } } }`. An empty object is refused for the same reason. Also returns
|
|
176
|
+
* the file mode, so a stopped container's file is written back unchanged in that
|
|
177
|
+
* respect.
|
|
178
|
+
*/
|
|
179
|
+
function readContainerJson(container: AgentContainer, remotePath: string): { config: Record<string, unknown>; mode: number } {
|
|
180
|
+
let raw: string;
|
|
181
|
+
let mode = 0o600;
|
|
182
|
+
if (container.running) {
|
|
183
|
+
raw = execFileSync('docker', ['exec', container.name, 'cat', remotePath], {
|
|
184
|
+
timeout: 8000, maxBuffer: 512 * 1024, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
185
|
+
});
|
|
186
|
+
} else {
|
|
187
|
+
const file = singleFileFromTar(execFileSync('docker', ['cp', `${container.name}:${remotePath}`, '-'], {
|
|
188
|
+
timeout: 8000, maxBuffer: 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
|
|
189
|
+
}));
|
|
190
|
+
raw = file.body.toString('utf-8');
|
|
191
|
+
mode = file.mode;
|
|
192
|
+
}
|
|
193
|
+
let parsed: unknown;
|
|
194
|
+
try {
|
|
195
|
+
parsed = JSON.parse(raw);
|
|
68
196
|
} catch {
|
|
69
|
-
|
|
197
|
+
// Not the parser's message: V8 quotes a slice of the input, this file holds
|
|
198
|
+
// tokens, and the error ends up in the sync summary shown in the browser.
|
|
199
|
+
throw new Error(`${remotePath} is not valid JSON; refusing to overwrite it`);
|
|
70
200
|
}
|
|
201
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || Object.keys(parsed).length === 0) {
|
|
202
|
+
throw new Error(`${remotePath} is empty or not a JSON object; refusing to overwrite it`);
|
|
203
|
+
}
|
|
204
|
+
return { config: parsed as Record<string, unknown>, mode };
|
|
71
205
|
}
|
|
72
206
|
|
|
73
|
-
|
|
207
|
+
/**
|
|
208
|
+
* Write a container's JSON config, running or stopped.
|
|
209
|
+
*
|
|
210
|
+
* Running: through `docker exec`, truncating the file in place so its mode and
|
|
211
|
+
* owner stay as they are, and OpenClaw hot-applies the change. Stopped: through
|
|
212
|
+
* `docker cp`, the only way to reach it; the file is staged on the host with the
|
|
213
|
+
* original mode, lands on the agent's volume, and is read at next start.
|
|
214
|
+
*/
|
|
215
|
+
function writeContainerJson(container: AgentContainer, remotePath: string, data: Record<string, unknown>, mode: number): void {
|
|
74
216
|
const jsonStr = JSON.stringify(data, null, 2);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
217
|
+
if (container.running) {
|
|
218
|
+
execFileSync('docker', ['exec', '-i', container.name, 'sh', '-c', 'cat > "$1"', 'sh', remotePath], {
|
|
219
|
+
timeout: 10000, maxBuffer: 1024 * 1024, input: jsonStr, stdio: ['pipe', 'pipe', 'pipe'],
|
|
220
|
+
});
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rev4a-sync-'));
|
|
224
|
+
const staged = path.join(dir, path.basename(remotePath));
|
|
225
|
+
try {
|
|
226
|
+
fs.writeFileSync(staged, jsonStr);
|
|
227
|
+
fs.chmodSync(staged, mode);
|
|
228
|
+
execFileSync('docker', ['cp', staged, `${container.name}:${remotePath}`], {
|
|
229
|
+
timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'],
|
|
230
|
+
});
|
|
231
|
+
} finally {
|
|
232
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
233
|
+
}
|
|
80
234
|
}
|
|
81
235
|
|
|
236
|
+
|
|
82
237
|
/* ------------------------------------------------------------------ */
|
|
83
238
|
/* Sync functions */
|
|
84
239
|
/* ------------------------------------------------------------------ */
|
|
85
240
|
|
|
86
241
|
/**
|
|
87
242
|
* Build the models.providers.rev4a config object from local state.
|
|
88
|
-
* Shared between the create
|
|
243
|
+
* Shared between the create route and syncAllAgents — single source of truth
|
|
89
244
|
* for the provider block injected into every agent container.
|
|
90
245
|
*/
|
|
91
|
-
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[] }[] } {
|
|
92
247
|
const providerKeys = readProviderKeys();
|
|
93
248
|
const rev4aApiKey = providerKeys['rev4a'] || '';
|
|
94
|
-
const
|
|
95
|
-
const activeModels = getActiveRev4aModels(allModels);
|
|
249
|
+
const activeModels = getActiveRev4aModels(version);
|
|
96
250
|
return {
|
|
97
251
|
baseUrl: PROVIDER_GATEWAY_BASE_URL,
|
|
98
252
|
apiKey: rev4aApiKey,
|
|
@@ -102,65 +256,170 @@ export function buildRev4aProviderConfig(): { baseUrl: string; apiKey: string; a
|
|
|
102
256
|
}
|
|
103
257
|
|
|
104
258
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
259
|
+
* Write `models.providers.rev4a` into one running container.
|
|
260
|
+
*
|
|
261
|
+
* Throws instead of writing when the catalogue could not be read: an unreadable
|
|
262
|
+
* catalogue is not an empty one, and patching it in would strip every model from
|
|
263
|
+
* the agent. `syncAllAgents()` refuses for the same reason.
|
|
264
|
+
*
|
|
265
|
+
* No gateway restart: `config patch` reports "Change will apply without restarting
|
|
266
|
+
* the gateway" for a models-only patch, and the Gateway watches openclaw.json with
|
|
267
|
+
* gateway.reload defaulting to hybrid.
|
|
268
|
+
*/
|
|
269
|
+
export function patchRev4aProvider(container: string): void {
|
|
270
|
+
if (bundledCatalogueStatus() === 'unavailable') {
|
|
271
|
+
throw new Error('models.config.json could not be read; models.providers.rev4a left unchanged');
|
|
272
|
+
}
|
|
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)) } } });
|
|
275
|
+
execFileSync('docker', ['exec', '-i', container, 'sh', '-c', 'openclaw config patch --stdin'], {
|
|
276
|
+
timeout: 15000,
|
|
277
|
+
stdio: 'pipe',
|
|
278
|
+
input,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** What a sync did, container by container. */
|
|
283
|
+
export interface SyncOutcome {
|
|
284
|
+
/**
|
|
285
|
+
* True only when every agent container received the catalogue. Zero agents is
|
|
286
|
+
* ok — there is nothing to sync, which is the normal state on first run.
|
|
287
|
+
*/
|
|
288
|
+
ok: boolean;
|
|
289
|
+
/** Models written into each container. */
|
|
290
|
+
activeModels: number;
|
|
291
|
+
/** Agent containers found. */
|
|
292
|
+
total: number;
|
|
293
|
+
synced: string[];
|
|
294
|
+
/** Of `synced`, the ones not running (stopped, paused, created): written with `docker cp`. Applied when started, or resumed. */
|
|
295
|
+
stopped: string[];
|
|
296
|
+
failed: { container: string; error: string }[];
|
|
297
|
+
/** Docker could not list containers at all, so nothing was attempted. */
|
|
298
|
+
dockerError?: string;
|
|
299
|
+
/** The catalogue block could not be built, so nothing was attempted. */
|
|
300
|
+
configError?: string;
|
|
301
|
+
/** One line for a person. Built here so every caller says the same thing. */
|
|
302
|
+
summary: string;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** ", 2 of them not running" — those apply the change later, which is worth saying. */
|
|
306
|
+
function stoppedNote(outcome: SyncOutcome): string {
|
|
307
|
+
return outcome.stopped.length ? `, ${outcome.stopped.length} of them not running` : '';
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Push the offered catalogue into every agent container, and say exactly what
|
|
312
|
+
* happened.
|
|
313
|
+
*
|
|
314
|
+
* Returns an outcome instead of throwing: callers run this *after* committing a
|
|
315
|
+
* change — a key saved, a model toggled — and a failed propagation must not be
|
|
316
|
+
* reported as a failed save, because the save stands. The old signature returned
|
|
317
|
+
* loose strings that no caller inspected, so a sync that reached no container
|
|
318
|
+
* surfaced as success.
|
|
107
319
|
*/
|
|
108
|
-
export function syncAllAgents():
|
|
320
|
+
export function syncAllAgents(): SyncOutcome {
|
|
109
321
|
const rev4aProviderConfig = buildRev4aProviderConfig();
|
|
322
|
+
const outcome: SyncOutcome = {
|
|
323
|
+
ok: false,
|
|
324
|
+
activeModels: rev4aProviderConfig.models.length,
|
|
325
|
+
total: 0,
|
|
326
|
+
synced: [],
|
|
327
|
+
stopped: [],
|
|
328
|
+
failed: [],
|
|
329
|
+
summary: '',
|
|
330
|
+
};
|
|
331
|
+
|
|
110
332
|
if (!rev4aProviderConfig.apiKey) {
|
|
111
|
-
|
|
333
|
+
outcome.configError = 'REV4A_API_KEY not found in provider-keys.json (add "rev4a" key)';
|
|
334
|
+
outcome.summary = `Not applied to any agent: ${outcome.configError}.`;
|
|
335
|
+
return outcome;
|
|
112
336
|
}
|
|
113
337
|
|
|
114
|
-
|
|
115
|
-
|
|
338
|
+
// A catalogue that could not be read is not an empty catalogue: pushing it would
|
|
339
|
+
// strip every model from every agent.
|
|
340
|
+
if (bundledCatalogueStatus() === 'unavailable') {
|
|
341
|
+
outcome.configError = 'models.config.json could not be read, so there is no catalogue to push';
|
|
342
|
+
outcome.summary = `Not applied to any agent: ${outcome.configError}.`;
|
|
343
|
+
return outcome;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
let containers: AgentContainer[];
|
|
347
|
+
try {
|
|
348
|
+
containers = getAgentContainers();
|
|
349
|
+
} catch (e) {
|
|
350
|
+
outcome.dockerError = (e as Error).message;
|
|
351
|
+
outcome.summary = `Not applied to any agent: Docker is unreachable (${outcome.dockerError}).`;
|
|
352
|
+
return outcome;
|
|
353
|
+
}
|
|
116
354
|
|
|
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
|
+
}
|
|
117
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
|
+
}
|
|
118
378
|
try {
|
|
119
|
-
|
|
120
|
-
|
|
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)));
|
|
382
|
+
outcome.synced.push(container.name);
|
|
383
|
+
if (!container.running) outcome.stopped.push(container.name);
|
|
121
384
|
} catch (e: unknown) {
|
|
122
|
-
|
|
123
|
-
results.push(`${container}: ERROR ${err.stderr || err.message || 'unknown'}`);
|
|
385
|
+
outcome.failed.push({ container: container.name, error: firstLine(e) });
|
|
124
386
|
}
|
|
125
387
|
}
|
|
126
388
|
|
|
127
|
-
|
|
128
|
-
|
|
389
|
+
outcome.ok = outcome.failed.length === 0;
|
|
390
|
+
if (outcome.total === 0) {
|
|
391
|
+
outcome.summary = 'No agent containers to sync.';
|
|
392
|
+
} else if (outcome.ok) {
|
|
393
|
+
outcome.summary = `Synced ${outcome.synced.length} of ${outcome.total} agent(s)${stoppedNote(outcome)}.`;
|
|
394
|
+
} else {
|
|
395
|
+
const names = outcome.failed.map((f) => `${f.container} (${f.error})`).join(', ');
|
|
396
|
+
outcome.summary = `Synced ${outcome.synced.length} of ${outcome.total} agent(s)${stoppedNote(outcome)}. Not applied to: ${names}.`;
|
|
129
397
|
}
|
|
130
|
-
|
|
131
|
-
return results;
|
|
398
|
+
return outcome;
|
|
132
399
|
}
|
|
133
400
|
|
|
134
401
|
/**
|
|
135
|
-
*
|
|
136
|
-
* Does NOT touch agents.defaults.model or agents.list[].model.
|
|
137
|
-
*/
|
|
138
|
-
export function syncAgent(containerName: string): string {
|
|
139
|
-
const rev4aProviderConfig = buildRev4aProviderConfig();
|
|
140
|
-
if (!rev4aProviderConfig.apiKey) {
|
|
141
|
-
throw new Error('REV4A_API_KEY not found in provider-keys.json (add "rev4a" key)');
|
|
142
|
-
}
|
|
143
|
-
return syncContainer(containerName, rev4aProviderConfig);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Internal: write models.providers.rev4a to one container and restart its gateway.
|
|
402
|
+
* Internal: write models.providers.rev4a into one container's openclaw.json.
|
|
148
403
|
*/
|
|
149
404
|
function syncContainer(
|
|
150
|
-
container:
|
|
405
|
+
container: AgentContainer,
|
|
151
406
|
rev4aProviderConfig: { baseUrl: string; apiKey: string; api: string; models: { id: string; name: string }[] },
|
|
152
407
|
): string {
|
|
153
408
|
const remotePath = '/root/.openclaw/openclaw.json';
|
|
154
|
-
|
|
409
|
+
// Read first. If this throws, the container is reported as failed and nothing
|
|
410
|
+
// below runs — in particular nothing is written.
|
|
411
|
+
const { config, mode } = readContainerJson(container, remotePath);
|
|
155
412
|
|
|
156
|
-
// Clean up stale files from old versions
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
413
|
+
// Clean up stale files from old versions. Needs exec, so running containers
|
|
414
|
+
// only; a stopped one is cleaned on the first sync after it starts.
|
|
415
|
+
if (container.running) {
|
|
416
|
+
try {
|
|
417
|
+
execFileSync('docker', ['exec', container.name, 'rm', '-f',
|
|
418
|
+
'/root/.openclaw/agents/main/agent/models.json',
|
|
419
|
+
'/root/.openclaw/agents/main/agent/auth-profiles.json'], { timeout: 5000, stdio: 'ignore' });
|
|
420
|
+
} catch {
|
|
421
|
+
// non-fatal
|
|
422
|
+
}
|
|
164
423
|
}
|
|
165
424
|
|
|
166
425
|
// Write/overwrite models.providers.rev4a — do NOT touch agents.*
|
|
@@ -169,7 +428,7 @@ function syncContainer(
|
|
|
169
428
|
(models.providers as Record<string, unknown>)['rev4a'] = rev4aProviderConfig;
|
|
170
429
|
config.models = models;
|
|
171
430
|
|
|
172
|
-
writeContainerJson(container, remotePath, config);
|
|
431
|
+
writeContainerJson(container, remotePath, config, mode);
|
|
173
432
|
|
|
174
433
|
// No gateway restart: this only writes models.providers.rev4a, and the Gateway
|
|
175
434
|
// watches openclaw.json and hot-applies it. OpenClaw's own config tooling says
|
|
@@ -182,5 +441,5 @@ function syncContainer(
|
|
|
182
441
|
// The restart used to cost ~7.5s per container, serially — roughly 52s of
|
|
183
442
|
// fully blocked event loop on a seven-agent host, every time a provider key or
|
|
184
443
|
// model toggle was saved, to do something the Gateway had already done.
|
|
185
|
-
return `${container}: synced`;
|
|
444
|
+
return `${container.name}: synced`;
|
|
186
445
|
}
|