@flame0510/project-aether 1.5.1 → 1.5.2
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/app/agents/ChannelManager.tsx +17 -5
- package/app/agents/PageClient.tsx +13 -3
- package/app/api/agents/download-image/route.ts +8 -0
- package/bin/rev4a.js +6 -3
- package/daemon.js +3 -3
- package/docs/ARCHITECTURE.md +25 -3
- package/docs/FRONTEND-ARCHITECTURE.md +3 -2
- package/docs/REV4A.md +17 -0
- package/docs/dev/API-REFERENCE.md +4 -1
- package/docs/rag/REV4A-OVERVIEW.md +7 -1
- package/docs/rag/WHAT-I-CAN-ANSWER.md +2 -1
- package/instrumentation.ts +11 -0
- package/lib/agent-edit-state.ts +25 -66
- package/lib/agent-job-state.ts +145 -0
- package/lib/agent-jobs-maintenance.ts +34 -0
- package/lib/agent-recreate-state.ts +24 -65
- package/lib/agent-restore-state.ts +25 -66
- package/lib/agent-restore.ts +28 -5
- package/lib/agent-update-state.ts +25 -61
- package/lib/channelManager.ts +12 -1
- package/model-pricing.json +258 -22
- package/models.config.json +424 -6
- package/package.json +1 -1
- package/scripts/refresh-model-pricing.mjs +5 -5
|
@@ -52,6 +52,7 @@ export default function ChannelManager({ agentId, displayName, open, onClose }:
|
|
|
52
52
|
|
|
53
53
|
const [pending, setPending] = useState<PendingPairing[]>([]);
|
|
54
54
|
const [approved, setApproved] = useState<ApprovedPairing[]>([]);
|
|
55
|
+
const [pairingError, setPairingError] = useState<string | null>(null);
|
|
55
56
|
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
56
57
|
const pairingsAbortRef = useRef<AbortController | null>(null);
|
|
57
58
|
const channelsAbortRef = useRef<AbortController | null>(null);
|
|
@@ -85,6 +86,7 @@ export default function ChannelManager({ agentId, displayName, open, onClose }:
|
|
|
85
86
|
const data = await res.json();
|
|
86
87
|
setPending(data.pending ?? []);
|
|
87
88
|
setApproved(data.approved ?? []);
|
|
89
|
+
setPairingError(data.error ?? null);
|
|
88
90
|
}
|
|
89
91
|
} catch (e: any) {
|
|
90
92
|
if (e?.name !== 'AbortError') { /* ignore aborted */ }
|
|
@@ -288,6 +290,7 @@ export default function ChannelManager({ agentId, displayName, open, onClose }:
|
|
|
288
290
|
restarting={restarting}
|
|
289
291
|
pending={pending}
|
|
290
292
|
approved={approved}
|
|
293
|
+
pairingError={pairingError}
|
|
291
294
|
approvingCode={approvingCode}
|
|
292
295
|
revokingId={revokingId}
|
|
293
296
|
onConnect={handleTelegramConnect}
|
|
@@ -305,7 +308,7 @@ export default function ChannelManager({ agentId, displayName, open, onClose }:
|
|
|
305
308
|
|
|
306
309
|
function TelegramTab({
|
|
307
310
|
tg, tgToken, setTgToken, loading, disconnecting, pairingsLoading, restarting,
|
|
308
|
-
pending, approved, approvingCode, revokingId,
|
|
311
|
+
pending, approved, pairingError, approvingCode, revokingId,
|
|
309
312
|
onConnect, onDisconnect, onRestart, onApprove, onRevoke,
|
|
310
313
|
}: {
|
|
311
314
|
tg: TelegramChannel | null;
|
|
@@ -317,6 +320,7 @@ function TelegramTab({
|
|
|
317
320
|
restarting: boolean;
|
|
318
321
|
pending: PendingPairing[];
|
|
319
322
|
approved: ApprovedPairing[];
|
|
323
|
+
pairingError: string | null;
|
|
320
324
|
approvingCode: string | null;
|
|
321
325
|
revokingId: string | null;
|
|
322
326
|
onConnect: () => void;
|
|
@@ -343,6 +347,7 @@ function TelegramTab({
|
|
|
343
347
|
<PairingSection
|
|
344
348
|
pending={pending}
|
|
345
349
|
approved={approved}
|
|
350
|
+
pairingError={pairingError}
|
|
346
351
|
pairingsLoading={pairingsLoading}
|
|
347
352
|
approvingCode={approvingCode}
|
|
348
353
|
revokingId={revokingId}
|
|
@@ -389,11 +394,12 @@ function TelegramTab({
|
|
|
389
394
|
// ── Pairing Section ──────────────────────────
|
|
390
395
|
|
|
391
396
|
function PairingSection({
|
|
392
|
-
pending, approved, pairingsLoading, approvingCode, revokingId,
|
|
397
|
+
pending, approved, pairingError, pairingsLoading, approvingCode, revokingId,
|
|
393
398
|
onApprove, onRevoke,
|
|
394
399
|
}: {
|
|
395
400
|
pending: PendingPairing[];
|
|
396
401
|
approved: ApprovedPairing[];
|
|
402
|
+
pairingError: string | null;
|
|
397
403
|
pairingsLoading: boolean;
|
|
398
404
|
approvingCode: string | null;
|
|
399
405
|
revokingId: string | null;
|
|
@@ -462,9 +468,15 @@ function PairingSection({
|
|
|
462
468
|
)}
|
|
463
469
|
|
|
464
470
|
{approved.length === 0 && (
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
471
|
+
pairingError ? (
|
|
472
|
+
<div style={{ fontSize: 10, color: 'var(--red, #ef4444)' }}>
|
|
473
|
+
{pairingError}
|
|
474
|
+
</div>
|
|
475
|
+
) : (
|
|
476
|
+
<div style={{ fontSize: 10, color: 'var(--text-dim)' }}>
|
|
477
|
+
No approved senders
|
|
478
|
+
</div>
|
|
479
|
+
)
|
|
468
480
|
)}
|
|
469
481
|
</>
|
|
470
482
|
)}
|
|
@@ -343,9 +343,19 @@ export default function AgentsPageClient() {
|
|
|
343
343
|
const res = await fetch('/api/agents/devices-summary');
|
|
344
344
|
if (!res.ok || seq !== dvSummarySeq.current) return;
|
|
345
345
|
const payload = await res.json();
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
setDvSummary(
|
|
346
|
+
// A row whose read failed carries `error: true` and no count: keep the previous
|
|
347
|
+
// value instead of writing zero, which would hide a request that is really waiting.
|
|
348
|
+
setDvSummary((prev) => {
|
|
349
|
+
const next: Record<string, { pending: number }> = {};
|
|
350
|
+
for (const row of payload.agents ?? []) {
|
|
351
|
+
if (row.error === true) {
|
|
352
|
+
if (prev[row.agentId]) next[row.agentId] = prev[row.agentId];
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
next[row.agentId] = { pending: row.pending ?? 0 };
|
|
356
|
+
}
|
|
357
|
+
return next;
|
|
358
|
+
});
|
|
349
359
|
} catch { /* badges are optional */ }
|
|
350
360
|
}, []);
|
|
351
361
|
|
|
@@ -32,6 +32,14 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
|
32
32
|
}
|
|
33
33
|
const version = (body?.version as string | undefined) ?? (await defaultDownloadVersion());
|
|
34
34
|
|
|
35
|
+
// Checked again here, next to the start: the first check and this one are separated by
|
|
36
|
+
// awaits, so two requests can pass it together. The loser of the race is refused inside
|
|
37
|
+
// downloadAgentImage, and that rejection would be swallowed by the catch below — the
|
|
38
|
+
// route would answer 202 for a download it never started.
|
|
39
|
+
if (getIsDownloading()) {
|
|
40
|
+
return NextResponse.json({ error: 'A download is already in progress' }, { status: 409 });
|
|
41
|
+
}
|
|
42
|
+
|
|
35
43
|
// Start download in background — fire and forget
|
|
36
44
|
downloadAgentImage({ version }).catch((e: unknown) => {
|
|
37
45
|
console.error(`[download-image] OpenClaw ${version}:`, (e as Error).message);
|
package/bin/rev4a.js
CHANGED
|
@@ -428,7 +428,9 @@ function seedAgentToken() {
|
|
|
428
428
|
function build() {
|
|
429
429
|
log('BUILD', 'Building Next.js app…');
|
|
430
430
|
try {
|
|
431
|
-
|
|
431
|
+
// Generous on purpose: a build is the slowest step of an install, and killing it
|
|
432
|
+
// because it passed two minutes takes the server down over slowness alone.
|
|
433
|
+
execSync('npx next build', { stdio: 'inherit', cwd: ROOT, timeout: 30 * 60_000 });
|
|
432
434
|
log('BUILD', 'Build complete');
|
|
433
435
|
} catch (err) {
|
|
434
436
|
log('BUILD', `Build failed: ${err.message}`);
|
|
@@ -754,13 +756,14 @@ if (cmd === 'update') {
|
|
|
754
756
|
console.log('Detected git repo — pulling and building…');
|
|
755
757
|
execSync(`cd ${root} && git pull && npm install && npm run build`, {
|
|
756
758
|
stdio: 'inherit',
|
|
757
|
-
|
|
759
|
+
// npm install + a full build: minutes, not two.
|
|
760
|
+
timeout: 30 * 60_000,
|
|
758
761
|
});
|
|
759
762
|
} else {
|
|
760
763
|
console.log('Using npm global install…');
|
|
761
764
|
execSync(`npm install -g ${PKG_NAME}@latest`, {
|
|
762
765
|
stdio: 'inherit',
|
|
763
|
-
timeout:
|
|
766
|
+
timeout: 20 * 60_000,
|
|
764
767
|
});
|
|
765
768
|
// Wipe old build so rev4a serve rebuilds with the new version
|
|
766
769
|
const nextDir = join(ROOT, '.next');
|
package/daemon.js
CHANGED
|
@@ -28,7 +28,7 @@ const MODEL_PRICING = {
|
|
|
28
28
|
'default': { in: 3.00, out: 15.00 },
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
-
//
|
|
31
|
+
// Direct aliases for models that do not match by substring
|
|
32
32
|
const MODEL_ALIASES = {
|
|
33
33
|
'cheap': 'flash',
|
|
34
34
|
'fast': 'claude-sonnet-4',
|
|
@@ -69,7 +69,7 @@ try {
|
|
|
69
69
|
process.on('uncaughtException', (err) => {
|
|
70
70
|
console.error(`[UNCAUGHT] ${err.message}\n${err.stack}`);
|
|
71
71
|
console.error('Daemon will attempt restart via watchdog cron');
|
|
72
|
-
//
|
|
72
|
+
// Do NOT exit — the watchdog reads this from the healthcheck
|
|
73
73
|
});
|
|
74
74
|
|
|
75
75
|
db.exec(`
|
|
@@ -749,7 +749,7 @@ function pollSessions() {
|
|
|
749
749
|
}
|
|
750
750
|
}
|
|
751
751
|
|
|
752
|
-
// WAL checkpoint
|
|
752
|
+
// FULL WAL checkpoint every 10 polls
|
|
753
753
|
pollCount = (pollCount || 0) + 1;
|
|
754
754
|
if (pollCount % 10 === 0) {
|
|
755
755
|
db.pragma('wal_checkpoint(FULL)');
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Rev4a Architecture — Design & Vision
|
|
2
2
|
|
|
3
3
|
> **Status:** Active — `main` branch
|
|
4
|
-
> **Last updated:** 2026-09-
|
|
4
|
+
> **Last updated:** 2026-09-22
|
|
5
5
|
> **Goal:** Transform Rev4a from a monitoring dashboard into a central orchestrator for a distributed multi-container agency.
|
|
6
6
|
|
|
7
7
|
---
|
|
@@ -207,8 +207,17 @@ The central container, running the Next.js dashboard + orchestration API.
|
|
|
207
207
|
table (`lib/agent-restore-state.ts`): the archive replaces the volume (stop, clear,
|
|
208
208
|
extract, start), up to 30 minutes. Same 202-and-background shape as a recreate, and the
|
|
209
209
|
same guarantees — a reload or a Rev4a restart does not lose the job, a second restore
|
|
210
|
-
is refused, an interrupted one
|
|
211
|
-
|
|
210
|
+
is refused, and an interrupted one is **run again from the same archive** at startup: a
|
|
211
|
+
half-written volume must never be presented as a restore, and the archive was already
|
|
212
|
+
checked before the job started (when it is gone the container is started on whatever the
|
|
213
|
+
volume holds, and the row says so). The extract has no percentage (it is a single
|
|
214
|
+
`tar xzf`); the panel and the agent card show `RESTORING`.
|
|
215
|
+
- **The four job tables share one implementation**: `lib/agent-job-state.ts` builds the
|
|
216
|
+
insert/update/latest/active/`markInterrupted` machinery for `agent_upgrades`,
|
|
217
|
+
`agent_recreates`, `agent_restores` and `agent_edits`; each `lib/agent-*-state.ts` keeps
|
|
218
|
+
only its table's types and function names. They were four near-identical copies that had
|
|
219
|
+
started to drift. At startup `lib/agent-jobs-maintenance.ts` prunes each table to the
|
|
220
|
+
newest 20 rows per agent — the panel reads the latest row only, so the rest is history.
|
|
212
221
|
- **Browser access** to an agent's Control UI goes through `lib/agent-devices.ts`:
|
|
213
222
|
`openclaw devices list | approve | reject | rename | remove` and
|
|
214
223
|
`openclaw dashboard --json`, run inside the container with the async `dockerExec`.
|
|
@@ -279,6 +288,19 @@ the rules for anything you touch, not as a description of the whole tree:
|
|
|
279
288
|
`promisify(exec)` silently ignores an `input` option: the process starts, stdin
|
|
280
289
|
is never written, and the command hangs with no error to point at.
|
|
281
290
|
|
|
291
|
+
- **The process model and self-update.** `rev4a serve` (the systemd unit's process) is a
|
|
292
|
+
supervisor: it spawns `rev4a _run <port>`, which in turn runs Next, the daemon and the
|
|
293
|
+
terminal WebSocket. `rev4a update` installs the new version, then `rev4a restart` writes
|
|
294
|
+
`.restart-flag` in the data directory; `_run` sees it, stops its children, waits for the
|
|
295
|
+
port to come free and exits with code **42**, and the supervisor relaunches `_run` on the
|
|
296
|
+
new code — so the supervisor itself never goes down (the unit's `NRestarts` stays 0).
|
|
297
|
+
The dashboard's **Update now** is only a detached `rev4a update` (`POST
|
|
298
|
+
/api/update-check`): it must not try to restart anything itself, because the update
|
|
299
|
+
stops the very process that spawned it, and the CLI's own flag is the mechanism that
|
|
300
|
+
matters. Its output goes to `update.log` in the data directory, since nothing else can
|
|
301
|
+
report the outcome: the banner records what it asked for, waits for the installed
|
|
302
|
+
version to change and only then reloads (see `VersionBanner`).
|
|
303
|
+
|
|
282
304
|
### 3.2 Agent Container Template (`openclaw-agent-base`)
|
|
283
305
|
|
|
284
306
|
Docker image for every agent container.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Rev4a Frontend Architecture
|
|
2
2
|
|
|
3
|
-
> **Last updated:** 2026-09-
|
|
3
|
+
> **Last updated:** 2026-09-22
|
|
4
4
|
|
|
5
5
|
## Layering
|
|
6
6
|
|
|
@@ -46,8 +46,9 @@ All shared UI primitives live in `app/components/ui/` and are exported from `app
|
|
|
46
46
|
| `BackupSection` | inline in `app/agents/PageClient.tsx` | BACKUP section of the agent detail panel, on the cold backup and the restore. **Backup Now** starts `POST /api/agents/[id]/cold-backup`; while the job runs a banner shows the file and its live percent with **Cancel** (`DELETE /cold-backup`). **Restore** (after a confirm) starts `POST /restore` and a banner shows `Restoring <file>…` (no percent: the extract is a single `tar xzf`, and there is no Cancel). Both sections poll their `GET` every 2 s while running, and on mount pick up a job that is already running — a backup lives in a Docker helper, a restore in `agent_restores`, so reloading the page or navigating away never loses them nor allows a second one (the server answers 409 anyway). Delete per row; all actions disabled while one runs; keyed busy state `{ kind, file }` so only the row in action shows the spinner. On the agent list, an activity Badge (fed by `/api/agents/activity-summary`, polled at 2 s only while something runs, otherwise riding the 15 s list poll) reads `BACKUP nn%`, `RESTORING`, `RECREATING`, `EDITING` or `UPDATING`. |
|
|
47
47
|
| `RecreateSection` | inline in `app/agents/PageClient.tsx` | RECREATE section of the agent detail panel. **Recreate Container** starts `POST /api/agents/[id]/recreate` (202) after a confirm; a banner then shows the phase — *Backing up … nn%* while the cold backup runs, *Recreating container…* while the container is rebuilt and the gateway starts. The section polls `GET /recreate` every 2 s, and on mount picks up a recreate that is already running, so a reload or navigation never loses it; it refetches the agent once the job reports `done`. |
|
|
48
48
|
| `ImageDownloadBanner` | `app/agents/ImageDownloadBanner.tsx` | Agent image banner on the Agents page. Polls `/api/agents/image-status` every 2 s; offers **Download Image** when no supported version is downloaded, **Download <version>** when the registry publishes a newer one, and shows the download in progress and its completion. Downloading changes no agent. |
|
|
49
|
+
| `VersionBanner` | `app/components/VersionBanner.tsx` | "Update available" banner for Rev4a itself, when `GET /api/update-check?check=1` reports a newer published version (dismissable per version, remembered in `localStorage`). **Update now** starts `POST /api/update-check`; because that update restarts the server, the banner cannot be told the outcome by the response: it records what it asked for in `sessionStorage`, polls `/api/update-check` until the installed version moves (two minutes at most) and reloads, then on the next mount either confirms "Updated to vX" or reports that the update did not complete and points at `update.log` (the update's own output). It never reloads blindly onto the same version. |
|
|
49
50
|
| `BrowserAccessSection` / `OpenControlUiButton` | `app/agents/BrowserAccessSection.tsx` | Browser access to one agent's Control UI, in its detail panel: requests waiting for approval (Approve / Reject) and approved browsers (Rename / Revoke), refreshed every 5 s while mounted. A successful approve, reject, rename or revoke updates the list at once, since the refresh behind it runs the OpenClaw CLI and takes seconds; a read started before the mutation is discarded. On agents that require approval, "Invite link" fetches `/api/agents/[id]/invite-link` and shows the link in a read-only field with Copy, which uses the Clipboard API in a secure context and the field's selection over plain HTTP, plus a warning when the link uses localhost. `OpenControlUiButton` opens `/api/agents/[id]/open-control-ui` in a new tab inside the click; that route redirects to a one-time link that pairs the browser with no approval, or to the plain token link when none can be issued. Used on the agent cards and in the panel. |
|
|
50
|
-
| `ChannelManager` / `ChannelSection` | `app/agents/ChannelManager.tsx`, `ChannelSection` inline in `app/agents/PageClient.tsx` | Telegram, in the agent detail panel (`ChannelSection` is the card that opens the modal; the modal title is the agent's display name). Reads `GET /channels`; lists pending pairing requests with **Approve** (`POST /channels/pairing`) and approved senders with **Revoke** after a confirm (`DELETE /channels/pairing?senderId=`). Pending comes from `openclaw pairing list`, approved from OpenClaw's pairing store (`lib/channelManager.ts`). Polls pairings every 5 s while open; one keyed busy state per action. |
|
|
51
|
+
| `ChannelManager` / `ChannelSection` | `app/agents/ChannelManager.tsx`, `ChannelSection` inline in `app/agents/PageClient.tsx` | Telegram, in the agent detail panel (`ChannelSection` is the card that opens the modal; the modal title is the agent's display name). Reads `GET /channels`; lists pending pairing requests with **Approve** (`POST /channels/pairing`) and approved senders with **Revoke** after a confirm (`DELETE /channels/pairing?senderId=`). Pending comes from `openclaw pairing list`, approved from OpenClaw's pairing store (`lib/channelManager.ts`). When that store cannot be read the panel shows the reason instead of "No approved senders" (`PairingState.error`), so an empty list is never a guess. Polls pairings every 5 s while open; one keyed busy state per action. |
|
|
51
52
|
| `EditAgentModal` / `EditBanner` | `app/agents/PageClient.tsx` | Rename/ports editing. The modal has **Display Name** and **Host Port** (same input and validation messages as the create wizard, from `lib/agent-ports.ts`), sends `PATCH /api/agents/[id]` and closes on `202`; `EditBanner` (top of the agent detail) polls `GET /api/agents/[id]` every 2 s while an edit is `rebuilding`, resumes on mount, and refetches the agent once it finishes, so a reload or a navigation shows the running edit instead of allowing a second (the server answers 409). No backup is taken: the volume is untouched. The port range is validated **as it is typed**: the modal fetches `GET /api/agents/ports` when it opens (the same set the create form uses, every container, running or stopped) and excludes the agent's own block, so an occupied range shows the API's message inline and the Save button stays disabled — the create wizard does the same with the same endpoint. |
|
|
52
53
|
| `ModelSection` | `app/agents/ModelSection.tsx` | Primary model and fallbacks for one agent, in its detail panel. Explicit save, no restart. The model is a property of the agent, not of the gateway. Tags models the catalogue marks `deprecated`. |
|
|
53
54
|
| `ModelsProvider` / `useModels` | `app/lib/models-context.tsx` | The client's single model list, from `/api/models`. Whatever changes what is offered calls `refresh()`: the Gateway page after a toggle, a key save or removal, or a sync, and the first-run wizard after saving keys. Everything else only reads, PulseChat included. |
|
package/docs/REV4A.md
CHANGED
|
@@ -201,6 +201,23 @@ If the daemon never answers, `serve` starts anyway, skips the network and image
|
|
|
201
201
|
checks, and says so in the log. The startup sync then reaches no agent, and logs
|
|
202
202
|
that too; run Sync All Agents once Docker is up.
|
|
203
203
|
|
|
204
|
+
### Memory (a host that runs several agents)
|
|
205
|
+
|
|
206
|
+
`rev4a update` installs the new version and the server rebuilds Next before it comes
|
|
207
|
+
back (`rev4a` wipes `.next`, so the build always runs). That build is the memory peak
|
|
208
|
+
of the whole installation: with several agent Gateways resident (roughly 0.5-0.8 GB
|
|
209
|
+
each) a host with no swap can hit the kernel OOM killer during the update — it killed
|
|
210
|
+
`next-server` on a 7.7 GB VPS, which then stayed unreachable until a reboot.
|
|
211
|
+
|
|
212
|
+
Give such a host swap (4-8 GB is enough for the build) and a conservative
|
|
213
|
+
`vm.swappiness`:
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
fallocate -l 4G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
|
|
217
|
+
echo '/swapfile none swap sw 0 0' >> /etc/fstab
|
|
218
|
+
echo 'vm.swappiness=10' > /etc/sysctl.d/99-rev4a-swap.conf
|
|
219
|
+
```
|
|
220
|
+
|
|
204
221
|
### Systemd environment override
|
|
205
222
|
|
|
206
223
|
File: `/etc/systemd/system/rev4a-next.service` (EnvironmentFile)
|
|
@@ -1339,7 +1339,10 @@ releases used are gone, and reading those returned nothing).
|
|
|
1339
1339
|
|---|---|---|
|
|
1340
1340
|
| `channel` | no | Channel name (default: `telegram`) |
|
|
1341
1341
|
|
|
1342
|
-
**Response:**
|
|
1342
|
+
**Response:** `{ pending: [...], approved: [...], error: null }` — `error` carries why the
|
|
1343
|
+
approved list is empty when that is not simply "none" (the store could not be read); the
|
|
1344
|
+
panel shows it instead of "No approved senders". A missing store is not an error.
|
|
1345
|
+
|
|
1343
1346
|
```json
|
|
1344
1347
|
{
|
|
1345
1348
|
"pending": [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# What is Rev4a?
|
|
2
2
|
|
|
3
|
-
> **Last updated:** 2026-09-
|
|
3
|
+
> **Last updated:** 2026-09-22
|
|
4
4
|
|
|
5
5
|
Rev4a is the control panel for your AI agent infrastructure. It shows you everything your agents are doing, how much they cost, and whether the system is healthy — all in one dashboard.
|
|
6
6
|
|
|
@@ -36,6 +36,12 @@ The agent list also shows a compact TG chip next to each agent name (green = con
|
|
|
36
36
|
|
|
37
37
|
**Browser access:** on agents running OpenClaw 9.x, every new browser has to be approved once before the agent's Control UI connects. The detail panel's "BROWSER ACCESS" section lists browsers waiting for approval, with Approve and Reject, and the browsers already approved, with Rename and Revoke. A yellow "BROWSER WAITING" chip on the agent card says a request is pending. The **Open** button tries a one-time link that lets the browser in without any approval; if the agent cannot issue one (it is stopped, or the link fails) it opens the normal link. To let someone else in, **Invite link** gives a link to send them: their browser appears under waiting for approval, and nothing opens until you approve it. The link contains the token all agents share; changing the agents token cancels every link already sent.
|
|
38
38
|
|
|
39
|
+
Rev4a itself has an **"Update available"** banner: it starts `rev4a update` and the server
|
|
40
|
+
restarts itself, so the dashboard drops briefly. The banner is only shown when a newer
|
|
41
|
+
version is published; it waits for the restart and then confirms the new version, or
|
|
42
|
+
reports that the update did not complete and points at `update.log` in the Rev4a data
|
|
43
|
+
directory, where the update's output is written.
|
|
44
|
+
|
|
39
45
|
The Agents page includes a banner for the **agent base image**, which is kept per
|
|
40
46
|
OpenClaw version. It is hidden when a supported version is downloaded and nothing newer
|
|
41
47
|
is published. When no image is downloaded it offers **Download Image**; when a newer
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# What PULSE Can Answer
|
|
2
2
|
|
|
3
|
-
> **Last updated:** 2026-09-
|
|
3
|
+
> **Last updated:** 2026-09-22
|
|
4
4
|
|
|
5
5
|
PULSE is the in-dashboard AI concierge for Rev4a. This document defines what she can and cannot answer.
|
|
6
6
|
|
|
@@ -102,6 +102,7 @@ PULSE is the in-dashboard AI concierge for Rev4a. This document defines what she
|
|
|
102
102
|
- "Why is the agent base image banner showing?"
|
|
103
103
|
- "How do I fix 'image is outdated'?"
|
|
104
104
|
- "How do agent image updates work?" — The banner on the Agents page runs a `docker pull`. It is a download, not a build: there is no modal, no live log, and no way to abort from the UI.
|
|
105
|
+
- "How do I update Rev4a itself?" — The "Update available" banner starts `rev4a update` in the background (the same command as from a shell). It installs the new version and Rev4a restarts itself, so the dashboard drops for a short while; the banner waits for the new version and then confirms it, or says the update did not complete. The update's output is written to `update.log` in the Rev4a data directory (`~/.config/rev4a/data/update.log`) — the only place that says why an update failed.
|
|
105
106
|
- "Why is my Telegram bot not connecting?"
|
|
106
107
|
- "Why can't I approve a pairing code?"
|
|
107
108
|
- "Why is the TG badge missing from my agent?"
|
package/instrumentation.ts
CHANGED
|
@@ -65,5 +65,16 @@ export async function register() {
|
|
|
65
65
|
} catch {
|
|
66
66
|
console.warn('[rev4a] Could not reconcile cold backups on startup');
|
|
67
67
|
}
|
|
68
|
+
// Retention for the lifecycle job tables: the panel reads the latest row of each
|
|
69
|
+
// agent, so the history is trimmed to the newest rows per agent. Best-effort.
|
|
70
|
+
try {
|
|
71
|
+
const { pruneLifecycleRows } = await import('./lib/agent-jobs-maintenance');
|
|
72
|
+
const removed = pruneLifecycleRows().filter((r) => r.removed > 0);
|
|
73
|
+
if (removed.length) {
|
|
74
|
+
console.log(`[rev4a] Pruned job history: ${removed.map((r) => `${r.table} ${r.removed}`).join(', ')}`);
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
console.warn('[rev4a] Could not prune the lifecycle job history');
|
|
78
|
+
}
|
|
68
79
|
}
|
|
69
80
|
}
|
package/lib/agent-edit-state.ts
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Persisted state of agent edits: the `agent_edits` table (lib/db-bootstrap.mjs).
|
|
3
3
|
*
|
|
4
4
|
* Kept apart from lib/agent-edit.ts so busy checks (lib/agent-busy.ts) can ask which
|
|
5
|
-
* agents are being edited without importing the edit action.
|
|
5
|
+
* agents are being edited without importing the edit action. The machinery is
|
|
6
|
+
* lib/agent-job-state.ts; this file holds the table's types and names.
|
|
6
7
|
*/
|
|
7
|
-
import {
|
|
8
|
+
import { createJobState } from '@/lib/agent-job-state';
|
|
8
9
|
|
|
9
10
|
export type EditStatus = 'rebuilding' | 'done' | 'failed' | 'interrupted';
|
|
10
11
|
|
|
@@ -25,84 +26,42 @@ export interface AgentEditRow {
|
|
|
25
26
|
|
|
26
27
|
const FINAL: readonly EditStatus[] = ['done', 'failed', 'interrupted'];
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return Number(info.lastInsertRowid);
|
|
37
|
-
} finally {
|
|
38
|
-
db.close();
|
|
39
|
-
}
|
|
40
|
-
}
|
|
29
|
+
const state = createJobState({
|
|
30
|
+
table: 'agent_edits',
|
|
31
|
+
initialStatus: 'rebuilding',
|
|
32
|
+
activeStatuses: ACTIVE_EDIT_STATUSES,
|
|
33
|
+
finalStatuses: FINAL,
|
|
34
|
+
insertColumns: ['display_name', 'port_range'],
|
|
35
|
+
writableColumns: [],
|
|
36
|
+
});
|
|
41
37
|
|
|
42
38
|
type Writable = Partial<Pick<AgentEditRow, 'status' | 'error'>>;
|
|
43
39
|
|
|
40
|
+
export function insertEdit(agentId: string, displayName: string | null, portRange: string | null): number {
|
|
41
|
+
return state.insert(agentId, displayName, portRange);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
44
|
export function updateEditRow(id: number, fields: Writable): void {
|
|
45
|
-
|
|
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_edits SET ${sets.join(', ')} WHERE id = ?`).run(...values, id);
|
|
58
|
-
} finally {
|
|
59
|
-
db.close();
|
|
60
|
-
}
|
|
45
|
+
state.update(id, fields);
|
|
61
46
|
}
|
|
62
47
|
|
|
63
48
|
export function latestEdit(agentId: string): AgentEditRow | null {
|
|
64
|
-
|
|
65
|
-
try {
|
|
66
|
-
return (db.prepare('SELECT * FROM agent_edits WHERE agent_id = ? ORDER BY id DESC LIMIT 1').get(agentId) as AgentEditRow | undefined) ?? null;
|
|
67
|
-
} finally {
|
|
68
|
-
db.close();
|
|
69
|
-
}
|
|
49
|
+
return state.latest<AgentEditRow>(agentId);
|
|
70
50
|
}
|
|
71
51
|
|
|
72
52
|
export function isEditActive(agentId: string): boolean {
|
|
73
|
-
|
|
74
|
-
return !!row && ACTIVE_EDIT_STATUSES.includes(row.status);
|
|
53
|
+
return state.isActive(agentId);
|
|
75
54
|
}
|
|
76
55
|
|
|
77
|
-
/** AGENT_IDs with an edit in an active status. */
|
|
78
56
|
export function activeEditAgentIds(): Set<string> {
|
|
79
|
-
|
|
80
|
-
try {
|
|
81
|
-
const placeholders = ACTIVE_EDIT_STATUSES.map(() => '?').join(', ');
|
|
82
|
-
const rows = db.prepare(`SELECT DISTINCT agent_id FROM agent_edits WHERE status IN (${placeholders})`).all(...ACTIVE_EDIT_STATUSES) as { agent_id: string }[];
|
|
83
|
-
return new Set(rows.map((r) => r.agent_id));
|
|
84
|
-
} finally {
|
|
85
|
-
db.close();
|
|
86
|
-
}
|
|
57
|
+
return state.activeAgentIds();
|
|
87
58
|
}
|
|
88
59
|
|
|
89
|
-
/**
|
|
90
|
-
* At startup no edit job can be running: a row still active was cut off by the restart.
|
|
91
|
-
* It becomes `interrupted`, keeping the name and port it was applying.
|
|
92
|
-
*/
|
|
93
60
|
export function markInterruptedEdits(): number {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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_EDIT_STATUSES);
|
|
104
|
-
return info.changes;
|
|
105
|
-
} finally {
|
|
106
|
-
db.close();
|
|
107
|
-
}
|
|
61
|
+
return state.markInterrupted();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Keep the newest `keepPerAgent` edits of each agent; the rest is history. */
|
|
65
|
+
export function pruneEdits(keepPerAgent: number): number {
|
|
66
|
+
return state.prune(keepPerAgent);
|
|
108
67
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four lifecycle job tables — `agent_upgrades`, `agent_recreates`, `agent_restores`,
|
|
3
|
+
* `agent_edits` — are the same machine: an insert that starts a row in one status, an
|
|
4
|
+
* update that stamps `updated_at` and `finished_at`, a latest lookup, an "is something
|
|
5
|
+
* running" check, an at-startup sweep that turns interrupted rows into `interrupted`, and
|
|
6
|
+
* a retention prune. They used to be four copies of that code, which drifted; this factory
|
|
7
|
+
* is the single implementation, and each table keeps its own types and function names.
|
|
8
|
+
*
|
|
9
|
+
* The table and column names come from this file's callers, never from a request, and
|
|
10
|
+
* `update` only writes the columns its spec allows: nothing user-supplied can reach the
|
|
11
|
+
* generated SQL.
|
|
12
|
+
*/
|
|
13
|
+
import { openDb } from '@/lib/db';
|
|
14
|
+
|
|
15
|
+
export interface JobStateSpec {
|
|
16
|
+
/** Table name (created by lib/db-bootstrap.mjs). */
|
|
17
|
+
table: string;
|
|
18
|
+
/** The status an inserted row starts in. */
|
|
19
|
+
initialStatus: string;
|
|
20
|
+
/** Statuses during which the agent must not be touched by anything else. */
|
|
21
|
+
activeStatuses: readonly string[];
|
|
22
|
+
/** Statuses that end a job: `finished_at` is stamped when one is set. */
|
|
23
|
+
finalStatuses: readonly string[];
|
|
24
|
+
/** Extra columns `insert` writes, after agent_id/status/started_at/updated_at. */
|
|
25
|
+
insertColumns: readonly string[];
|
|
26
|
+
/** Extra columns `update` may set, besides status and error. */
|
|
27
|
+
writableColumns: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface JobState {
|
|
31
|
+
insert(agentId: string, ...values: (string | number | null)[]): number;
|
|
32
|
+
update(id: number, fields: Record<string, string | null | undefined>): void;
|
|
33
|
+
latest<T>(agentId: string): T | null;
|
|
34
|
+
isActive(agentId: string): boolean;
|
|
35
|
+
activeAgentIds(): Set<string>;
|
|
36
|
+
markInterrupted(): number;
|
|
37
|
+
/** Delete all but the newest `keepPerAgent` rows of each agent. Returns the count. */
|
|
38
|
+
prune(keepPerAgent: number): number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createJobState(spec: JobStateSpec): JobState {
|
|
42
|
+
const { table, initialStatus, activeStatuses, finalStatuses } = spec;
|
|
43
|
+
const insertColumns = ['agent_id', 'status', ...spec.insertColumns, 'started_at', 'updated_at'];
|
|
44
|
+
const writable = new Set(['status', 'error', ...spec.writableColumns]);
|
|
45
|
+
const activePlaceholders = activeStatuses.map(() => '?').join(', ');
|
|
46
|
+
|
|
47
|
+
const latest = <T,>(agentId: string): T | null => {
|
|
48
|
+
const db = openDb(true);
|
|
49
|
+
try {
|
|
50
|
+
return (db.prepare(`SELECT * FROM ${table} WHERE agent_id = ? ORDER BY id DESC LIMIT 1`).get(agentId) as T | undefined) ?? null;
|
|
51
|
+
} finally {
|
|
52
|
+
db.close();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
insert(agentId, ...values) {
|
|
58
|
+
if (values.length !== spec.insertColumns.length) {
|
|
59
|
+
throw new Error(`${table}: insert expects ${spec.insertColumns.length} value(s), got ${values.length}`);
|
|
60
|
+
}
|
|
61
|
+
const db = openDb(false);
|
|
62
|
+
try {
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
const info = db.prepare(
|
|
65
|
+
`INSERT INTO ${table} (${insertColumns.join(', ')})
|
|
66
|
+
VALUES (${insertColumns.map(() => '?').join(', ')})`,
|
|
67
|
+
).run(agentId, initialStatus, ...values, now, now);
|
|
68
|
+
return Number(info.lastInsertRowid);
|
|
69
|
+
} finally {
|
|
70
|
+
db.close();
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
update(id, fields) {
|
|
75
|
+
const entries = Object.entries(fields).filter(([key, value]) => value !== undefined && writable.has(key));
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
const sets = [...entries.map(([key]) => `${key} = ?`), 'updated_at = ?'];
|
|
78
|
+
const values: unknown[] = [...entries.map(([, value]) => value), now];
|
|
79
|
+
const status = fields.status;
|
|
80
|
+
if (status && finalStatuses.includes(status)) {
|
|
81
|
+
sets.push('finished_at = ?');
|
|
82
|
+
values.push(now);
|
|
83
|
+
} else if (status) {
|
|
84
|
+
sets.push('finished_at = NULL');
|
|
85
|
+
}
|
|
86
|
+
const db = openDb(false);
|
|
87
|
+
try {
|
|
88
|
+
db.prepare(`UPDATE ${table} SET ${sets.join(', ')} WHERE id = ?`).run(...values, id);
|
|
89
|
+
} finally {
|
|
90
|
+
db.close();
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
latest,
|
|
95
|
+
|
|
96
|
+
isActive(agentId) {
|
|
97
|
+
const row = latest<{ status: string }>(agentId);
|
|
98
|
+
return !!row && activeStatuses.includes(row.status);
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
activeAgentIds() {
|
|
102
|
+
const db = openDb(true);
|
|
103
|
+
try {
|
|
104
|
+
const rows = db.prepare(
|
|
105
|
+
`SELECT DISTINCT agent_id FROM ${table} WHERE status IN (${activePlaceholders})`,
|
|
106
|
+
).all(...activeStatuses) as { agent_id: string }[];
|
|
107
|
+
return new Set(rows.map((r) => r.agent_id));
|
|
108
|
+
} finally {
|
|
109
|
+
db.close();
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
markInterrupted() {
|
|
114
|
+
const db = openDb(false);
|
|
115
|
+
try {
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
const info = db.prepare(
|
|
118
|
+
`UPDATE ${table}
|
|
119
|
+
SET error = COALESCE(error, 'Rev4a restarted while this step was running: ' || status),
|
|
120
|
+
status = 'interrupted', updated_at = ?, finished_at = ?
|
|
121
|
+
WHERE status IN (${activePlaceholders})`,
|
|
122
|
+
).run(now, now, ...activeStatuses);
|
|
123
|
+
return info.changes;
|
|
124
|
+
} finally {
|
|
125
|
+
db.close();
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
prune(keepPerAgent) {
|
|
130
|
+
const db = openDb(false);
|
|
131
|
+
try {
|
|
132
|
+
const info = db.prepare(
|
|
133
|
+
`DELETE FROM ${table} WHERE id IN (
|
|
134
|
+
SELECT id FROM (
|
|
135
|
+
SELECT id, ROW_NUMBER() OVER (PARTITION BY agent_id ORDER BY id DESC) AS rn FROM ${table}
|
|
136
|
+
) WHERE rn > ?
|
|
137
|
+
)`,
|
|
138
|
+
).run(keepPerAgent);
|
|
139
|
+
return info.changes;
|
|
140
|
+
} finally {
|
|
141
|
+
db.close();
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retention for the lifecycle job tables (`agent_upgrades`, `agent_recreates`,
|
|
3
|
+
* `agent_restores`, `agent_edits`). They record every update, recreate, restore and edit
|
|
4
|
+
* an agent has been through, and nothing pruned them: the rows grew for the life of the
|
|
5
|
+
* installation while the panel only ever reads the latest one of each agent. Each table
|
|
6
|
+
* keeps the newest `KEEP_PER_AGENT` rows per agent; the rest is history.
|
|
7
|
+
*
|
|
8
|
+
* Best-effort: a failure is logged and does not stop the others or the startup.
|
|
9
|
+
*/
|
|
10
|
+
import { pruneUpdates } from '@/lib/agent-update-state';
|
|
11
|
+
import { pruneRecreates } from '@/lib/agent-recreate-state';
|
|
12
|
+
import { pruneRestores } from '@/lib/agent-restore-state';
|
|
13
|
+
import { pruneEdits } from '@/lib/agent-edit-state';
|
|
14
|
+
|
|
15
|
+
/** Rows kept per agent, per table. Enough to see the recent history of an agent. */
|
|
16
|
+
const KEEP_PER_AGENT = 20;
|
|
17
|
+
|
|
18
|
+
export function pruneLifecycleRows(keepPerAgent: number = KEEP_PER_AGENT): { table: string; removed: number }[] {
|
|
19
|
+
const prunes: [string, (keep: number) => number][] = [
|
|
20
|
+
['agent_upgrades', pruneUpdates],
|
|
21
|
+
['agent_recreates', pruneRecreates],
|
|
22
|
+
['agent_restores', pruneRestores],
|
|
23
|
+
['agent_edits', pruneEdits],
|
|
24
|
+
];
|
|
25
|
+
const result: { table: string; removed: number }[] = [];
|
|
26
|
+
for (const [table, prune] of prunes) {
|
|
27
|
+
try {
|
|
28
|
+
result.push({ table, removed: prune(keepPerAgent) });
|
|
29
|
+
} catch (e) {
|
|
30
|
+
console.warn(`[rev4a] could not prune ${table}: ${(e as Error).message}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|