@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
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The OpenClaw version of one agent, the Update action and its Rollback, in the
|
|
5
|
+
* agent's detail panel.
|
|
6
|
+
*
|
|
7
|
+
* An update stops the agent, takes a cold backup, recreates it on the newer version
|
|
8
|
+
* (which migrates its data one way), then checks that no transcript events or cron jobs
|
|
9
|
+
* went missing. A rollback restores that backup on the previous version. Data comes from
|
|
10
|
+
* `/api/agents/[id]/update`, polled every 2 s while either runs. See lib/agent-update.ts.
|
|
11
|
+
*/
|
|
12
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
13
|
+
import { Badge, Button, ConfirmModal } from '../components/ui';
|
|
14
|
+
import { SkeletonLines } from '../components/Skeleton';
|
|
15
|
+
|
|
16
|
+
type UpdateStatus =
|
|
17
|
+
| 'pending' | 'backing_up' | 'migrating' | 'verifying' | 'done'
|
|
18
|
+
| 'failed' | 'interrupted' | 'rolling_back' | 'rolled_back' | 'rollback_failed';
|
|
19
|
+
|
|
20
|
+
interface Verification {
|
|
21
|
+
sessions: number;
|
|
22
|
+
lostEvents: { session: string; before: number; after: number }[];
|
|
23
|
+
missingCronJobs: string[] | null;
|
|
24
|
+
ok: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface UpdateView {
|
|
28
|
+
id: number;
|
|
29
|
+
status: UpdateStatus;
|
|
30
|
+
fromVersion: string | null;
|
|
31
|
+
toVersion: string;
|
|
32
|
+
backupFile: string | null;
|
|
33
|
+
backupPercent: number | null;
|
|
34
|
+
verification: Verification | null;
|
|
35
|
+
error: string | null;
|
|
36
|
+
startedAtMs: number;
|
|
37
|
+
finishedAtMs: number | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface UpdateInfo {
|
|
41
|
+
currentVersion: string | null;
|
|
42
|
+
candidate: { fromVersion: string; toVersion: string } | null;
|
|
43
|
+
update: UpdateView | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type Action = 'update' | 'rollback';
|
|
47
|
+
|
|
48
|
+
const ACTIVE: UpdateStatus[] = ['pending', 'backing_up', 'migrating', 'verifying', 'rolling_back'];
|
|
49
|
+
const ROLLBACK_FROM: UpdateStatus[] = ['done', 'failed', 'interrupted', 'rollback_failed'];
|
|
50
|
+
const POLL_MS = 2_000;
|
|
51
|
+
|
|
52
|
+
const STEPS: { status: UpdateStatus; label: string }[] = [
|
|
53
|
+
{ status: 'backing_up', label: 'Cold backup' },
|
|
54
|
+
{ status: 'migrating', label: 'Start on the new version' },
|
|
55
|
+
{ status: 'verifying', label: 'Check transcripts and cron jobs' },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const metaStyle: React.CSSProperties = { fontSize: 11, color: 'var(--text-dim)', wordBreak: 'break-word' };
|
|
59
|
+
|
|
60
|
+
function when(ms: number | null): string {
|
|
61
|
+
return ms ? new Date(ms).toLocaleString() : '';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export default function UpdateSection({
|
|
65
|
+
agentId,
|
|
66
|
+
running,
|
|
67
|
+
onChanged,
|
|
68
|
+
onStarted,
|
|
69
|
+
}: {
|
|
70
|
+
agentId: string;
|
|
71
|
+
running: boolean;
|
|
72
|
+
/** An update or rollback finished: the parent reloads the agent (new image, new version). */
|
|
73
|
+
onChanged?: () => void;
|
|
74
|
+
/** The update or rollback started — show the UPDATING chip at once. */
|
|
75
|
+
onStarted?: () => void;
|
|
76
|
+
}) {
|
|
77
|
+
const [info, setInfo] = useState<UpdateInfo | null>(null);
|
|
78
|
+
const [loadError, setLoadError] = useState<string | null>(null);
|
|
79
|
+
const [busy, setBusy] = useState<Action | null>(null);
|
|
80
|
+
const busyRef = useRef<Action | null>(null);
|
|
81
|
+
const [confirming, setConfirming] = useState<Action | null>(null);
|
|
82
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
83
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
84
|
+
const lastStatus = useRef<UpdateStatus | null>(null);
|
|
85
|
+
|
|
86
|
+
const load = useCallback(async () => {
|
|
87
|
+
abortRef.current?.abort();
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
abortRef.current = controller;
|
|
90
|
+
try {
|
|
91
|
+
const res = await fetch(`/api/agents/${encodeURIComponent(agentId)}/update`, { signal: controller.signal, cache: 'no-store' });
|
|
92
|
+
if (abortRef.current !== controller) return;
|
|
93
|
+
const data = (await res.json().catch(() => null)) as (UpdateInfo & { error?: string }) | null;
|
|
94
|
+
if (abortRef.current !== controller) return;
|
|
95
|
+
if (!res.ok || !data) {
|
|
96
|
+
setLoadError(data?.error ?? `Could not load the update state (HTTP ${res.status})`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
setInfo(data);
|
|
100
|
+
setLoadError(null);
|
|
101
|
+
const status = data.update?.status ?? null;
|
|
102
|
+
if (lastStatus.current && ACTIVE.includes(lastStatus.current) && status && !ACTIVE.includes(status)) onChanged?.();
|
|
103
|
+
lastStatus.current = status;
|
|
104
|
+
} catch (e) {
|
|
105
|
+
if ((e as Error)?.name !== 'AbortError' && abortRef.current === controller) setLoadError('Could not load the update state');
|
|
106
|
+
}
|
|
107
|
+
}, [agentId, onChanged]);
|
|
108
|
+
|
|
109
|
+
const update = info?.update ?? null;
|
|
110
|
+
const active = !!update && ACTIVE.includes(update.status);
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
setInfo(null);
|
|
114
|
+
setLoadError(null);
|
|
115
|
+
lastStatus.current = null;
|
|
116
|
+
void load();
|
|
117
|
+
return () => abortRef.current?.abort();
|
|
118
|
+
}, [load]);
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (!active) return;
|
|
122
|
+
const timer = setInterval(() => { void load(); }, POLL_MS);
|
|
123
|
+
return () => clearInterval(timer);
|
|
124
|
+
}, [active, load]);
|
|
125
|
+
|
|
126
|
+
const run = async (action: Action) => {
|
|
127
|
+
setConfirming(null);
|
|
128
|
+
if (busyRef.current) return;
|
|
129
|
+
busyRef.current = action;
|
|
130
|
+
setBusy(action);
|
|
131
|
+
setActionError(null);
|
|
132
|
+
try {
|
|
133
|
+
const res = action === 'update'
|
|
134
|
+
? await fetch(`/api/agents/${encodeURIComponent(agentId)}/update`, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: { 'Content-Type': 'application/json' },
|
|
137
|
+
body: JSON.stringify({ version: info?.candidate?.toVersion }),
|
|
138
|
+
})
|
|
139
|
+
: await fetch(`/api/agents/${encodeURIComponent(agentId)}/update/rollback`, { method: 'POST' });
|
|
140
|
+
if (!res.ok) {
|
|
141
|
+
const data = (await res.json().catch(() => null)) as { error?: string } | null;
|
|
142
|
+
setActionError(data?.error ?? `Could not start the ${action} (HTTP ${res.status})`);
|
|
143
|
+
} else {
|
|
144
|
+
onStarted?.();
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
setActionError(`Could not start the ${action}`);
|
|
148
|
+
}
|
|
149
|
+
busyRef.current = null;
|
|
150
|
+
setBusy(null);
|
|
151
|
+
void load();
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
if (!info && !loadError) return <SkeletonLines count={2} />;
|
|
155
|
+
|
|
156
|
+
const candidate = info?.candidate ?? null;
|
|
157
|
+
const stepIndex = update ? STEPS.findIndex((s) => s.status === update.status) : -1;
|
|
158
|
+
const canRollBack = !!update && !active && ROLLBACK_FROM.includes(update.status) && !!update.backupFile && !!update.fromVersion;
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div>
|
|
162
|
+
{loadError && (
|
|
163
|
+
<div style={{ margin: '0 0 6px' }}>
|
|
164
|
+
<Badge tone="danger"><span style={{ fontSize: 10 }}>{loadError}</span></Badge>
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
<div style={{ fontSize: 12, color: 'var(--text)', marginBottom: 6 }}>
|
|
169
|
+
OpenClaw {info?.currentVersion ?? 'version unknown'}
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
{update && active && update.status !== 'rolling_back' && (
|
|
173
|
+
<div style={{ margin: '6px 0 10px' }}>
|
|
174
|
+
<div style={metaStyle}>Updating OpenClaw {update.fromVersion} → {update.toVersion}. The agent is offline until it finishes.</div>
|
|
175
|
+
{STEPS.map((step, i) => {
|
|
176
|
+
const state = update.status === 'pending' ? 'waiting' : i < stepIndex ? 'done' : i === stepIndex ? 'running' : 'waiting';
|
|
177
|
+
return (
|
|
178
|
+
<div key={step.status} style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '3px 0', fontSize: 11 }}>
|
|
179
|
+
<span style={{ width: 14, color: state === 'done' ? 'var(--green)' : state === 'running' ? 'var(--violet)' : 'var(--text-dim)' }}>
|
|
180
|
+
{state === 'done' ? '✓' : state === 'running' ? '●' : '○'}
|
|
181
|
+
</span>
|
|
182
|
+
<span style={{ color: state === 'waiting' ? 'var(--text-dim)' : 'var(--text)' }}>
|
|
183
|
+
{step.label}
|
|
184
|
+
{state === 'running' && step.status === 'backing_up' && update.backupPercent !== null ? ` — ${update.backupPercent}%` : ''}
|
|
185
|
+
</span>
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
})}
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
|
|
192
|
+
{update && update.status === 'rolling_back' && (
|
|
193
|
+
<div style={{ margin: '6px 0 10px', ...metaStyle }}>
|
|
194
|
+
Rolling back to OpenClaw {update.fromVersion}: restoring {update.backupFile}. The agent is offline until it finishes.
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
|
|
198
|
+
{update && update.status === 'done' && (
|
|
199
|
+
<div style={{ margin: '6px 0 10px' }}>
|
|
200
|
+
<Badge tone="success"><span style={{ fontSize: 10 }}>Updated from OpenClaw {update.fromVersion} to {update.toVersion}</span></Badge>
|
|
201
|
+
<div style={metaStyle}>
|
|
202
|
+
{when(update.finishedAtMs)}
|
|
203
|
+
{update.verification ? ` · ${update.verification.sessions} session(s) checked` : ''}
|
|
204
|
+
{update.backupFile ? ` · pre-update backup ${update.backupFile}` : ''}
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
|
|
209
|
+
{update && (update.status === 'failed' || update.status === 'interrupted') && (
|
|
210
|
+
<div style={{ margin: '6px 0 10px' }}>
|
|
211
|
+
<Badge tone="danger">
|
|
212
|
+
<span style={{ fontSize: 10 }}>
|
|
213
|
+
Update to OpenClaw {update.toVersion} {update.status === 'interrupted' ? 'was interrupted' : 'failed'}
|
|
214
|
+
</span>
|
|
215
|
+
</Badge>
|
|
216
|
+
{update.error && <div style={metaStyle}>{update.error}</div>}
|
|
217
|
+
{update.verification && update.verification.lostEvents.length > 0 && (
|
|
218
|
+
<div style={metaStyle}>
|
|
219
|
+
{update.verification.lostEvents.map((l) => `${l.session}: ${l.before} → ${l.after}`).join(' · ')}
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
{update.backupFile && <div style={metaStyle}>Pre-update backup: {update.backupFile}</div>}
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
|
|
226
|
+
{update && update.status === 'rolled_back' && (
|
|
227
|
+
<div style={{ margin: '6px 0 10px' }}>
|
|
228
|
+
<Badge tone="warning"><span style={{ fontSize: 10 }}>Rolled back to OpenClaw {update.fromVersion}</span></Badge>
|
|
229
|
+
<div style={metaStyle}>{when(update.finishedAtMs)} · restored {update.backupFile}</div>
|
|
230
|
+
</div>
|
|
231
|
+
)}
|
|
232
|
+
|
|
233
|
+
{update && update.status === 'rollback_failed' && (
|
|
234
|
+
<div style={{ margin: '6px 0 10px' }}>
|
|
235
|
+
<Badge tone="danger"><span style={{ fontSize: 10 }}>Rollback to OpenClaw {update.fromVersion} failed</span></Badge>
|
|
236
|
+
{update.error && <div style={metaStyle}>{update.error}</div>}
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
|
|
240
|
+
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 6 }}>
|
|
241
|
+
{candidate && !active && (
|
|
242
|
+
<Button
|
|
243
|
+
variant="primary"
|
|
244
|
+
size="sm"
|
|
245
|
+
loading={busy === 'update'}
|
|
246
|
+
disabled={busy !== null || !running}
|
|
247
|
+
title={running ? undefined : 'Start the agent first: its data is counted while it runs'}
|
|
248
|
+
onClick={() => setConfirming('update')}
|
|
249
|
+
>
|
|
250
|
+
{busy === 'update' ? 'Starting…' : `Update to ${candidate.toVersion}`}
|
|
251
|
+
</Button>
|
|
252
|
+
)}
|
|
253
|
+
{canRollBack && update && (
|
|
254
|
+
<Button
|
|
255
|
+
variant="secondary"
|
|
256
|
+
size="sm"
|
|
257
|
+
loading={busy === 'rollback'}
|
|
258
|
+
disabled={busy !== null}
|
|
259
|
+
onClick={() => setConfirming('rollback')}
|
|
260
|
+
>
|
|
261
|
+
{busy === 'rollback' ? 'Starting…' : `Roll back to ${update.fromVersion}`}
|
|
262
|
+
</Button>
|
|
263
|
+
)}
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
{actionError && (
|
|
267
|
+
<div style={{ margin: '6px 0' }}>
|
|
268
|
+
<Badge tone="danger"><span style={{ fontSize: 10 }}>{actionError}</span></Badge>
|
|
269
|
+
</div>
|
|
270
|
+
)}
|
|
271
|
+
|
|
272
|
+
<ConfirmModal
|
|
273
|
+
open={confirming === 'update'}
|
|
274
|
+
onClose={() => setConfirming(null)}
|
|
275
|
+
onConfirm={() => { void run('update'); }}
|
|
276
|
+
title={candidate ? `Update to OpenClaw ${candidate.toVersion}?` : 'Update?'}
|
|
277
|
+
message={
|
|
278
|
+
'The agent goes offline, its volume is backed up while stopped, and it starts again on the new version, ' +
|
|
279
|
+
'which migrates its data. The migration cannot be undone in place: going back restores this backup, ' +
|
|
280
|
+
'and anything the agent does after the update is lost.'
|
|
281
|
+
}
|
|
282
|
+
confirmLabel="Update"
|
|
283
|
+
cancelLabel="Cancel"
|
|
284
|
+
/>
|
|
285
|
+
<ConfirmModal
|
|
286
|
+
open={confirming === 'rollback'}
|
|
287
|
+
onClose={() => setConfirming(null)}
|
|
288
|
+
onConfirm={() => { void run('rollback'); }}
|
|
289
|
+
title={update?.fromVersion ? `Roll back to OpenClaw ${update.fromVersion}?` : 'Roll back?'}
|
|
290
|
+
message={
|
|
291
|
+
`The agent goes offline, its volume is replaced by the pre-update backup ${update?.backupFile ?? ''} ` +
|
|
292
|
+
'and it starts again on the previous version. Everything the agent did after that backup is lost.'
|
|
293
|
+
}
|
|
294
|
+
confirmLabel="Roll back"
|
|
295
|
+
cancelLabel="Cancel"
|
|
296
|
+
tone="danger"
|
|
297
|
+
/>
|
|
298
|
+
</div>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import { useState, useMemo, useEffect } from 'react';
|
|
4
4
|
import { useRouter } from 'next/navigation';
|
|
5
5
|
import { Button, Input, TemplateOption } from '../../components/ui';
|
|
6
|
+
import { OpenControlUiButton } from '../BrowserAccessSection';
|
|
7
|
+
import { findAvailablePortBlock, validatePortInput } from '@/lib/agent-ports';
|
|
6
8
|
|
|
7
9
|
interface TemplateInfo {
|
|
8
10
|
id: string;
|
|
@@ -26,19 +28,6 @@ type Step = 'template' | 'config' | 'creating' | 'done' | 'error';
|
|
|
26
28
|
|
|
27
29
|
const REV4A_PORT = 3740;
|
|
28
30
|
|
|
29
|
-
function findFirstFreeBlock(usedPorts: number[]): number | null {
|
|
30
|
-
const used = new Set(usedPorts);
|
|
31
|
-
for (let base = 3700; base + 9 <= 3799; base += 10) {
|
|
32
|
-
if (base <= REV4A_PORT && REV4A_PORT <= base + 9) continue;
|
|
33
|
-
const block = Array.from({ length: 10 }, (_, i) => base + i);
|
|
34
|
-
if (block.every((p) => !used.has(p))) return base;
|
|
35
|
-
}
|
|
36
|
-
// Fallback beyond range
|
|
37
|
-
for (let base = 3800; ; base += 10) {
|
|
38
|
-
const block = Array.from({ length: 10 }, (_, i) => base + i);
|
|
39
|
-
if (block.every((p) => !used.has(p))) return base;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
31
|
|
|
43
32
|
// ─── Toast ───
|
|
44
33
|
function Toast({ message, type, onClose }: { message: string; type: 'success' | 'error'; onClose: () => void }) {
|
|
@@ -77,16 +66,9 @@ export default function CreatePageClient({ templates, models, usedNames, usedPor
|
|
|
77
66
|
const [fallbacks] = useState<string[]>([]);
|
|
78
67
|
const [submitting, setSubmitting] = useState(false);
|
|
79
68
|
const [errorMsg, setErrorMsg] = useState('');
|
|
80
|
-
const [result, setResult] = useState<{
|
|
69
|
+
const [result, setResult] = useState<{ containerName: string; displayName: string; containerId: string; port: number } | null>(null);
|
|
81
70
|
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
|
82
71
|
|
|
83
|
-
// The agent's Control UI URL, composed here rather than server-side: whatever
|
|
84
|
-
// hostname reached this dashboard is one that resolves and routes for this
|
|
85
|
-
// user, which the server can only guess at. Null until the create succeeds.
|
|
86
|
-
const controlUrl = result && typeof window !== 'undefined'
|
|
87
|
-
? `http://${window.location.hostname}:${result.port}#token=${encodeURIComponent(result.controlToken)}`
|
|
88
|
-
: null;
|
|
89
|
-
|
|
90
72
|
const handleSelectTemplate = (id: string) => {
|
|
91
73
|
setSelectedTemplate(id);
|
|
92
74
|
setAgentName('');
|
|
@@ -95,7 +77,7 @@ export default function CreatePageClient({ templates, models, usedNames, usedPor
|
|
|
95
77
|
setPortRange('3700-3709');
|
|
96
78
|
} else {
|
|
97
79
|
// Simple client-side block finder
|
|
98
|
-
|
|
80
|
+
const block = initialUsedPorts.length ? findAvailablePortBlock(new Set(initialUsedPorts)) : null;
|
|
99
81
|
setPortRange(block ? `${block}-${block + 9}` : '');
|
|
100
82
|
}
|
|
101
83
|
};
|
|
@@ -108,26 +90,9 @@ export default function CreatePageClient({ templates, models, usedNames, usedPor
|
|
|
108
90
|
}, [agentName, usedNames]);
|
|
109
91
|
|
|
110
92
|
const portError = useMemo(() => {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const m = v.match(/^(\d{1,5})-(\d{1,5})$/);
|
|
115
|
-
if (!m) return 'Use format: 3700-3709';
|
|
116
|
-
const start = parseInt(m[1], 10);
|
|
117
|
-
const end = parseInt(m[2], 10);
|
|
118
|
-
if (start < 1 || end > 65535) return 'Ports 1-65535';
|
|
119
|
-
if (end <= start) return 'End port must be greater than start';
|
|
120
|
-
for (let p = start; p <= end; p++) {
|
|
121
|
-
if (initialUsedPorts.includes(p)) return `Port ${p} is already in use`;
|
|
122
|
-
if (p === 3740) return 'Port 3740 is reserved';
|
|
123
|
-
}
|
|
124
|
-
return '';
|
|
125
|
-
}
|
|
126
|
-
const p = parseInt(v, 10);
|
|
127
|
-
if (isNaN(p) || p < 1 || p > 65535) return 'Port must be between 1 and 65535';
|
|
128
|
-
if (p === 3740) return 'Port 3740 is reserved';
|
|
129
|
-
if (initialUsedPorts.includes(p)) return 'Port is already in use';
|
|
130
|
-
return '';
|
|
93
|
+
if (!portRange.trim()) return '';
|
|
94
|
+
const res = validatePortInput(portRange, new Set(initialUsedPorts));
|
|
95
|
+
return res.valid ? '' : res.error;
|
|
131
96
|
}, [portRange, initialUsedPorts]);
|
|
132
97
|
|
|
133
98
|
const canProceed = (): boolean => {
|
|
@@ -394,14 +359,11 @@ export default function CreatePageClient({ templates, models, usedNames, usedPor
|
|
|
394
359
|
Container: <span style={{ color: 'var(--text)', fontFamily: 'var(--font-mono-stack)' }}>{result.containerId}</span>
|
|
395
360
|
</div>
|
|
396
361
|
<div style={{ fontSize: 13, color: '#888', marginBottom: 4 }}>
|
|
397
|
-
Name: <span style={{ color: 'var(--text)' }}>{result.
|
|
362
|
+
Name: <span style={{ color: 'var(--text)' }}>{result.displayName || result.containerName}</span>
|
|
363
|
+
</div>
|
|
364
|
+
<div style={{ marginTop: 8 }}>
|
|
365
|
+
<OpenControlUiButton agentId={result.containerName} label="Open Control UI ↗" />
|
|
398
366
|
</div>
|
|
399
|
-
{controlUrl && (
|
|
400
|
-
<div style={{ fontSize: 13, color: '#888' }}>
|
|
401
|
-
Access: <a href={controlUrl} target="_blank" rel="noopener noreferrer"
|
|
402
|
-
style={{ color: '#60a5fa', textDecoration: 'underline' }}>{controlUrl}</a>
|
|
403
|
-
</div>
|
|
404
|
-
)}
|
|
405
367
|
</div>
|
|
406
368
|
<div style={{ display: 'flex', gap: 12 }}>
|
|
407
369
|
<Button variant="ghost" size="md" onClick={() => { setToast(null); router.push('/agents'); }} style={{ flex: 1 }}>
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { execSync } from 'child_process';
|
|
2
2
|
import * as fs from 'fs';
|
|
3
3
|
import * as path from 'path';
|
|
4
|
-
import { DB_FILE, AGENTS_TOKEN_FILE,
|
|
4
|
+
import { DB_FILE, AGENTS_TOKEN_FILE, templatesDir } from '@/lib/rev4a-paths';
|
|
5
|
+
import { loadOfferedModels } from '@/lib/model-catalogue';
|
|
5
6
|
import CreatePageClient from './PageClient';
|
|
6
7
|
|
|
7
8
|
export const dynamic = 'force-dynamic';
|
|
@@ -33,26 +34,12 @@ async function getTemplates(): Promise<TemplateInfo[]> {
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
async function getModels(): Promise<ModelInfo[]> {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
try {
|
|
43
|
-
const keysRaw = fs.readFileSync(PROVIDER_KEYS_FILE, 'utf-8');
|
|
44
|
-
configuredProviders = Object.keys(JSON.parse(keysRaw));
|
|
45
|
-
} catch { /* no keys configured yet */ }
|
|
46
|
-
|
|
47
|
-
if (configuredProviders.length > 0) {
|
|
48
|
-
return allModels.filter((m) => configuredProviders.includes(m.provider));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// No providers configured — show no models
|
|
52
|
-
return [];
|
|
53
|
-
} catch {
|
|
54
|
-
return [];
|
|
55
|
-
}
|
|
37
|
+
// Through the shared definition, not a raw file read. Reading the bundle
|
|
38
|
+
// directly skipped model-overrides.json, so a model the operator had switched
|
|
39
|
+
// off on the Gateway page was still offered here — and one they had switched
|
|
40
|
+
// on was missing. On the production host that was 58 models listed against 62
|
|
41
|
+
// correct: two offered that should not be, six absent that should be.
|
|
42
|
+
return loadOfferedModels().map((m) => ({ id: m.id, name: m.name, provider: m.provider }));
|
|
56
43
|
}
|
|
57
44
|
|
|
58
45
|
async function getUsedNames(): Promise<string[]> {
|
|
@@ -1,36 +1,12 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
|
-
import { execSync } from 'child_process';
|
|
3
2
|
import { isValidAgentId } from '@/lib/container';
|
|
4
3
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
4
|
+
import { runDocker } from '@/lib/agent-images';
|
|
5
5
|
|
|
6
6
|
export const dynamic = 'force-dynamic';
|
|
7
7
|
|
|
8
8
|
const BACKUP_VOLUME = 'rev4a-backups';
|
|
9
9
|
|
|
10
|
-
/**
|
|
11
|
-
* Determine the volume name for an agent by checking if it exists.
|
|
12
|
-
*/
|
|
13
|
-
function agentVolumeName(agentName: string): string | null {
|
|
14
|
-
try {
|
|
15
|
-
const vols = execSync(
|
|
16
|
-
"docker volume ls --format '{{.Name}}'",
|
|
17
|
-
{ encoding: 'utf-8', timeout: 5000 },
|
|
18
|
-
).trim().split('\n').filter(Boolean);
|
|
19
|
-
const needle = `agent-${agentName}-data`;
|
|
20
|
-
return vols.includes(needle) ? needle : null;
|
|
21
|
-
} catch {
|
|
22
|
-
return null;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Format a timestamp as YYYY-MM-DD_HHmmssSSS (ms precision avoids same-second collisions). */
|
|
27
|
-
function ts(): string {
|
|
28
|
-
const d = new Date();
|
|
29
|
-
const pad = (n: number) => String(n).padStart(2, '0');
|
|
30
|
-
const ms = String(d.getMilliseconds()).padStart(3, '0');
|
|
31
|
-
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}${ms}`;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
10
|
/** Convert bytes to human-readable size. */
|
|
35
11
|
function fmtSize(bytes: number): string {
|
|
36
12
|
if (bytes < 1024) return `${bytes} B`;
|
|
@@ -39,40 +15,6 @@ function fmtSize(bytes: number): string {
|
|
|
39
15
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
40
16
|
}
|
|
41
17
|
|
|
42
|
-
/**
|
|
43
|
-
* POST /api/agents/[id]/backup
|
|
44
|
-
* Create a backup of the agent's persistent volume.
|
|
45
|
-
*/
|
|
46
|
-
export async function POST(request: Request,
|
|
47
|
-
{ params }: { params: Promise<{ id: string }> },
|
|
48
|
-
): Promise<NextResponse> {
|
|
49
|
-
const denied = await requireAuthJWT(request); if (denied) return denied as any;
|
|
50
|
-
try {
|
|
51
|
-
const { id } = await params;
|
|
52
|
-
if (!id) return NextResponse.json({ error: 'Agent name required' }, { status: 400 });
|
|
53
|
-
if (!isValidAgentId(id)) return NextResponse.json({ error: 'Invalid agent id' }, { status: 400 });
|
|
54
|
-
|
|
55
|
-
const volume = agentVolumeName(id);
|
|
56
|
-
if (!volume) {
|
|
57
|
-
return NextResponse.json({ error: `No persistent volume found for agent '${id}'` }, { status: 404 });
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const filename = `agent-${id}-${ts()}.tar.gz`;
|
|
61
|
-
|
|
62
|
-
execSync(
|
|
63
|
-
`docker run --rm ` +
|
|
64
|
-
`-v ${volume}:/source:ro ` +
|
|
65
|
-
`-v ${BACKUP_VOLUME}:/backup ` +
|
|
66
|
-
`alpine sh -c 'tar czf /backup/${filename} --exclude=.npm/_cacache -C /source . && chmod 644 /backup/${filename}'`,
|
|
67
|
-
{ timeout: 60000, stdio: 'pipe' },
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
return NextResponse.json({ success: true, file: filename });
|
|
71
|
-
} catch (e: unknown) {
|
|
72
|
-
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
18
|
interface BackupFile {
|
|
77
19
|
name: string;
|
|
78
20
|
size: string;
|
|
@@ -96,12 +38,22 @@ export async function GET(request: Request,
|
|
|
96
38
|
|
|
97
39
|
const prefix = `agent-${id}-`;
|
|
98
40
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
41
|
+
// $PREFIX stays unquoted so the shell expands the glob; the id is validated, so
|
|
42
|
+
// the pattern carries no shell metacharacters. The archive name reaches the
|
|
43
|
+
// command through the environment, never as shell syntax.
|
|
44
|
+
const lines: string[] = [];
|
|
45
|
+
let failed: unknown = null;
|
|
46
|
+
await runDocker(
|
|
47
|
+
['run', '--rm', '-v', `${BACKUP_VOLUME}:/backup`, 'alpine', 'sh', '-c',
|
|
48
|
+
'stat -c "%s %Y %n" /backup/$PREFIX*.tar.gz 2>/dev/null || true'],
|
|
49
|
+
{ timeoutMs: 30_000, env: { PREFIX: prefix }, onLine: (line) => lines.push(line.trim()) },
|
|
50
|
+
).catch((e) => { failed = e; });
|
|
51
|
+
// A failed list must not read as "no backups": under load the container start has
|
|
52
|
+
// been seen to pass 10 s, and an empty array would look like an empty volume.
|
|
53
|
+
if (failed) {
|
|
54
|
+
return NextResponse.json({ error: `Could not list backups: ${(failed as Error).message}` }, { status: 502 });
|
|
55
|
+
}
|
|
103
56
|
|
|
104
|
-
const lines = raw.split('\n').filter(Boolean);
|
|
105
57
|
const backups: BackupFile[] = [];
|
|
106
58
|
|
|
107
59
|
for (const line of lines) {
|
|
@@ -138,6 +90,10 @@ export async function GET(request: Request,
|
|
|
138
90
|
/**
|
|
139
91
|
* DELETE /api/agents/[id]/backup?file=agent-xxx-2026-07-11_2115.tar.gz
|
|
140
92
|
* Remove a specific backup file.
|
|
93
|
+
*
|
|
94
|
+
* The file name reaches the container through its environment, never as shell
|
|
95
|
+
* syntax, and the name is validated against a strict character set first: only
|
|
96
|
+
* `agent-<id>-<timestamp>.tar.gz` shapes are accepted.
|
|
141
97
|
*/
|
|
142
98
|
export async function DELETE(request: Request,
|
|
143
99
|
{ params }: { params: Promise<{ id: string }> },
|
|
@@ -152,14 +108,15 @@ export async function DELETE(request: Request,
|
|
|
152
108
|
const file = url.searchParams.get('file');
|
|
153
109
|
if (!file) return NextResponse.json({ error: 'file query param required' }, { status: 400 });
|
|
154
110
|
|
|
155
|
-
//
|
|
156
|
-
|
|
111
|
+
// Only this agent's archives, and only those shapes; no separators, no `..`,
|
|
112
|
+
// nothing that could carry shell syntax.
|
|
113
|
+
if (!file.startsWith(`agent-${id}-`) || !/^[A-Za-z0-9._-]+\.tar\.gz$/.test(file)) {
|
|
157
114
|
return NextResponse.json({ error: 'Invalid backup file name' }, { status: 400 });
|
|
158
115
|
}
|
|
159
116
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
{
|
|
117
|
+
await runDocker(
|
|
118
|
+
['run', '--rm', '-v', `${BACKUP_VOLUME}:/backup`, 'alpine', 'sh', '-c', 'rm -f "/backup/$FILE"'],
|
|
119
|
+
{ timeoutMs: 30_000, env: { FILE: file } },
|
|
163
120
|
);
|
|
164
121
|
|
|
165
122
|
return NextResponse.json({ success: true });
|
|
@@ -15,7 +15,7 @@ export async function GET(
|
|
|
15
15
|
const denied = await requireAuthJWT(request); if (denied) return denied as any;
|
|
16
16
|
const { id } = await params;
|
|
17
17
|
const channel = getChannel(request);
|
|
18
|
-
const result = getPairings(id, channel);
|
|
18
|
+
const result = await getPairings(id, channel);
|
|
19
19
|
return NextResponse.json(result);
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -34,7 +34,7 @@ export async function POST(
|
|
|
34
34
|
return NextResponse.json({ error: 'Pairing code is required' }, { status: 400 });
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
const result = approvePairing(id, body.code.trim(), channel);
|
|
37
|
+
const result = await approvePairing(id, body.code.trim(), channel);
|
|
38
38
|
|
|
39
39
|
if (!result.success) {
|
|
40
40
|
return NextResponse.json({ error: result.error }, { status: 404 });
|
|
@@ -59,7 +59,7 @@ export async function DELETE(
|
|
|
59
59
|
return NextResponse.json({ error: 'senderId query param is required' }, { status: 400 });
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
const result = revokePairing(id, senderId, channel);
|
|
62
|
+
const result = await revokePairing(id, senderId, channel);
|
|
63
63
|
|
|
64
64
|
if (!result.success) {
|
|
65
65
|
return NextResponse.json({ error: result.error }, { status: 404 });
|
|
@@ -18,7 +18,7 @@ export async function PUT(
|
|
|
18
18
|
return NextResponse.json({ error: 'botToken is required' }, { status: 400 });
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
const result = setTelegramConfig(id, {
|
|
21
|
+
const result = await setTelegramConfig(id, {
|
|
22
22
|
botToken: body.botToken.trim(),
|
|
23
23
|
dmPolicy: body.dmPolicy,
|
|
24
24
|
allowFrom: body.allowFrom,
|
|
@@ -40,7 +40,7 @@ export async function DELETE(request: NextRequest,
|
|
|
40
40
|
const denied = await requireAuthJWT(request); if (denied) return denied as any;
|
|
41
41
|
const { id } = await params;
|
|
42
42
|
|
|
43
|
-
const result = removeTelegram(id);
|
|
43
|
+
const result = await removeTelegram(id);
|
|
44
44
|
|
|
45
45
|
if (!result.success) {
|
|
46
46
|
return NextResponse.json({ error: result.error }, { status: 404 });
|