@flame0510/project-aether 1.6.1 → 1.7.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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +5 -2
  3. package/agent-templates/atlas/HEARTBEAT.md +1 -1
  4. package/app/agents/ImageDownloadBanner.tsx +171 -37
  5. package/app/api/agents/download-image/route.ts +29 -5
  6. package/app/api/agents/image-status/route.ts +17 -2
  7. package/app/api/assistant/route.ts +13 -4
  8. package/app/api/auth/login/route.ts +2 -2
  9. package/app/api/setup/agent-image/route.ts +6 -4
  10. package/app/api/system-health/route.ts +3 -3
  11. package/app/components/DashboardToolbar.tsx +2 -2
  12. package/app/components/LineageGraphPage.tsx +4 -4
  13. package/app/components/SessionDrawer.tsx +8 -8
  14. package/app/components/SystemCockpit.tsx +1 -1
  15. package/app/globals.css +1 -1
  16. package/app/setup/PageClient.tsx +1 -1
  17. package/bin/postinstall.js +5 -1
  18. package/daemon.js +13 -34
  19. package/docs/ARCHITECTURE.md +44 -26
  20. package/docs/CONTAINER-TERMINAL.md +17 -8
  21. package/docs/DESIGN-SYSTEM.md +21 -12
  22. package/docs/FRONTEND-ARCHITECTURE.md +2 -2
  23. package/docs/REV4A.md +35 -85
  24. package/docs/dev/API-REFERENCE.md +73 -19
  25. package/docs/dev/DATABASE.md +18 -7
  26. package/docs/dev/GATEWAY.md +23 -0
  27. package/docs/dev/SESSION-MAINTENANCE-PLAN.md +6 -6
  28. package/docs/rag/DATA-FRESHNESS.md +20 -9
  29. package/docs/rag/GLOSSARY.md +2 -2
  30. package/docs/rag/REV4A-OVERVIEW.md +10 -6
  31. package/docs/rag/WHAT-I-CAN-ANSWER.md +3 -3
  32. package/lib/agent-images.ts +43 -14
  33. package/lib/buildAgentImage.ts +142 -6
  34. package/lib/patterns/sessionPresentation.ts +4 -2
  35. package/lib/rev4a-auth.d.ts +1 -0
  36. package/lib/rev4a-auth.js +18 -2
  37. package/models.config.json +937 -45
  38. package/next.config.mjs +9 -1
  39. package/package.json +5 -3
  40. package/scripts/backup.sh +48 -54
  41. package/scripts/check-language.mjs +21 -3
  42. package/scripts/model-info-verify.mjs +194 -0
  43. package/scripts/restore.sh +77 -59
package/next.config.mjs CHANGED
@@ -1,7 +1,15 @@
1
+ // Extra hostnames or IPs allowed to reach the dev server (`next dev` only — no effect on
2
+ // a production build), e.g. a LAN address when opening the dashboard from another device.
3
+ // Comma-separated, without scheme or port; none by default.
4
+ const extraDevOrigins = (process.env.REV4A_DEV_ORIGINS ?? '')
5
+ .split(',')
6
+ .map((origin) => origin.trim())
7
+ .filter(Boolean);
8
+
1
9
  const nextConfig = {
2
10
  reactStrictMode: true,
3
11
  serverExternalPackages: ['better-sqlite3'],
4
- allowedDevOrigins: ['187.77.156.41', '127.0.0.1', 'localhost', 'rev4a.srv1490011.hstgr.cloud'],
12
+ allowedDevOrigins: ['127.0.0.1', 'localhost', ...extraDevOrigins],
5
13
  };
6
14
 
7
15
  export default nextConfig;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flame0510/project-aether",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
4
4
  "description": "Rev4a — Revolution for Agents. OpenClaw agent fleet orchestrator.",
5
5
  "keywords": [
6
6
  "openclaw",
@@ -52,12 +52,14 @@
52
52
  "scripts": {
53
53
  "dev": "next dev -p 3720",
54
54
  "build": "next build",
55
+ "typecheck": "tsc --noEmit",
55
56
  "start": "rev4a serve",
56
57
  "start:all": "rev4a serve",
57
58
  "refresh:pricing": "node scripts/refresh-model-pricing.mjs",
58
- "smoke:db-bootstrap": "node scripts/smoke-db-bootstrap.mjs",
59
59
  "postinstall": "node bin/postinstall.js",
60
- "check:language": "node scripts/check-language.mjs"
60
+ "check:language": "node scripts/check-language.mjs",
61
+ "info:suggest": "node scripts/model-info-suggest.mjs",
62
+ "info:verify": "node scripts/model-info-verify.mjs"
61
63
  },
62
64
  "dependencies": {
63
65
  "@types/better-sqlite3": "^7.6.13",
package/scripts/backup.sh CHANGED
@@ -1,66 +1,60 @@
1
1
  #!/usr/bin/env bash
2
- # backup.sh — Backup Rev4a data to a tar.gz archive
2
+ # backup.sh — archive Rev4a's own data directory
3
3
  #
4
4
  # Usage:
5
- # sudo bash scripts/backup.sh # backup to ./backups/
6
- # sudo bash scripts/backup.sh /path/to/backups # custom directory
5
+ # bash scripts/backup.sh # archive into ~/rev4a-backups/
6
+ # bash scripts/backup.sh /path/to/backups # custom destination
7
7
  #
8
- # What it backs up:
9
- # - data/*.db (events, credentials, lineage)
10
- # - data/provider-keys.json
11
- # - data/agents-token.json
12
- # - models.config.json
13
- # - .env (credentials and configuration)
8
+ # What it archives: the whole Rev4a data directory — $REV4A_DATA_DIR when set,
9
+ # otherwise ~/.config/rev4a (the same rule as lib/rev4a-paths.ts). That is `.env`
10
+ # (secrets and configuration), everything under `data/` (events.db and credentials.db
11
+ # with their -wal/-shm files, provider-keys.json, agents-token.json, model overrides,
12
+ # the update log) and `shared/` (shared skills and rules). New files there are included
13
+ # automatically.
14
14
  #
15
- # Does NOT back up:
16
- # - node_modules (can be reinstalled)
17
- # - .next build cache (can be rebuilt)
15
+ # Not included: agent volumes (the cold backups in the dashboard cover those), the
16
+ # installed package, node_modules, .next.
17
+ #
18
+ # The archive holds every Rev4a secret in plain text: it is created with mode 0600, and a
19
+ # destination directory the script creates gets mode 0700 (an existing one is left as it
20
+ # is). Keep a copy off the machine, stored securely.
21
+ # For a fully consistent database copy, stop Rev4a first (`systemctl stop rev4a`);
22
+ # a copy taken while it runs is normally fine, since SQLite keeps its WAL alongside.
18
23
  set -euo pipefail
19
24
 
20
- # Resolve script directory
21
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
22
- REV4A_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
23
- BACKUP_DIR="${1:-${REV4A_ROOT}/backups}"
24
-
25
- mkdir -p "$BACKUP_DIR"
26
-
27
- TS=$(date -u +%Y%m%d-%H%M%S)
28
- ARCHIVE="${BACKUP_DIR}/rev4a-backup-${TS}.tar.gz"
29
-
30
- echo "Backing up Rev4a data..."
31
- echo " Source: ${REV4A_ROOT}"
32
- echo " Target: ${ARCHIVE}"
25
+ DATA_DIR="${REV4A_DATA_DIR:-${HOME}/.config/rev4a}"
26
+ BACKUP_DIR="${1:-${HOME}/rev4a-backups}"
33
27
 
34
- cd "$REV4A_ROOT"
28
+ if [[ ! -d "$DATA_DIR" ]]; then
29
+ echo "Error: Rev4a data directory not found: $DATA_DIR" >&2
30
+ echo "Set REV4A_DATA_DIR if Rev4a keeps its data elsewhere." >&2
31
+ exit 1
32
+ fi
33
+ if [[ ! -f "$DATA_DIR/.env" && ! -d "$DATA_DIR/data" ]]; then
34
+ echo "Error: $DATA_DIR has neither .env nor data/ — refusing to archive an unrelated directory." >&2
35
+ exit 1
36
+ fi
35
37
 
36
- # Restrict permissions during archive creation
37
38
  umask 077
38
-
39
- # Create tar with only the files we care about
40
- tar -czf "$ARCHIVE" \
41
- --exclude='node_modules' \
42
- --exclude='.next' \
43
- --exclude='.git' \
44
- --exclude='backups' \
45
- data/*.db \
46
- data/*.db-shm \
47
- data/*.db-wal \
48
- data/provider-keys.json 2>/dev/null || true \
49
- data/agents-token.json 2>/dev/null || true \
50
- models.config.json 2>/dev/null || true \
51
- .env 2>/dev/null || true
52
-
53
- echo ""
54
- echo "=============================================="
55
- echo " Backup complete!"
56
- echo "=============================================="
57
- echo ""
58
- echo " Archive: ${ARCHIVE}"
59
- echo " Size: $(du -h "$ARCHIVE" | cut -f1)"
60
- echo ""
39
+ mkdir -p "$BACKUP_DIR"
40
+ # The archive must not end up inside what it archives. Both paths are compared once they
41
+ # exist and with symlinks resolved, so a relative or linked destination cannot slip past.
42
+ case "$(cd "$BACKUP_DIR" && pwd -P)/" in
43
+ "$(cd "$DATA_DIR" && pwd -P)/"*) echo "Error: the destination is inside the data directory." >&2; exit 1 ;;
44
+ esac
45
+
46
+ ARCHIVE="${BACKUP_DIR}/rev4a-backup-$(date -u +%Y%m%d-%H%M%S).tar.gz"
47
+ echo "Backing up Rev4a data"
48
+ echo " Source: $DATA_DIR"
49
+ echo " Target: $ARCHIVE"
50
+
51
+ # noclobber: a second backup in the same second fails instead of overwriting the first.
52
+ set -o noclobber
53
+ tar -C "$DATA_DIR" -czf - . > "$ARCHIVE"
54
+ set +o noclobber
61
55
  chmod 600 "$ARCHIVE"
62
- echo " WARNING: This archive contains plain-text credentials (.env)."
63
- echo " Store it securely (encrypted at rest recommended)."
56
+
64
57
  echo ""
65
- echo " To restore: sudo bash scripts/restore.sh ${ARCHIVE}"
66
- echo "=============================================="
58
+ echo "Backup complete: $(tar -tzf "$ARCHIVE" | grep -vc '/$') files, $(du -h "$ARCHIVE" | cut -f1)"
59
+ echo "It contains plain-text secrets (.env, keys, credentials): keep a copy off this machine, stored securely."
60
+ echo "To restore: bash scripts/restore.sh $ARCHIVE"
@@ -4,7 +4,7 @@
4
4
  * identifiers, comments, docs, UI strings (AGENTS.md §2.1).
5
5
  *
6
6
  * The word list is deliberately conservative: only words that are **not** English words.
7
- * `come`, `per`, `non`, `la`, `al`, `tra` are left out on purpose, they would fire on
7
+ * `come`, `per`, `non`, `la`, `al` are left out on purpose, they would fire on
8
8
  * English text; the list is content words plus unmistakable function words. A line
9
9
  * carrying `lang-ok` is skipped, for the legitimate case where the Italian is the point
10
10
  * (a fixture, a note about the rule):
@@ -12,8 +12,9 @@
12
12
  * // lang-ok: the exact text a user reported
13
13
  *
14
14
  * It catches Italian *words*, not Italian thinking written in English, and it never
15
- * translates: the fix is manual. Wired into the release workflow (a release cannot ship
16
- * Italian) and available locally as `npm run check:language`.
15
+ * translates: the fix is manual. Run by CI on every push to main and every pull request,
16
+ * again by the release workflow before publishing, and available locally as
17
+ * `npm run check:language` (and as a pre-commit hook, `.githooks/pre-commit`).
17
18
  *
18
19
  * Usage:
19
20
  * node scripts/check-language.mjs # every tracked file
@@ -35,6 +36,23 @@ const WORDS = [
35
36
  'essere', 'fare', 'dove', 'nuovo', 'nuova', 'vecchio', 'vecchia', 'davvero', 'invece',
36
37
  'inoltre', 'quindi', 'della', 'delle', 'degli', 'nella', 'nelle', 'negli', 'dalla', 'dalle',
37
38
  'dallo', 'allo', 'alla', 'sul', 'sulla', 'nessuno', 'nessuna', 'adesso', 'subito', 'oggi', 'ieri',
39
+ // Added 2026-09-24, after three lines survived the list above — one of them a UI string
40
+ // ('Include OpenRouter e altri provider…' in the system-health recommendations).
41
+ 'compila', 'compilare', 'compilato', 'pubblica', 'pubblico', 'pubblicare', 'pubblicato',
42
+ 'altro', 'altra', 'altri', 'altre', 'giorno', 'giorni', 'mese', 'settimana', 'settimane',
43
+ 'aggiorna', 'aggiornare', 'aggiornamento', 'aggiornato', 'controllo', 'controlla', 'controllare',
44
+ 'verifica', 'verificare', 'esempio', 'esempi', 'funziona', 'funzione', 'funzioni',
45
+ 'creare', 'cancella', 'cancellare', 'scarica', 'scaricare', 'usare', 'usato', 'utilizzo',
46
+ 'riga', 'parola', 'parole', 'elenco', 'codice', 'chiave', 'chiavi', 'prova', 'risultato',
47
+ 'risultati', 'stesso', 'stessa', 'stessi', 'stesse', 'ancora', 'sempre', 'dopo', 'forse',
48
+ 'puoi', 'posso', 'siamo', 'abbiamo', 'erano', 'servono', 'vuole', 'vuoi', 'quale', 'quali',
49
+ 'disponibile', 'necessario', 'corretto', 'sbagliato', 'mancante', 'vuoto', 'ultimo', 'ultima',
50
+ 'italiano', 'italiana', 'italiane', 'inglese', 'pero', 'cosi', 'piu', 'ecco', 'grazie',
51
+ // Added after a later review found UI labels, alert text and a doc line the list above
52
+ // missed (session drawer, lineage and dashboard toolbars, system-health alerts).
53
+ 'stato', 'tipo', 'costo', 'costi', 'durata', 'inizio', 'aggiorn', 'azioni', 'recenti',
54
+ 'grafo', 'reimposta', 'periodo', 'agenti', 'agente', 'apri', 'soglia', 'coprire',
55
+ 'contenuti', 'allinea', 'allineare', 'campioni', 'consecutivi',
38
56
  ];
39
57
 
40
58
  const EXTENSIONS = ['.ts', '.tsx', '.js', '.mjs', '.cjs', '.md', '.json', '.sh', '.yml', '.yaml', '.css', '.html'];
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Verifies proposed `info` blocks against the model cards they cite — the gate between a
4
+ * curator agent's proposal and `models.config.json`. Nothing is written: the script says
5
+ * which proposals hold and which do not.
6
+ *
7
+ * What it checks, per model:
8
+ * - the id exists in the catalogue, and the model has a Hugging Face card;
9
+ * - `evidence` is present (a proposal without it is not verifiable);
10
+ * - every number in `params` appears in the card text;
11
+ * - every benchmark value sits in a row named after the benchmark, under a column whose
12
+ * header names this model (or its instruct variant) — the nearest header row that
13
+ * carries the model's name, so a card with a stacked or merged header still resolves;
14
+ * - `docUrl`, when asked with `--check-links`, answers 200.
15
+ *
16
+ * Usage:
17
+ * node scripts/model-info-verify.mjs <file with the proposals|-> [--check-links]
18
+ * The file may be raw agent output: JSON objects are extracted from it.
19
+ */
20
+ import * as fs from 'node:fs';
21
+ import * as path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
+ const args = process.argv.slice(2);
26
+ const input = args.find((a) => !a.startsWith('--')) ?? '-';
27
+ const checkLinks = args.includes('--check-links');
28
+
29
+ const catalogue = new Set(JSON.parse(fs.readFileSync(path.join(ROOT, 'models.config.json'), 'utf-8')).models.map((m) => m.id));
30
+ const details = (() => {
31
+ try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'model-details.json'), 'utf-8')).models ?? {}; } catch { return {}; }
32
+ })();
33
+
34
+ const decode = (s) => s.replace(/<[^>]*>/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/\s+/g, ' ').trim();
35
+ const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
36
+
37
+ /** Every table row of a card, markdown and HTML, as normalised `| a | b |` strings. */
38
+ function tableRows(md) {
39
+ const rows = md.split('\n').filter((l) => l.trim().startsWith('|')).map(decode);
40
+ for (const tr of md.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/gi)) {
41
+ const cells = [...tr[1].matchAll(/<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi)].map((c) => decode(c[1]));
42
+ if (cells.length) rows.push(`| ${cells.join(' | ')} |`);
43
+ }
44
+ return rows;
45
+ }
46
+
47
+ const cellsOf = (row) => row.split('|').slice(1, -1).map((c) => c.replace(/\*/g, '').trim());
48
+
49
+ /**
50
+ * A header cell names the model when it carries the same distinctive run of characters:
51
+ * cards label columns with a display name ("Kimi K2.5 (Thinking)", "Nemotron-3-Super")
52
+ * that is not the id, and an exact match would miss every one of them.
53
+ */
54
+ function namesModel(cell, modelNames) {
55
+ const c = norm(cell);
56
+ if (!c) return false;
57
+ return modelNames.some((n) => {
58
+ const m = norm(n);
59
+ if (!m) return false;
60
+ for (let len = Math.min(c.length, m.length); len >= 6; len--) {
61
+ for (let i = 0; i + len <= c.length; i++) if (m.includes(c.slice(i, i + len))) return true;
62
+ }
63
+ return false;
64
+ });
65
+ }
66
+
67
+ /** The nearest row above `row` that names the model: the header this value belongs under. */
68
+ function headerNaming(rows, rowIndex, modelNames) {
69
+ for (let i = rowIndex - 1; i >= 0; i--) {
70
+ if (!rows[i]) break;
71
+ const cells = cellsOf(rows[i]);
72
+ if (cells.some((c) => namesModel(c, modelNames))) return cells;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ function proposalObjects(text) {
78
+ const out = [];
79
+ for (let i = 0; i < text.length; i++) {
80
+ if (text[i] !== '{') continue;
81
+ let depth = 0, inString = false, escaped = false, end = -1;
82
+ for (let j = i; j < text.length; j++) {
83
+ const ch = text[j];
84
+ if (inString) {
85
+ if (escaped) escaped = false;
86
+ else if (ch === '\\') escaped = true;
87
+ else if (ch === '"') inString = false;
88
+ continue;
89
+ }
90
+ if (ch === '"') inString = true;
91
+ else if (ch === '{') depth++;
92
+ else if (ch === '}') { depth--; if (depth === 0) { end = j; break; } }
93
+ }
94
+ if (end < 0) continue; // an unclosed fragment (engine logs) must not stop the scan
95
+ try {
96
+ const parsed = JSON.parse(text.slice(i, end + 1));
97
+ if (parsed && parsed.id && (parsed.info || parsed.skipped)) out.push(parsed);
98
+ } catch { /* not a proposal object */ }
99
+ i = end;
100
+ }
101
+ return out;
102
+ }
103
+
104
+ async function cardFor(hfId) {
105
+ const cached = path.join('/tmp', `card-${hfId.replace('/', '_')}.md`);
106
+ if (fs.existsSync(cached)) return fs.readFileSync(cached, 'utf-8');
107
+ const res = await fetch(`https://huggingface.co/${hfId}/raw/main/README.md`, { signal: AbortSignal.timeout(30_000) });
108
+ if (!res.ok) throw new Error(`card HTTP ${res.status}`);
109
+ const md = await res.text();
110
+ fs.writeFileSync(cached, md);
111
+ return md;
112
+ }
113
+
114
+ async function verify(model, catalogueId) {
115
+ const problems = [];
116
+ const warnings = [];
117
+ if (!catalogue.has(catalogueId)) problems.push('id not in the catalogue');
118
+ if (model.skipped) return { problems, warnings: [`skipped: ${model.skipped}`] };
119
+ const hfId = details[catalogueId]?.hf?.id;
120
+ if (!hfId) return { problems, warnings: ['no Hugging Face card: not verifiable'] };
121
+ if (!model.evidence?.length) problems.push('evidence missing');
122
+ let md;
123
+ try {
124
+ md = await cardFor(hfId);
125
+ } catch (e) {
126
+ if (e instanceof Error) return { problems, warnings: [`card unavailable: ${e.message}`] };
127
+ return { problems, warnings: ['card unavailable'] };
128
+ }
129
+ const rows = tableRows(md);
130
+ const shortName = hfId.split('/').pop();
131
+ const modelNames = [shortName, catalogueId.split('/').pop(), shortName.replace(/-/g, '')];
132
+
133
+ const info = model.info ?? {};
134
+ if (info.params) {
135
+ const numbers = info.params.match(/\d+(?:\.\d+)?\s*[BT]/g) ?? [];
136
+ const missing = numbers.filter((n) => !md.replace(/\s/g, '').includes(n.replace(/\s/g, '')));
137
+ if (missing.length) problems.push(`params: numbers not in the card (${missing.join(', ')})`);
138
+ }
139
+ if (info.docUrl && checkLinks) {
140
+ try {
141
+ const res = await fetch(info.docUrl, { method: 'GET', signal: AbortSignal.timeout(20_000) });
142
+ if (!res.ok) problems.push(`docUrl: HTTP ${res.status}`);
143
+ } catch {
144
+ problems.push('docUrl: unreachable');
145
+ }
146
+ }
147
+ const sameNumber = (cell, value) => {
148
+ const parsed = Number(String(cell).replace(/[^0-9.\-]/g, ''));
149
+ return Number.isFinite(parsed) && Math.abs(parsed - value) < 1e-9;
150
+ };
151
+ for (const bench of info.benchmarks ?? []) {
152
+ // A benchmark name can appear in more than one table (or in prose rows); accept the
153
+ // value when any row named after it carries it, then check the column of that row.
154
+ const candidates = rows
155
+ .map((r, index) => ({ r, index }))
156
+ .filter(({ r }) => r.toLowerCase().includes(String(bench.name).toLowerCase()) && /\d/.test(r));
157
+ if (candidates.length === 0) { problems.push(`${bench.name}: no matching row`); continue; }
158
+ const hit = candidates.find(({ r }) => cellsOf(r).some((c) => sameNumber(c, bench.value)));
159
+ if (!hit) { problems.push(`${bench.name}: value ${bench.value} not in any row named after it`); continue; }
160
+ const cells = cellsOf(hit.r);
161
+ const header = headerNaming(rows, hit.index, modelNames);
162
+ if (!header) { warnings.push(`${bench.name}: no header naming the model above the row — check by hand`); continue; }
163
+ const valueAt = cells.map((c, i) => (sameNumber(c, bench.value) ? i : -1)).filter((i) => i >= 0);
164
+ const owns = valueAt.some((i) => header[i] && namesModel(header[i], modelNames));
165
+ if (!owns) problems.push(`${bench.name}: value sits under "${valueAt.map((i) => header[i] ?? '?').join(', ')}"`);
166
+ }
167
+ return { problems, warnings };
168
+ }
169
+
170
+ const raw = input === '-' ? fs.readFileSync(0, 'utf-8') : fs.readFileSync(input, 'utf-8');
171
+ let models;
172
+ try {
173
+ const parsed = JSON.parse(raw);
174
+ models = Array.isArray(parsed) ? parsed : Object.values(parsed);
175
+ } catch {
176
+ models = proposalObjects(raw);
177
+ }
178
+
179
+ let clean = 0, flagged = 0, warned = 0;
180
+ for (const model of models) {
181
+ const { problems, warnings } = await verify(model, model.id);
182
+ if (problems.length) {
183
+ flagged++;
184
+ console.log(`✗ ${model.id}\n ${problems.join('\n ')}`);
185
+ } else if (warnings.length) {
186
+ warned++;
187
+ console.log(`! ${model.id}\n ${warnings.join('\n ')}`);
188
+ } else {
189
+ clean++;
190
+ console.log(`✓ ${model.id}`);
191
+ }
192
+ }
193
+ console.log(`\n${models.length} proposal(s): ${clean} verified, ${warned} to check by hand, ${flagged} rejected`);
194
+ process.exit(flagged > 0 ? 1 : 0);
@@ -1,82 +1,100 @@
1
1
  #!/usr/bin/env bash
2
- # restore.sh — Restore Rev4a data from a backup archive
2
+ # restore.sh — restore Rev4a's data directory from a backup.sh archive
3
3
  #
4
4
  # Usage:
5
- # sudo bash scripts/restore.sh backups/rev4a-backup-20260724-120000.tar.gz
5
+ # bash scripts/restore.sh ~/rev4a-backups/rev4a-backup-20260926-120000.tar.gz
6
6
  #
7
- # The script restores:
8
- # - data/*.db
9
- # - data/provider-keys.json
10
- # - data/agents-token.json
11
- # - models.config.json
12
- # - .env
7
+ # Restores into $REV4A_DATA_DIR when set, otherwise ~/.config/rev4a — the directory
8
+ # backup.sh archived. Run it as the user Rev4a runs as: under `sudo` HOME becomes /root
9
+ # and REV4A_DATA_DIR is dropped, so pass it explicitly (`sudo REV4A_DATA_DIR=… bash …`).
13
10
  #
14
- # Existing files are backed up to a .pre-restore directory before overwriting.
11
+ # The data directory is replaced as a whole, not overwritten file by file: a file the
12
+ # backup does not have (a leftover SQLite -wal, for one) would otherwise be mixed into
13
+ # the restored state. The current contents are archived first into ~/rev4a-backups, so a
14
+ # restore can itself be undone.
15
+ #
16
+ # Rev4a must not be running. When the rev4a systemd service is active it is stopped (and
17
+ # must be started again afterwards); a `rev4a serve` started any other way makes the
18
+ # script refuse until it is stopped.
15
19
  set -euo pipefail
16
20
 
17
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18
- REV4A_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
19
-
21
+ DATA_DIR="${REV4A_DATA_DIR:-${HOME}/.config/rev4a}"
22
+ PRE_DIR="${HOME}/rev4a-backups"
20
23
  ARCHIVE="${1:-}"
21
24
 
22
25
  if [[ -z "$ARCHIVE" ]]; then
23
- echo "Usage: sudo bash scripts/restore.sh <backup-archive.tar.gz>"
26
+ echo "Usage: bash scripts/restore.sh <rev4a-backup-….tar.gz>"
24
27
  echo ""
25
- echo "Available backups:"
26
- if [[ -d "${REV4A_ROOT}/backups" ]]; then
27
- ls -lh "${REV4A_ROOT}/backups/"*.tar.gz 2>/dev/null || echo " (none)"
28
- else
29
- echo " (no backups directory)"
30
- fi
28
+ echo "Backups in ~/rev4a-backups:"
29
+ ls -lh "${HOME}/rev4a-backups/"rev4a-backup-*.tar.gz 2>/dev/null || echo " (none)"
31
30
  exit 1
32
31
  fi
33
-
34
32
  if [[ ! -f "$ARCHIVE" ]]; then
35
- echo "Error: Archive not found: $ARCHIVE"
33
+ echo "Error: archive not found: $ARCHIVE" >&2
36
34
  exit 1
37
35
  fi
36
+ if [[ ! -d "$DATA_DIR" ]]; then
37
+ # Restoring into a directory nothing reads would "succeed" and change nothing. On a new
38
+ # machine, create it first — the check is only there to catch a wrong path.
39
+ echo "Error: Rev4a data directory not found: $DATA_DIR" >&2
40
+ echo "Set REV4A_DATA_DIR to the directory Rev4a uses, or create it first on a new machine." >&2
41
+ exit 1
42
+ fi
43
+ # The real directory: when the path is a symlink, the swap below replaces its target.
44
+ DATA_DIR="$(cd "$DATA_DIR" && pwd -P)"
38
45
 
39
- echo "Restoring Rev4a from: $ARCHIVE"
40
- echo ""
41
-
42
- # Create pre-restore backup of current state
43
- PRE_DIR="${REV4A_ROOT}/backups/.pre-restore-$(date -u +%Y%m%d-%H%M%S)"
44
- mkdir -p "$PRE_DIR"
45
-
46
- echo "Saving current state to: $PRE_DIR"
47
- cd "$REV4A_ROOT"
48
- for f in data/*.db data/*.db-shm data/*.db-wal data/provider-keys.json data/agents-token.json models.config.json .env; do
49
- if [[ -f "$f" ]]; then
50
- cp --parents "$f" "$PRE_DIR/" 2>/dev/null || true
51
- fi
52
- done
46
+ # Refuse an archive that is not a backup.sh archive of a Rev4a data directory. The listing
47
+ # is read whole: `grep -q` would stop reading early and, with pipefail, turn tar's SIGPIPE
48
+ # into a false refusal on large archives.
49
+ LISTING="$(tar -tvzf "$ARCHIVE")"
50
+ if ! grep -E '[[:space:]]\./(\.env|data/)' <<<"$LISTING" >/dev/null; then
51
+ echo "Error: $ARCHIVE does not look like a Rev4a data backup (no .env or data/ inside)." >&2
52
+ exit 1
53
+ fi
54
+ # backup.sh never stores links; a link in the archive could point outside the data directory.
55
+ if grep -E '^[lh]' <<<"$LISTING" >/dev/null; then
56
+ echo "Error: $ARCHIVE contains symbolic or hard links — refusing to restore it." >&2
57
+ exit 1
58
+ fi
53
59
 
54
- # Stop Rev4a services before restore
55
- if systemctl is-active --quiet rev4a.service 2>/dev/null; then
56
- echo "Stopping Rev4a services..."
57
- sudo systemctl stop rev4a.service
58
- echo "Services stopped."
60
+ if command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet rev4a.service 2>/dev/null; then
61
+ echo "Stopping rev4a.service..."
62
+ systemctl stop rev4a.service
63
+ STOPPED=1
64
+ fi
65
+ if pgrep -f 'rev4a(\.js)? serve' >/dev/null 2>&1; then
66
+ echo "Error: Rev4a is running (rev4a serve). Stop it first: it keeps the databases open." >&2
67
+ exit 1
59
68
  fi
60
69
 
61
- # Extract the archive
62
- echo "Extracting backup..."
63
- tar -xzf "$ARCHIVE" -C "$REV4A_ROOT"
70
+ umask 077
71
+ mkdir -p "$PRE_DIR"
72
+ PRE="${PRE_DIR}/rev4a-pre-restore-$(date -u +%Y%m%d-%H%M%S).tar.gz"
73
+ echo "Archiving the current state first: $PRE"
74
+ set -o noclobber
75
+ tar -C "$DATA_DIR" -czf - . > "$PRE"
76
+ set +o noclobber
77
+ chmod 600 "$PRE"
64
78
 
65
- # Fix permissions
66
- chmod 600 "${REV4A_ROOT}/.env" 2>/dev/null || true
67
- chmod 600 "${REV4A_ROOT}/data/provider-keys.json" 2>/dev/null || true
68
- chmod 600 "${REV4A_ROOT}/data/agents-token.json" 2>/dev/null || true
79
+ # Extract next to the data directory, then swap: a failed extraction leaves the current
80
+ # data untouched, and nothing from the current state survives into the restored one.
81
+ PARENT="$(dirname "$DATA_DIR")"
82
+ STAGE="$(mktemp -d "${PARENT}/.rev4a-restore.XXXXXX")"
83
+ trap 'rm -rf "$STAGE"' EXIT
84
+ echo "Restoring into: $DATA_DIR"
85
+ tar -C "$STAGE" --no-same-owner -xzf "$ARCHIVE"
86
+ find "$STAGE" -type d -exec chmod 700 {} +
87
+ [[ -f "$STAGE/.env" ]] && chmod 600 "$STAGE/.env"
88
+ [[ -d "$STAGE/data" ]] && find "$STAGE/data" -type f -exec chmod 600 {} +
89
+
90
+ OLD="${PARENT}/.rev4a-replaced.$$"
91
+ mv "$DATA_DIR" "$OLD"
92
+ mv "$STAGE" "$DATA_DIR"
93
+ trap - EXIT
94
+ rm -rf "$OLD"
69
95
 
70
96
  echo ""
71
- echo "=============================================="
72
- echo " Restore complete!"
73
- echo "=============================================="
74
- echo ""
75
- echo " Previous state saved to: ${PRE_DIR}"
76
- echo ""
77
- echo " To restart Rev4a:"
78
- echo " sudo systemctl start rev4a.service"
79
- echo ""
80
- echo " If something went wrong, restore previous state from:"
81
- echo " ${PRE_DIR}"
82
- echo "=============================================="
97
+ echo "Restore complete."
98
+ echo "The previous state is in: $PRE"
99
+ [[ -n "${STOPPED:-}" ]] && echo "Start Rev4a again: systemctl start rev4a.service"
100
+ exit 0