@flame0510/project-aether 1.1.15 → 1.3.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 (60) hide show
  1. package/README.md +2 -1
  2. package/app/agents/ModelSection.tsx +313 -0
  3. package/app/agents/PageClient.tsx +83 -4
  4. package/app/agents/create/page.tsx +8 -21
  5. package/app/api/agents/[id]/model/route.ts +113 -0
  6. package/app/api/agents/[id]/recreate/route.ts +10 -34
  7. package/app/api/agents/[id]/route.ts +10 -29
  8. package/app/api/agents/create/route.ts +59 -57
  9. package/app/api/agents/models-summary/route.ts +163 -0
  10. package/app/api/assistant/route.ts +36 -15
  11. package/app/api/credentials/[id]/sync/route.ts +3 -3
  12. package/app/api/credentials/detect/route.ts +126 -176
  13. package/app/api/credentials/route.ts +3 -0
  14. package/app/api/gateway/agent/route.ts +23 -6
  15. package/app/api/gateway/provider/keys.ts +13 -1
  16. package/app/api/gateway/provider/route.ts +43 -12
  17. package/app/api/gateway/sync.ts +248 -72
  18. package/app/api/models/route.ts +28 -34
  19. package/app/api/provider/auth.ts +65 -0
  20. package/app/api/provider/upstream.ts +9 -2
  21. package/app/api/provider/v1/chat/completions/route.ts +22 -16
  22. package/app/api/provider/v1/models/route.ts +26 -133
  23. package/app/components/PulseChat.tsx +25 -39
  24. package/app/components/Skeleton.tsx +132 -0
  25. package/app/components/ui/RemoveButton.tsx +46 -0
  26. package/app/components/ui/Select.tsx +3 -2
  27. package/app/components/ui/index.ts +1 -0
  28. package/app/credentials/PageClient.tsx +461 -140
  29. package/app/credentials/loading.tsx +19 -5
  30. package/app/gateway/PageClient.tsx +257 -673
  31. package/app/globals.css +8 -0
  32. package/app/lib/models-context.tsx +43 -7
  33. package/app/wizard/useWizard.ts +6 -1
  34. package/bin/rev4a.js +73 -9
  35. package/docs/ARCHITECTURE.md +92 -33
  36. package/docs/FRONTEND-ARCHITECTURE.md +36 -6
  37. package/docs/REV4A.md +62 -30
  38. package/docs/dev/API-REFERENCE.md +490 -227
  39. package/docs/dev/DATABASE.md +8 -3
  40. package/docs/dev/GATEWAY.md +236 -92
  41. package/docs/dev/PROVIDERS.md +44 -44
  42. package/docs/rag/DATA-FRESHNESS.md +57 -28
  43. package/docs/rag/GLOSSARY.md +20 -18
  44. package/docs/rag/REV4A-OVERVIEW.md +28 -32
  45. package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -12
  46. package/instrumentation.ts +9 -1
  47. package/lib/agent-readiness.ts +110 -0
  48. package/lib/channelManager.ts +64 -22
  49. package/lib/container-file.ts +27 -0
  50. package/lib/credentials/delivery.ts +212 -119
  51. package/lib/credentials/detect.ts +229 -97
  52. package/lib/credentials/providers.ts +38 -7
  53. package/lib/credentials/vault.ts +78 -13
  54. package/lib/docker-exec.ts +50 -14
  55. package/lib/model-catalogue.ts +140 -27
  56. package/lib/rev4a-paths.ts +0 -21
  57. package/model-pricing.json +118 -110
  58. package/models.config.json +27 -12
  59. package/package.json +1 -1
  60. package/app/api/gateway/route.ts +0 -191
package/app/globals.css CHANGED
@@ -1296,6 +1296,14 @@ html, body { height: 100%; height: 100dvh; background: var(--bg); color: var(--t
1296
1296
  background: rgba(var(--red-rgb,239,68,68),0.1);
1297
1297
  color: var(--red);
1298
1298
  }
1299
+ /* Saved, but not fully applied — e.g. a change stored while an agent sync failed. */
1300
+ .msg-warning {
1301
+ padding: 8px 12px;
1302
+ border-radius: 0;
1303
+ font-size: 12px;
1304
+ background: rgba(245,158,11,0.1);
1305
+ color: var(--yellow);
1306
+ }
1299
1307
 
1300
1308
  /* --- Mobile Bottom Navigation --- */
1301
1309
  .mobile-bottom-nav {
@@ -1,6 +1,20 @@
1
1
  'use client';
2
2
 
3
- import { createContext, useContext, useEffect, useState } from 'react';
3
+ /**
4
+ * The model list, fetched once and shared by every client that needs it.
5
+ *
6
+ * There used to be two independent copies: this provider, and a private fetch
7
+ * inside PulseChat. Both ran on mount with an empty dependency list, and neither
8
+ * component ever unmounts — so unchecking a model on the Gateway page left the
9
+ * chat panel offering it for the rest of the session, and only a full page reload
10
+ * cleared it.
11
+ *
12
+ * The fix is not to refetch more often. It is to refetch when the answer
13
+ * changes: `refresh()` is called by whoever changes it — the Gateway page after a
14
+ * model toggle, a key save or removal, and a sync; the first-run wizard after
15
+ * saving keys. Everything else just reads.
16
+ */
17
+ import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
4
18
 
5
19
  interface ProviderEntry {
6
20
  provider: string;
@@ -11,26 +25,48 @@ interface ProviderEntry {
11
25
  interface ModelsContextValue {
12
26
  providers: ProviderEntry[];
13
27
  loaded: boolean;
28
+ /** Re-read the catalogue. Call after changing which models are enabled. */
29
+ refresh: () => void;
14
30
  }
15
31
 
16
- const ModelsContext = createContext<ModelsContextValue>({ providers: [], loaded: false });
32
+ const ModelsContext = createContext<ModelsContextValue>({
33
+ providers: [],
34
+ loaded: false,
35
+ refresh: () => {},
36
+ });
17
37
 
18
38
  export function ModelsProvider({ children }: { children: React.ReactNode }) {
19
39
  const [providers, setProviders] = useState<ProviderEntry[]>([]);
20
40
  const [loaded, setLoaded] = useState(false);
21
41
 
22
- useEffect(() => {
42
+ // Refreshes can overlap — two quick toggles — and responses can arrive out of
43
+ // order. Only the most recent request may write.
44
+ const seq = useRef(0);
45
+
46
+ const refresh = useCallback(() => {
47
+ const mine = ++seq.current;
23
48
  fetch('/api/models')
24
- .then((r) => r.json())
49
+ .then((r) => {
50
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
51
+ return r.json();
52
+ })
25
53
  .then((data) => {
26
- if (Array.isArray(data) && data.length) setProviders(data);
54
+ if (mine !== seq.current) return;
55
+ // An empty array is a real answer — every model disabled, or no provider
56
+ // key — so it must replace the list rather than be discarded as a failure.
57
+ if (Array.isArray(data)) setProviders(data);
27
58
  setLoaded(true);
28
59
  })
29
- .catch(() => setLoaded(true)); // fallback silenzioso
60
+ .catch(() => {
61
+ // Keep the previous list: a failed refresh is not an empty catalogue.
62
+ if (mine === seq.current) setLoaded(true);
63
+ });
30
64
  }, []);
31
65
 
66
+ useEffect(() => { refresh(); }, [refresh]);
67
+
32
68
  return (
33
- <ModelsContext.Provider value={{ providers, loaded }}>
69
+ <ModelsContext.Provider value={{ providers, loaded, refresh }}>
34
70
  {children}
35
71
  </ModelsContext.Provider>
36
72
  );
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useState, useCallback, useEffect } from 'react';
4
+ import { useModels } from '../lib/models-context';
4
5
  import {
5
6
  isWizardComplete,
6
7
  markWizardComplete,
@@ -45,6 +46,9 @@ export function useWizard() {
45
46
  // Step tracking is in-memory only; the cookie is the source of truth
46
47
  }, []);
47
48
 
49
+ // Saving a key changes which models are offered; the shared list must follow.
50
+ const { refresh: refreshOfferedModels } = useModels();
51
+
48
52
  const saveProviders = useCallback(async () => {
49
53
  const entries = Object.entries(providerKeys).filter(([, v]) => v.trim());
50
54
  if (entries.length === 0) {
@@ -60,10 +64,11 @@ export function useWizard() {
60
64
  body: JSON.stringify({ provider, apiKey: apiKey.trim() }),
61
65
  });
62
66
  }
67
+ refreshOfferedModels();
63
68
  markStep('providers');
64
69
  } catch { /* API call failed — do not mark step as complete */ }
65
70
  setSaving(false);
66
- }, [providerKeys, markStep]);
71
+ }, [providerKeys, markStep, refreshOfferedModels]);
67
72
 
68
73
  const goNext = useCallback(async () => {
69
74
  if (step === 'welcome') { markStep('welcome'); setStep('providers'); }
package/bin/rev4a.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * rev4a serve:
11
11
  * 1. Ensures .env exists (auto-generates on first run)
12
12
  * 2. Ensures .next build exists (builds if missing)
13
- * 3. Ensures Docker is installed (installs if missing + root)
13
+ * 3. Ensures Docker is installed (installs if missing + root) and waits for its daemon
14
14
  * 4. Ensures agent base image is built (builds if missing)
15
15
  * 5. Starts Next.js + daemon + terminal WS as a single process group
16
16
  *
@@ -44,6 +44,17 @@ const DOCKERFILE = join(ROOT, 'agent-templates', 'base-image', 'Dockerfile');
44
44
  const AGENT_IMAGE = 'openclaw-agent-base:latest';
45
45
  const AGENT_IMAGE_REGISTRY = 'ghcr.io/flame0510/rev4a/openclaw-agent-base:latest';
46
46
  const DOCKER_NETWORK = 'rev4a-network';
47
+ // How long `serve` waits for the Docker daemon before starting without it. Like
48
+ // REV4A_DATA_DIR it is read from the real process environment (a systemd
49
+ // Environment= line, a shell export), because the .env file is loaded later.
50
+ const DOCKER_WAIT_SECONDS = (() => {
51
+ const raw = (process.env.REV4A_DOCKER_WAIT_SECONDS || '').trim();
52
+ if (!raw) return 90;
53
+ const n = Number(raw);
54
+ // 0 means a single check. Non-numeric or infinite values fall back to the
55
+ // default, and the ceiling keeps a typo from holding start-up for hours.
56
+ return Number.isFinite(n) && n >= 0 ? Math.min(n, 600) : 90;
57
+ })();
47
58
 
48
59
  // Fallback used only when package.json cannot be read — the published npm
49
60
  // package name is the single source of truth, so never hardcode it elsewhere.
@@ -97,25 +108,70 @@ function whichDistro() {
97
108
  }
98
109
  }
99
110
 
100
- // ── Docker auto-install ─────────────────────────────────────────────────────
111
+ // ── Docker auto-install and daemon wait ─────────────────────────────────────
101
112
 
113
+ /** Block this thread for `ms` without spawning a process (`sleep` is not on every OS). */
114
+ function sleepSync(ms) {
115
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
116
+ }
117
+
118
+ /**
119
+ * Wait until the Docker daemon answers, up to DOCKER_WAIT_SECONDS.
120
+ *
121
+ * Finding the `docker` command only proves the CLI is installed. At boot the
122
+ * daemon can still be starting — on the production host systemd started this
123
+ * service a second before Docker. Start-up still worked, by accident: the first
124
+ * `docker` call blocked on docker.socket until the daemon was up. With a daemon
125
+ * slower than a later call's 10 s timeout, the image check would have concluded
126
+ * the image was missing and tried to pull and rebuild it. Asking the daemon
127
+ * directly, with a short per-attempt timeout, makes the wait explicit, bounded
128
+ * and logged, whatever kind of Docker install this is.
129
+ */
130
+ function waitForDockerDaemon() {
131
+ const deadline = Date.now() + DOCKER_WAIT_SECONDS * 1000;
132
+ let lastError = '';
133
+ let announced = false;
134
+ for (;;) {
135
+ try {
136
+ const version = execSync('docker info --format "{{.ServerVersion}}"', { stdio: 'pipe', timeout: 10_000 })
137
+ .toString().trim();
138
+ if (version) {
139
+ log('DOCKER', `Docker daemon ready (server ${version})`);
140
+ return true;
141
+ }
142
+ } catch (err) {
143
+ lastError = ((err.stderr && err.stderr.toString()) || err.message || '').trim().split('\n')[0];
144
+ }
145
+ if (Date.now() >= deadline) break;
146
+ if (!announced) {
147
+ log('DOCKER', `Waiting for the Docker daemon (up to ${DOCKER_WAIT_SECONDS}s)…`);
148
+ announced = true;
149
+ }
150
+ sleepSync(2000);
151
+ }
152
+ log('DOCKER', `⚠ Docker daemon not reachable after ${DOCKER_WAIT_SECONDS}s: ${lastError || 'no answer'}`);
153
+ log('DOCKER', ' Starting without it. Agents are unavailable and the startup sync reaches none; run Sync All Agents once Docker is up.');
154
+ return false;
155
+ }
156
+
157
+ /** True when the Docker daemon is reachable and the Docker-dependent steps can run. */
102
158
  function ensureDocker() {
103
159
  if (hasCmd('docker')) {
104
- log('DOCKER', 'Docker is available');
105
- return;
160
+ log('DOCKER', 'Docker CLI found');
161
+ return waitForDockerDaemon();
106
162
  }
107
163
 
108
164
  if (!isLinux()) {
109
165
  log('DOCKER', '⚠ Docker not found. Rev4a needs Docker to create agent containers.');
110
166
  log('DOCKER', ' Install Docker: https://docs.docker.com/get-docker/');
111
167
  log('DOCKER', ' Then run: rev4a serve');
112
- return;
168
+ return false;
113
169
  }
114
170
 
115
171
  if (!isRoot()) {
116
172
  log('DOCKER', '⚠ Docker not found. Run Rev4a as root to auto-install:');
117
173
  log('DOCKER', ' sudo rev4a serve');
118
- return;
174
+ return false;
119
175
  }
120
176
 
121
177
  const distro = whichDistro();
@@ -134,10 +190,12 @@ function ensureDocker() {
134
190
  );
135
191
 
136
192
  log('DOCKER', 'Docker installed successfully');
193
+ return waitForDockerDaemon();
137
194
  } catch (err) {
138
195
  log('DOCKER', '⚠ Docker auto-install failed. Install manually:');
139
196
  log('DOCKER', ' https://docs.docker.com/engine/install/');
140
197
  log('DOCKER', ' Then run: rev4a serve');
198
+ return false;
141
199
  }
142
200
  }
143
201
 
@@ -414,9 +472,15 @@ function checkVersion() {
414
472
  function preflight() {
415
473
  log('PREFLIGHT', 'Running pre-flight checks…');
416
474
  ensureEnv();
417
- ensureDocker();
418
- ensureNetwork();
419
- ensureAgentImage();
475
+ // Network and image need the daemon. Without it the image check fails and
476
+ // would start a pull and then a local build — up to fifteen minutes, before the
477
+ // dashboard is even up — for an image that is most likely already there.
478
+ if (ensureDocker()) {
479
+ ensureNetwork();
480
+ ensureAgentImage();
481
+ } else {
482
+ log('PREFLIGHT', 'Skipping network and agent image checks: Docker daemon not reachable.');
483
+ }
420
484
  ensureRev4aRules();
421
485
 
422
486
  if (!existsSync(NEXT_DIR)) {
@@ -1,7 +1,7 @@
1
1
  # Rev4a Architecture — Design & Vision
2
2
 
3
3
  > **Status:** Active — `main` branch
4
- > **Last updated:** 2026-06-30
4
+ > **Last updated:** 2026-09-14
5
5
  > **Goal:** Transform Rev4a from a monitoring dashboard into a central orchestrator for a distributed multi-container agency.
6
6
 
7
7
  ---
@@ -64,15 +64,15 @@
64
64
 
65
65
  ---
66
66
 
67
- ## 2. Current Architecture (as of 2026-06-24)
67
+ ## 2. Current Architecture (as of 2026-08-28)
68
68
 
69
69
  ### What's already been implemented
70
70
 
71
71
  **Container separation:** `openclaw-atlas` already runs as a standalone container with `AGENT_ID=atlas`, discovered dynamically by Rev4a. This proves the container-per-agent model works.
72
72
 
73
- **Vault system:** A credentials vault (`/vault` page) stores API keys and service tokens with per-agent permissions. File-backed vault system (removed - sensitive data is env-only).
73
+ **Credentials vault:** The `/credentials` page stores third-party service tokens in `credentials.db` and installs them into containers on explicit sync. Provider API keys live separately in `provider-keys.json`, managed from the Gateway UI.
74
74
 
75
- **Container management UI:** The `/containers` page lists all running Docker containers with resource usage, logs, and quick links. Fully functional.
75
+ **Container management UI:** The `/containers` page lists all running Docker containers with resource usage and quick links, and opens a web terminal into any of them. Fully functional.
76
76
 
77
77
  **Workspace API:** Lazy-loaded file tree explorer with real-time reads (no caching), supports both host and container workspaces via `docker exec`.
78
78
 
@@ -80,8 +80,8 @@
80
80
 
81
81
  | Feature | Status | Notes |
82
82
  |---|---|---|
83
- | Vault (UI + API) | 🟢 Implemented | SQLite + JSON, no encryption yet |
84
- | Container page | 🟢 Functional | Lists all containers, logs, resources |
83
+ | Credentials (UI + API) | 🟢 Implemented | SQLite, no encryption yet |
84
+ | Container page | 🟢 Functional | Lists all containers, resources, web terminal |
85
85
  | Agent creation wizard | 🟢 Implemented | One-click create with model/template selection |
86
86
  | Provider proxy | 🟢 Implemented | Agents route through Rev4a provider gateway |
87
87
  | Shared volumes | 🟢 Implemented | Skills + repos mounted on all agents |
@@ -103,7 +103,7 @@ The central container, running the Next.js dashboard + orchestration API.
103
103
  - Centralized credential management
104
104
  - Docker socket access for container management
105
105
  - SQLite DB for cross-container event monitoring
106
- - **Onboarding wizard** — first-run setup flow at `/onboarding`, tracks progress in `data/onboarding-progress.json`
106
+ - **First-run wizard** — setup flow at `/wizard`; completion is recorded in `<data dir>/data/wizard.json` and read through `GET /api/wizard/status`
107
107
 
108
108
  **Volume mounts (target):**
109
109
  ```
@@ -119,6 +119,61 @@ The central container, running the Next.js dashboard + orchestration API.
119
119
  - Injected into every session via the `bootstrap-extra-files` hook (glob `.rev4a/*.md`)
120
120
  - Immutable by agents — enforced by the `:ro` mount
121
121
  - See `lib/agent-setup.ts` for the centralized volume + config guarantee logic
122
+ - **Waiting for a new container** is `waitForGatewayReady()` in
123
+ `lib/agent-readiness.ts`, used by create, recreate and the agent route. On
124
+ OpenClaw 9.x it waits for `/startupz` to report `started` (503 while starting).
125
+ The 2026.7.1-2 image has no `/startupz` — its gateway serves the web UI with 200
126
+ for unknown paths — so there it falls back to `/health`, which only shows the
127
+ server is listening. Do not reimplement the wait in a route.
128
+ - **The model catalogue** is read only through `lib/model-catalogue.ts`:
129
+ `loadModelsConfig()`, `loadOfferedModels()` (enabled after overrides, provider has
130
+ a key), `isModelOffered()` (enforced by the proxy and the assistant) and
131
+ `catalogueStatus()`. A failed read of `models.config.json` falls back to the last
132
+ good copy and never deletes overrides.
133
+
134
+ **Running commands inside containers** (`lib/docker-exec.ts`):
135
+
136
+ This module is how a route reaches an agent container. The migration to it is
137
+ not finished — routes predating it still shell out directly — so treat these as
138
+ the rules for anything you touch, not as a description of the whole tree:
139
+
140
+ - **Never `execSync`/`execFileSync` in a request path.** They block Node's single
141
+ thread: while one runs, no other request is served and no agent's stream
142
+ advances. A `docker exec` costs ~90 ms warm and the OpenClaw CLI costs
143
+ seconds, so a route that walks the fleet freezes the event loop for the sum of
144
+ all of them. Use `dockerExec` / `dockerExecShell` and their `…NoFail` variants.
145
+ - **`mapWithConcurrency` is fail-fast, like `Promise.all`.** If the mapped
146
+ function rejects for one item, the whole call rejects and results already
147
+ computed for other containers are discarded. Fanning out over a fleet requires
148
+ the `…NoFail` variants, or an explicit per-item try/catch, so one unreachable
149
+ container cannot blank an entire page. Callers: `app/api/skills/route.js`,
150
+ `app/api/agents/models-summary/route.ts`,
151
+ `app/api/agents/channels-summary/route.ts`,
152
+ `lib/openclaw-cron.ts`, `app/api/credentials/detect/route.ts`,
153
+ `lib/credentials/delivery.ts`.
154
+ - **Never interpolate a secret into a command string.** Pass it through the `env`
155
+ option and reference it as `"$KEY"`; command substitution inside an
156
+ interpolated value would otherwise execute on the host. Only the *name* reaches
157
+ argv, as `docker exec -e KEY` — docker forwards the value from its own
158
+ environment, so it appears neither in the host process table nor in any
159
+ rejection built from that argv.
160
+ - **A timeout does not reject.** `docker exec` exits 0 on the SIGTERM Node sends
161
+ when the deadline passes, so a command killed halfway *resolves* with whatever
162
+ it had already printed. A `try/catch` therefore cannot tell a truncated read
163
+ from a complete one. Where that distinction matters, make the script prove it
164
+ finished — `detect.ts` emits a `probe:complete` sentinel before its slow calls,
165
+ `containerRead` prints a terminator after the file — and treat its absence as
166
+ failure. Anything that writes back what it read must refuse to write when the
167
+ proof is missing.
168
+ - **A rejection carries `stderr`, never the command.** Node's own message is
169
+ `Command failed: <argv…>`, which reproduces whatever was interpolated into the
170
+ script. `dockerExec` replaces it with `docker exec <container>: <cause> —
171
+ <stderr>`, because these messages travel: `syncProfileToAgents` puts one into
172
+ `SyncResult.error` and the sync route returns it to the browser.
173
+
174
+ `dockerExecWithInput` uses `spawn` rather than `execFile` because
175
+ `promisify(exec)` silently ignores an `input` option: the process starts, stdin
176
+ is never written, and the command hangs with no error to point at.
122
177
 
123
178
  ### 3.2 Agent Container Template (`openclaw-agent-base`)
124
179
 
@@ -164,7 +219,7 @@ Agent Container Rev4a Gateway Provider API
164
219
  │ │ │
165
220
  │ Authorization: Bearer <gateway-token> │
166
221
  │ POST /api/provider/v1/chat/completions │
167
- │ model: rev4a/deepseek-v4-flash │
222
+ │ model: rev4a/deepseek-flash │
168
223
  │────────────────────────>│ │
169
224
  │ │ POST /v1/chat/completions│
170
225
  │ │ Authorization: Bearer <real-key>
@@ -196,30 +251,34 @@ detailed documentation.
196
251
 
197
252
  ## 4. Credential Management (Vault)
198
253
 
199
- Provider API keys are stored in `data/provider-keys.json`. Third-party service credentials
200
- (GitHub, Trello, Vercel, Supabase) are stored in `data/credentials.db` (SQLite).
201
- At agent spawn time, Rev4a injects only the credentials the agent is permitted to use.
202
-
203
- ```json
204
- {
205
- "providers": {
206
- "openai-codex": "sk-...",
207
- "anthropic": "sk-ant-...",
208
- "groq": "gsk_..."
209
- },
210
- "services": {
211
- "github": { "token": "***", "user": "Flame0510" },
212
- "vercel": { "token": "***", "team": "flame0510" }
213
- },
214
- "agent_permissions": {
215
- "argus": ["providers:all", "services:github", "services:vercel"],
216
- "atlas": ["providers:openai-codex", "services:github"],
217
- "prometheus": ["providers:openai-codex"]
218
- }
219
- }
220
- ```
254
+ Two separate stores:
255
+
256
+ - **Provider API keys** — `<data dir>/data/provider-keys.json`, used by the Rev4a
257
+ provider proxy.
258
+ - **Third-party service credentials** — `<data dir>/data/credentials.db` (SQLite),
259
+ managed from the Credentials page. Five providers: `github-pat`, `trello`,
260
+ `vercel`, `supabase`, `notion` (`lib/credentials/providers.ts`).
261
+
262
+ **Secrets are stored unencrypted.** `credential_secrets.payload` holds raw JSON.
263
+ Anything able to read that file, or to reach `POST /api/credentials/[id]/reveal`
264
+ with a valid session, obtains them in the clear. Encryption (AES-256-GCM, or an
265
+ external vault) is not implemented.
266
+
267
+ **Delivery is explicit, not automatic.** Creating an agent installs nothing: a
268
+ credential reaches a container only when the user syncs it
269
+ (`POST /api/credentials/[id]/sync`), and leaves only on de-sync
270
+ (`DELETE /api/credentials/[id]/sync`). There is no per-agent permission model —
271
+ any stored credential can be synced to any container. Delivery writes each CLI's
272
+ own config inside the container (`lib/credentials/delivery.ts`); OpenClaw's
273
+ `secrets` subsystem covers only OpenClaw's own configuration credentials and
274
+ offers nothing for third-party CLIs.
275
+
276
+ `GET /api/credentials/detect` reports which credentials are actually installed,
277
+ by SHA-256 hashing the token found in each container and matching it against the
278
+ stored profiles.
221
279
 
222
- **Current implementation:** SQLite (`credentials.db`) for services, JSON file (`provider-keys.json`) for provider keys (no encryption yet). Future: encrypted with AES-256-GCM or integrated with HashiCorp Vault / Vaultwarden.
280
+ **Planned:** encryption at rest, and a per-agent permission model so a credential
281
+ can be restricted to a subset of agents.
223
282
 
224
283
  ---
225
284
 
@@ -479,7 +538,7 @@ ALTER TABLE sessions ADD COLUMN ended_at INTEGER;
479
538
  ### Phase 2 — Provider Gateway
480
539
  - [x] Implement proxy API on Rev4a (`/api/provider/v1/`)
481
540
  - [x] Gateway Page (`/gateway`) for provider key management
482
- - [x] Agent model configuration via Gateway Agents tab
541
+ - [x] Agent model configuration in the agent detail panel
483
542
  - [x] Provider sync to agent containers (`PUT /api/gateway/provider`)
484
543
  - [x] Auth via rev4a token in `data/provider-keys.json`
485
544
  - [ ] Rate limiting per agent
@@ -568,7 +627,7 @@ for the full rationale.
568
627
  | Database | SQLite (WAL mode) | Current `events.db`, may need to scale |
569
628
  | Memory | Per-agent SQLite + central index | New |
570
629
  | Config | Generated YAML/JSON | New |
571
- | Vault | SQLite + JSON | Current: credentials.db + provider-keys.json. Future: encrypted |
630
+ | Credential stores | SQLite + JSON | credentials.db for service tokens, provider-keys.json for provider API keys. Neither is encrypted |
572
631
  | Reverse proxy | Traefik | Already in use |
573
632
  | Monitoring | Rev4a daemon (extended) | Evolution of current daemon |
574
633
  | Version control | Git + GitHub | `github.com/Flame0510/rev4a.git` |
@@ -1,6 +1,6 @@
1
1
  # Rev4a Frontend Architecture
2
2
 
3
- > **Last updated:** 2026-07-19
3
+ > **Last updated:** 2026-09-14
4
4
 
5
5
  ## Layering
6
6
 
@@ -24,7 +24,8 @@ All shared UI primitives live in `app/components/ui/` and are exported from `app
24
24
  |-----------|------|---------|
25
25
  | `Button` | `Button.tsx` | Action button, 5 variants (`primary`, `secondary`, `danger`, `ghost`, `success`), 2 sizes (`sm`, `md`), loading spinner. |
26
26
  | `Input` | `Input.tsx` | Text input with label, error state, placeholder. |
27
- | `Select` | `Select.tsx` | Native select with typed options, label, error state. |
27
+ | `Select` | `Select.tsx` | Native select with typed options, label, error state. An option can be `disabled`, to display a current value that may not be chosen again. |
28
+ | `RemoveButton` | `RemoveButton.tsx` | The `×` that removes an item from a list — one `danger` style everywhere. Whether the removal is immediate or pending goes in `title`, not in the colour. Not for dismissing dialogs — see rule 8. |
28
29
  | `Modal` | `Modal.tsx` | Overlay modal, Escape-to-close, maxWidth prop, `type="button"` on close. |
29
30
  | `Toast` | `Toast.tsx` | Lightweight toast notification with auto-dismiss (4s), `success` / `error` variants. |
30
31
  | `LoadingSpinner` | `LoadingSpinner.tsx` | Inline or fullscreen spinner. |
@@ -41,11 +42,14 @@ All shared UI primitives live in `app/components/ui/` and are exported from `app
41
42
  | `Page` / `PageHeader` | `Page.tsx` | Full-page layout shell. |
42
43
  | `Icons` | `Icons.tsx` | SVG icons (`EyeIcon`, `EyeOffIcon`), 16/20px shared. |
43
44
  | `PasswordInput` | `PasswordInput.tsx` | Password input with inline show/hide toggle (`<button type="button">` with `aria-label`). |
44
- | `OnboardingPageClient` + step components | `app/onboarding/PageClient.tsx` | 4-step wizard (`WelcomeStep`, `ProvidersStep`, `AgentsStep`, `DoneStep`) + `WizardFrame` shell with mobile-first CSS. |
45
- | `useOnboarding` | `app/onboarding/useOnboarding.ts` | Shared hook: onboarding state, step transitions, provider save, restart. Receives server-side initial state to avoid loading flash. |
46
- | `OnboardingIcons` | `app/onboarding/icons.tsx` | Shared SVG icons (flyweight pattern): `ArrowRightIcon`, `CheckIcon`, `DockerIcon`, `GatewayIcon`, etc. |
45
+ | `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`. |
46
+ | `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. |
47
+ | `WizardPageClient` + step components | `app/wizard/PageClient.tsx` | Multi-step first-run wizard plus its frame shell, with mobile-first CSS. |
48
+ | `useWizard` | `app/wizard/useWizard.ts` | Shared hook: wizard state, step transitions, provider save, restart. Receives server-side initial state to avoid a loading flash. |
49
+ | Wizard icons | `app/wizard/icons.tsx` | Shared SVG icons (flyweight pattern): `ArrowRightIcon`, `CheckIcon`, `CheckCircleIcon`, `DockerIcon`, `GatewayIcon`, `AgentIcon`, `TemplateIcon`, `LinkIcon`, `ConfigIcon`, `InformationIcon`. |
47
50
  | `tokens` | `tokens.ts` | TypeScript types for `Tone` and related token values. |
48
51
  | `Badge` | `Badge.tsx` | Inline status tag with tone variants (success, danger, warning, neutral). Used for channel chips, pairing labels, error/success messages. |
52
+ | `Skeleton` + shape helpers | `app/components/Skeleton.tsx` | Shimmer placeholders. `Skeleton` is the primitive; the rest mirror one specific layout each: `TreeSkeleton`, `CodeSkeleton`, `CronJobsSkeleton`, `CronRunsSkeleton`, `CredentialCardsSkeleton`, `AgentSyncRowsSkeleton`, `SkeletonLines`, `SkeletonMetric`, `CardRowSkeleton`. A shape helper must match the real markup it stands in for — same row structure, same paddings, same element count where the count is known — so nothing reflows when data replaces it. |
49
53
 
50
54
  ### Rules
51
55
 
@@ -53,10 +57,33 @@ All shared UI primitives live in `app/components/ui/` and are exported from `app
53
57
  2. **No inline styles on interactive elements.** Buttons, inputs, selects use the `app/components/ui/*` component with component props. Only layout/wrapping containers use inline style.
54
58
  3. **No Italian in code, labels, or comments.** UI strings, error messages, aria-labels — all English.
55
59
  4. **Clickable/custom interactive elements must be `<button type="button">`.** Not `<span onClick>`, not `<div onClick>`. Always include `aria-label` for icon-only buttons.
60
+ 4a. **A control gated on another field stays visible and disabled** — never conditionally unmounted. Rendering it only once its dependency is filled makes it appear out of nowhere, and any hint that references it is pointing at something not on screen. Show it disabled, with the reason beside it, so the shape of the form is stable from the first render.
56
61
  5. **Modals inside forms** — close button has `type="button"` to prevent accidental form submission.
57
- 6. **Loading buttons** — set `loading={true}` on `Button`, do not render separate loaders next to the button. The spinner is built-in.
62
+ 6. **Loading buttons** — set `loading={true}` on `Button`, do not render separate loaders next to the button. The spinner is built-in. The label must also change for the duration (`Saving…`, `Deleting…`, `Syncing…`).
63
+ 6a. **One in-flight mutation, keyed.** Hold a single `busy` string identifying the running action (`delete:<id>`, `sync:<id>:<container>`), plus a `useRef` mirror as the authoritative guard — state is read from the render that produced the click, so two fast clicks would otherwise both pass. `loading` compares against the exact key; `disabled` is set for any busy value, so a control that would be refused looks refused. Never key a mutation on one dimension when the same control is rendered per row of another: a key holding only a container name puts every profile's button for that container into a spinner.
64
+ 6b. **Do not hold the lock across a slow refresh.** Release it when the mutation itself completes and refresh derived state afterwards, unawaited. Show the pending state by fading the stale values only — never the buttons, since `opacity` on an ancestor cannot be undone by a child and makes live controls read as disabled.
65
+ 6c. **An optimistic update moves every field the render derives from, together.** A row that decides its state by reading two fields against each other flips to a third, wrong state if the update touches only one of them — and holds it until the slow refresh lands. Update the whole set the derivation reads, or none of it.
58
66
  7. **New UI component** — add it to `app/components/ui/`, export from `index.ts`, document it here. If it's specific to one page, keep it page-local unless another page needs it.
59
67
 
68
+ 8. **`×` means remove, `✕` means dismiss.** They look alike and are not the same
69
+ action. Removing an item from a list uses `RemoveButton`; dismissing a dialog
70
+ is the `Modal` component's own close control. Do not build a third variant of
71
+ either, and do not reuse one for the other.
72
+
73
+ *Outstanding:* four dialogs predate the shared `Modal` and roll their own close
74
+ control — the edit dialog and the `openclaw.json` dialog in
75
+ `app/agents/PageClient.tsx` (both a `ghost` Button with `✕`), the drawer in
76
+ `app/components/SessionDrawer.tsx` (a bare `<button>`), and
77
+ `app/components/ModelPickerModal.tsx` (`model-picker__close`, which compounds
78
+ the problem by using `×`, the *remove* glyph, to dismiss, and omits
79
+ `type="button"`). The fix is to move them onto `Modal`, not to extract a
80
+ `CloseButton`: they also reimplement Escape-to-close and overlay behaviour.
81
+ `ModelPickerModal` has no importers at all, so deleting it is the cheaper fix
82
+ there.
83
+
84
+ Separately, `app/agents/PageClient.tsx` has a raw `×` delete-backup button with
85
+ no accessible name that should be `RemoveButton`.
86
+
60
87
  ## GoF pattern mapping
61
88
 
62
89
  Already used:
@@ -108,6 +135,9 @@ For interactive pages that can change view/file/tab quickly:
108
135
  2. Guard against stale updates after `await` boundaries.
109
136
  3. On navigation/context switch, kill in-flight requests before starting new ones.
110
137
  4. Mobile layout changes must not wait on network completion.
138
+ 5. After a mutation, refresh the cheap endpoint and await it; fire the expensive one without awaiting. A page that re-reads everything makes the user wait on work their change did not need.
139
+ 6. A refresh helper invoked from a mutation must never reject. The mutation has already succeeded by then, so a failed re-read surfacing as the caller's error reports a completed action as failed.
140
+ 7. Check `res.ok` before reading the body. An error response is still valid JSON, so `json.items ?? []` silently turns a 401 into an empty result presented as fact.
111
141
 
112
142
  ## Migration rule
113
143