@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.
Files changed (44) hide show
  1. package/app/agents/ChannelManager.tsx +17 -5
  2. package/app/agents/PageClient.tsx +13 -3
  3. package/app/api/agents/download-image/route.ts +8 -0
  4. package/app/api/gateway/provider/route.ts +16 -10
  5. package/app/api/models/details/route.ts +30 -0
  6. package/app/api/system-health/route.ts +3 -3
  7. package/app/api/update-check/route.ts +23 -10
  8. package/app/components/SessionDrawer.tsx +4 -4
  9. package/app/components/VersionBanner.tsx +50 -33
  10. package/app/components/ui/Metric.tsx +7 -2
  11. package/app/gateway/ModelDetailsModal.tsx +322 -0
  12. package/app/gateway/PageClient.tsx +28 -10
  13. package/app/globals.css +4 -1
  14. package/app/lib/model-format.ts +21 -0
  15. package/bin/rev4a.js +6 -3
  16. package/daemon.js +3 -3
  17. package/docs/ARCHITECTURE.md +25 -3
  18. package/docs/FRONTEND-ARCHITECTURE.md +5 -3
  19. package/docs/REV4A.md +17 -0
  20. package/docs/dev/API-REFERENCE.md +41 -3
  21. package/docs/dev/GATEWAY.md +58 -0
  22. package/docs/rag/REV4A-OVERVIEW.md +7 -1
  23. package/docs/rag/WHAT-I-CAN-ANSWER.md +2 -1
  24. package/instrumentation.ts +11 -0
  25. package/lib/agent-edit-state.ts +25 -66
  26. package/lib/agent-job-state.ts +145 -0
  27. package/lib/agent-jobs-maintenance.ts +34 -0
  28. package/lib/agent-recreate-state.ts +24 -65
  29. package/lib/agent-restore-state.ts +25 -66
  30. package/lib/agent-restore.ts +28 -5
  31. package/lib/agent-update-state.ts +25 -61
  32. package/lib/channelManager.ts +12 -1
  33. package/lib/memory-context.ts +2 -2
  34. package/lib/model-catalogue.ts +17 -0
  35. package/lib/model-details.ts +124 -0
  36. package/lib/provider-labels.ts +21 -0
  37. package/model-details.json +16360 -0
  38. package/model-pricing.json +260 -24
  39. package/models.config.json +494 -10
  40. package/package.json +4 -2
  41. package/scripts/check-language.mjs +76 -0
  42. package/scripts/lib/model-upstream.mjs +61 -0
  43. package/scripts/model-info-suggest.mjs +149 -0
  44. 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
- `log` is the file name, in the Rev4a data directory, where the update's output is
2565
- written.
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:
@@ -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-15
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-15
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?"
@@ -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
  }
@@ -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 { openDb } from '@/lib/db';
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
- export function insertEdit(agentId: string, displayName: string | null, portRange: string | null): number {
29
- const db = openDb(false);
30
- try {
31
- const now = Date.now();
32
- const info = db.prepare(
33
- `INSERT INTO agent_edits (agent_id, status, display_name, port_range, started_at, updated_at)
34
- VALUES (?, 'rebuilding', ?, ?, ?, ?)`,
35
- ).run(agentId, displayName, portRange, now, now);
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
- const entries = Object.entries(fields).filter(([, v]) => v !== undefined);
46
- const now = Date.now();
47
- const sets = [...entries.map(([k]) => `${k} = ?`), 'updated_at = ?'];
48
- const values: unknown[] = [...entries.map(([, v]) => v), now];
49
- if (fields.status && FINAL.includes(fields.status)) {
50
- sets.push('finished_at = ?');
51
- values.push(now);
52
- } else if (fields.status) {
53
- sets.push('finished_at = NULL');
54
- }
55
- const db = openDb(false);
56
- try {
57
- db.prepare(`UPDATE agent_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
- const db = openDb(true);
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
- const row = latestEdit(agentId);
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
- const db = openDb(true);
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
- const db = openDb(false);
95
- try {
96
- const placeholders = ACTIVE_EDIT_STATUSES.map(() => '?').join(', ');
97
- const now = Date.now();
98
- const info = db.prepare(
99
- `UPDATE agent_edits
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 { openDb } from '@/lib/db';
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
- export function insertRecreate(agentId: string, image: string): number {
29
- const db = openDb(false);
30
- try {
31
- const now = Date.now();
32
- const info = db.prepare(
33
- `INSERT INTO agent_recreates (agent_id, status, image, started_at, updated_at)
34
- VALUES (?, 'backing_up', ?, ?, ?)`,
35
- ).run(agentId, image, now, now);
36
- return Number(info.lastInsertRowid);
37
- } finally {
38
- db.close();
39
- }
40
- }
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
- const entries = Object.entries(fields).filter(([, v]) => v !== undefined);
46
- const now = Date.now();
47
- const sets = [...entries.map(([k]) => `${k} = ?`), 'updated_at = ?'];
48
- const values: unknown[] = [...entries.map(([, v]) => v), now];
49
- if (fields.status && FINAL.includes(fields.status)) {
50
- sets.push('finished_at = ?');
51
- values.push(now);
52
- } else if (fields.status) {
53
- sets.push('finished_at = NULL');
54
- }
55
- const db = openDb(false);
56
- try {
57
- db.prepare(`UPDATE agent_recreates SET ${sets.join(', ')} WHERE id = ?`).run(...values, id);
58
- } finally {
59
- db.close();
60
- }
45
+ state.update(id, fields);
61
46
  }
62
47
 
63
48
  export function latestRecreate(agentId: string): AgentRecreateRow | null {
64
- const db = openDb(true);
65
- try {
66
- return (db.prepare('SELECT * FROM agent_recreates WHERE agent_id = ? ORDER BY id DESC LIMIT 1').get(agentId) as AgentRecreateRow | undefined) ?? null;
67
- } finally {
68
- db.close();
69
- }
49
+ return state.latest<AgentRecreateRow>(agentId);
70
50
  }
71
51
 
72
52
  export function isRecreateActive(agentId: string): boolean {
73
- const row = latestRecreate(agentId);
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
- const db = openDb(true);
80
- try {
81
- const placeholders = ACTIVE_RECREATE_STATUSES.map(() => '?').join(', ');
82
- const rows = db.prepare(`SELECT DISTINCT agent_id FROM agent_recreates WHERE status IN (${placeholders})`).all(...ACTIVE_RECREATE_STATUSES) as { agent_id: string }[];
83
- return new Set(rows.map((r) => r.agent_id));
84
- } finally {
85
- db.close();
86
- }
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
- const db = openDb(false);
95
- try {
96
- const placeholders = ACTIVE_RECREATE_STATUSES.map(() => '?').join(', ');
97
- const now = Date.now();
98
- const info = db.prepare(
99
- `UPDATE agent_recreates
100
- SET error = COALESCE(error, 'Rev4a restarted while this step was running: ' || status),
101
- status = 'interrupted', updated_at = ?, finished_at = ?
102
- WHERE status IN (${placeholders})`,
103
- ).run(now, now, ...ACTIVE_RECREATE_STATUSES);
104
- return info.changes;
105
- } finally {
106
- db.close();
107
- }
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
  }