@flame0510/project-aether 1.5.1 → 1.6.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/app/agents/ChannelManager.tsx +17 -5
- package/app/agents/PageClient.tsx +13 -3
- package/app/api/agents/download-image/route.ts +8 -0
- package/app/api/gateway/provider/route.ts +16 -10
- package/app/api/models/details/route.ts +30 -0
- package/app/api/system-health/route.ts +3 -3
- package/app/api/update-check/route.ts +23 -10
- package/app/components/SessionDrawer.tsx +4 -4
- package/app/components/VersionBanner.tsx +50 -33
- package/app/components/ui/Metric.tsx +7 -2
- package/app/gateway/ModelDetailsModal.tsx +322 -0
- package/app/gateway/PageClient.tsx +28 -10
- package/app/globals.css +4 -1
- package/app/lib/model-format.ts +21 -0
- package/bin/rev4a.js +6 -3
- package/daemon.js +3 -3
- package/docs/ARCHITECTURE.md +25 -3
- package/docs/FRONTEND-ARCHITECTURE.md +5 -3
- package/docs/REV4A.md +17 -0
- package/docs/dev/API-REFERENCE.md +41 -3
- package/docs/dev/GATEWAY.md +58 -0
- 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/lib/memory-context.ts +2 -2
- package/lib/model-catalogue.ts +17 -0
- package/lib/model-details.ts +124 -0
- package/lib/provider-labels.ts +21 -0
- package/model-details.json +16360 -0
- package/model-pricing.json +260 -24
- package/models.config.json +494 -10
- package/package.json +4 -2
- package/scripts/check-language.mjs +76 -0
- package/scripts/lib/model-upstream.mjs +61 -0
- package/scripts/model-info-suggest.mjs +149 -0
- package/scripts/refresh-model-pricing.mjs +180 -21
|
@@ -316,6 +316,38 @@ Rev4a Provider Gateway — proxy chat completions to the correct upstream.
|
|
|
316
316
|
|
|
317
317
|
---
|
|
318
318
|
|
|
319
|
+
### `GET /api/models/details?id=<model id>`
|
|
320
|
+
Everything the details modal shows for one model, and nothing more: fields the sources do
|
|
321
|
+
not have are **absent**, never `null` and never a guess.
|
|
322
|
+
|
|
323
|
+
**Auth:** browser cookie or bearer token
|
|
324
|
+
|
|
325
|
+
**Response:**
|
|
326
|
+
```json
|
|
327
|
+
{
|
|
328
|
+
"id": "glm/glm-5.3-flash", "name": "GLM-5.3 Flash", "provider": "glm",
|
|
329
|
+
"modality": "text+image+video->text", "enabled": false, "deprecated": false,
|
|
330
|
+
"price": { "input": 0.15, "output": 0.5, "source": "vendor" },
|
|
331
|
+
"details": { "description": "…", "created": "2026-08-26", "context": 1310720,
|
|
332
|
+
"providerContext": 1048576, "maxOutput": 943718,
|
|
333
|
+
"reasoning": { "mandatory": true, "default_effort": "max", "supported_efforts": ["max","high","low"] },
|
|
334
|
+
"supportedParameters": ["tools","reasoning","…"],
|
|
335
|
+
"benchmarks": { "design_arena": [ … ], "artificial_analysis": { … } },
|
|
336
|
+
"huggingFaceId": "zai-org/GLM-5.3-Flash", "url": "https://openrouter.ai/…" },
|
|
337
|
+
"info": null,
|
|
338
|
+
"asOf": "2026-09-23T14:02:40.271Z",
|
|
339
|
+
"detailsSource": "openrouter"
|
|
340
|
+
}
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
`price.source` is `openrouter` for an `openrouter/*` entry and `vendor` for a direct one,
|
|
344
|
+
which keeps prices honest: the refresh script never writes a vendor price. `details` comes
|
|
345
|
+
from the generated `model-details.json` (`npm run refresh:pricing`, see
|
|
346
|
+
`docs/dev/GATEWAY.md`), `info` from the catalogue's hand-written block, and `asOf` is when
|
|
347
|
+
the generated part was produced. An id the catalogue does not carry answers `404`.
|
|
348
|
+
|
|
349
|
+
**Errors:** `400` missing `id`, `404` unknown model.
|
|
350
|
+
|
|
319
351
|
### `GET /api/models`
|
|
320
352
|
Models eligible to be used: the catalogue filtered down to models that are enabled
|
|
321
353
|
(after overrides) and whose provider has a key configured, grouped by provider. Feeds the model pickers in the agent's
|
|
@@ -1339,7 +1371,10 @@ releases used are gone, and reading those returned nothing).
|
|
|
1339
1371
|
|---|---|---|
|
|
1340
1372
|
| `channel` | no | Channel name (default: `telegram`) |
|
|
1341
1373
|
|
|
1342
|
-
**Response:**
|
|
1374
|
+
**Response:** `{ pending: [...], approved: [...], error: null }` — `error` carries why the
|
|
1375
|
+
approved list is empty when that is not simply "none" (the store could not be read); the
|
|
1376
|
+
panel shows it instead of "No approved senders". A missing store is not an error.
|
|
1377
|
+
|
|
1343
1378
|
```json
|
|
1344
1379
|
{
|
|
1345
1380
|
"pending": [
|
|
@@ -2561,8 +2596,11 @@ from local state only.
|
|
|
2561
2596
|
|
|
2562
2597
|
**Response:** `{ "installed": "1.4.2", "latest": "1.5.0", "updateAvailable": true, "package": "@flame0510/project-aether", "log": "update.log" }`
|
|
2563
2598
|
|
|
2564
|
-
`
|
|
2565
|
-
|
|
2599
|
+
`installed` is `null` when `package.json` cannot be read — during an update npm rewrites
|
|
2600
|
+
it, and a transient failure must not look like a version. With `installed` unknown there
|
|
2601
|
+
is nothing to compare, so `latest` is `null` and `updateAvailable` false. `installed` is
|
|
2602
|
+
never a placeholder like `0.0.0`. `log` is the file name, in the Rev4a data directory,
|
|
2603
|
+
where the update's output is written.
|
|
2566
2604
|
|
|
2567
2605
|
### `POST /api/update-check`
|
|
2568
2606
|
Run `rev4a update` as a detached background process. Nothing here restarts anything:
|
package/docs/dev/GATEWAY.md
CHANGED
|
@@ -148,6 +148,64 @@ for anyone cloning the project. It defines all known models across all providers
|
|
|
148
148
|
Only enabled models for providers with a configured API key are synced to agent
|
|
149
149
|
containers.
|
|
150
150
|
|
|
151
|
+
Provider display names come from `lib/provider-labels.ts`: the Gateway's cards and the
|
|
152
|
+
details modal read the same map, so a provider is never called two things in one page.
|
|
153
|
+
|
|
154
|
+
### Model details (the details modal)
|
|
155
|
+
|
|
156
|
+
The Gateway model row has a **⋯ Details** button opening `ModelDetailsModal`
|
|
157
|
+
(`app/gateway/ModelDetailsModal.tsx`), fed by `GET /api/models/details?id=`. Three
|
|
158
|
+
sources, each one labelled in the modal:
|
|
159
|
+
|
|
160
|
+
| Source | File | Nature |
|
|
161
|
+
|---|---|---|
|
|
162
|
+
| Identity, enabled, modality, deprecation | `models.config.json` | curated by hand |
|
|
163
|
+
| Price | `model-pricing.json` | refreshed for `openrouter/*`, hand for direct vendors |
|
|
164
|
+
| Description, specs, benchmarks | `model-details.json` | **generated**, never hand-edited |
|
|
165
|
+
|
|
166
|
+
`npm run refresh:pricing` (script `scripts/refresh-model-pricing.mjs`) fetches the
|
|
167
|
+
OpenRouter catalogue once and writes both the prices and `model-details.json`:
|
|
168
|
+
description, release date (`created`), context and the provider's own limit, max output,
|
|
169
|
+
tokenizer, instruct type, knowledge cutoff, Hugging Face id, reasoning (mandatory, default
|
|
170
|
+
and supported efforts), supported parameters, and the benchmarks OpenRouter publishes
|
|
171
|
+
(design arenas with elo/rank/win-rate, Artificial Analysis indices).
|
|
172
|
+
|
|
173
|
+
**Size and architecture** come from a second source: for every entry with a
|
|
174
|
+
`hugging_face_id` the script asks the Hugging Face API (`safetensors.total` and the
|
|
175
|
+
per-dtype breakdown, the task, the languages, downloads and likes) **and the model's
|
|
176
|
+
`config.json`** — the API's summary config is reduced, the raw file is what carries the
|
|
177
|
+
architecture. That covers the **open-weight** models (DeepSeek, GLM, Kimi, Qwen, Llama,
|
|
178
|
+
StepFun…); a closed model has no card, so it gets no `params` and no `hf` block, and the
|
|
179
|
+
modal hides those rows rather than estimating. `--no-hf` skips the lookups.
|
|
180
|
+
|
|
181
|
+
Stored per model in an `hf` object: the architecture class and type, the mixture of
|
|
182
|
+
experts (`experts`, per token, shared), layers, hidden size, attention heads (and KV
|
|
183
|
+
heads, since MQA/GQA change what serving costs), vocabulary, the model's own max context,
|
|
184
|
+
whether it has a **vision encoder** (multimodal models nest their language model under
|
|
185
|
+
`text_config` and the encoder under `vision_config`), the precision (`fp8`,
|
|
186
|
+
`compressed-tensors`…) and the weight mix by dtype, the task, the languages and the
|
|
187
|
+
popularity. For a mixture-of-experts model the card's number is the **total**; an *active*
|
|
188
|
+
count is a derivation, so the modal shows the facts (experts, per token) and the curated
|
|
189
|
+
`info.params` free text can add the vendor's own "355B total, ~32B active". A direct entry is
|
|
190
|
+
matched through the vendor alias map (`glm` → `z-ai`, `kimi` → `moonshotai`) for the data
|
|
191
|
+
only — **its price stays the vendor's own**, and the modal says which is which.
|
|
192
|
+
|
|
193
|
+
Anything OpenRouter does not publish is curated in the catalogue as an optional `info`
|
|
194
|
+
block, and every number there carries its source:
|
|
195
|
+
|
|
196
|
+
```json
|
|
197
|
+
"info": {
|
|
198
|
+
"params": "~1.8T MoE (active ~40B)",
|
|
199
|
+
"docUrl": "https://docs.z.ai/guides/vlm/glm-5.3-flash",
|
|
200
|
+
"knowledgeCutoff": "2026-03",
|
|
201
|
+
"benchmarks": [ { "name": "SWE-bench Verified", "value": 78.4, "source": "https://…", "asOf": "2026-08-26" } ],
|
|
202
|
+
"notes": "Native multimodal; 3x the coding-plan quota."
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
No generated field ever replaces a curated one silently: prices are separate files, and the
|
|
207
|
+
modal shows the generated part's date (`asOf`).
|
|
208
|
+
|
|
151
209
|
---
|
|
152
210
|
|
|
153
211
|
## Per-agent model assignment
|
|
@@ -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
|
+
}
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Kept apart from lib/agent-recreate.ts so busy checks (lib/agent-busy.ts, the provider
|
|
5
5
|
* sync) can ask which agents are being recreated without importing the recreate action.
|
|
6
|
+
* The machinery is 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 RecreateStatus = 'backing_up' | 'recreating' | 'done' | 'failed' | 'interrupted';
|
|
10
11
|
|
|
@@ -25,84 +26,42 @@ export interface AgentRecreateRow {
|
|
|
25
26
|
|
|
26
27
|
const FINAL: readonly RecreateStatus[] = ['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_recreates',
|
|
31
|
+
initialStatus: 'backing_up',
|
|
32
|
+
activeStatuses: ACTIVE_RECREATE_STATUSES,
|
|
33
|
+
finalStatuses: FINAL,
|
|
34
|
+
insertColumns: ['image'],
|
|
35
|
+
writableColumns: ['backup_file'],
|
|
36
|
+
});
|
|
41
37
|
|
|
42
38
|
type Writable = Partial<Pick<AgentRecreateRow, 'status' | 'backup_file' | 'error'>>;
|
|
43
39
|
|
|
40
|
+
export function insertRecreate(agentId: string, image: string): number {
|
|
41
|
+
return state.insert(agentId, image);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
44
|
export function updateRecreateRow(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_recreates 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 latestRecreate(agentId: string): AgentRecreateRow | null {
|
|
64
|
-
|
|
65
|
-
try {
|
|
66
|
-
return (db.prepare('SELECT * FROM agent_recreates WHERE agent_id = ? ORDER BY id DESC LIMIT 1').get(agentId) as AgentRecreateRow | undefined) ?? null;
|
|
67
|
-
} finally {
|
|
68
|
-
db.close();
|
|
69
|
-
}
|
|
49
|
+
return state.latest<AgentRecreateRow>(agentId);
|
|
70
50
|
}
|
|
71
51
|
|
|
72
52
|
export function isRecreateActive(agentId: string): boolean {
|
|
73
|
-
|
|
74
|
-
return !!row && ACTIVE_RECREATE_STATUSES.includes(row.status);
|
|
53
|
+
return state.isActive(agentId);
|
|
75
54
|
}
|
|
76
55
|
|
|
77
|
-
/** AGENT_IDs with a recreate in an active status. */
|
|
78
56
|
export function activeRecreateAgentIds(): Set<string> {
|
|
79
|
-
|
|
80
|
-
try {
|
|
81
|
-
const placeholders = ACTIVE_RECREATE_STATUSES.map(() => '?').join(', ');
|
|
82
|
-
const rows = db.prepare(`SELECT DISTINCT agent_id FROM agent_recreates WHERE status IN (${placeholders})`).all(...ACTIVE_RECREATE_STATUSES) as { agent_id: string }[];
|
|
83
|
-
return new Set(rows.map((r) => r.agent_id));
|
|
84
|
-
} finally {
|
|
85
|
-
db.close();
|
|
86
|
-
}
|
|
57
|
+
return state.activeAgentIds();
|
|
87
58
|
}
|
|
88
59
|
|
|
89
|
-
/**
|
|
90
|
-
* At startup no recreate job can be running: any row still active was cut off by the
|
|
91
|
-
* restart. It becomes `interrupted`, keeping the step it was in.
|
|
92
|
-
*/
|
|
93
60
|
export function markInterruptedRecreates(): 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_RECREATE_STATUSES);
|
|
104
|
-
return info.changes;
|
|
105
|
-
} finally {
|
|
106
|
-
db.close();
|
|
107
|
-
}
|
|
61
|
+
return state.markInterrupted();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Keep the newest `keepPerAgent` recreates of each agent; the rest is history. */
|
|
65
|
+
export function pruneRecreates(keepPerAgent: number): number {
|
|
66
|
+
return state.prune(keepPerAgent);
|
|
108
67
|
}
|