@flame0510/project-aether 1.5.0 → 1.5.1

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.
@@ -68,45 +68,56 @@ export async function GET(request: Request): Promise<NextResponse> {
68
68
  }
69
69
  }
70
70
 
71
- return NextResponse.json({ installed, latest, updateAvailable, package: getPackageInfo().name });
71
+ return NextResponse.json({ installed, latest, updateAvailable, package: getPackageInfo().name, log: 'update.log' });
72
72
  }
73
73
 
74
74
  /**
75
75
  * POST /api/update-check
76
76
  *
77
- * Spawns rev4a update as a detached background process.
78
- * rev4a update installs the latest version and writes .restart-flag,
79
- * which rev4a serve detects and exits with code 42, then auto-restarts
80
- * with the newly installed binary. No systemd required.
77
+ * Spawns `rev4a update` as a detached background process: it installs the latest
78
+ * version, writes `.restart-flag` and lets `rev4a serve` restart the child (`rev4a
79
+ * _run`) with the new binary. Nothing here has to restart anything — the update kills
80
+ * this very process on its way in (`fuser -k <port>/tcp`), so any callback meant to run
81
+ * after it is dead code; the CLI's own flag is the only mechanism that has to work.
82
+ *
83
+ * The child's output goes to `update.log` in the data directory: since the update
84
+ * restarts this process mid-run, that file is the only record of what happened.
81
85
  */
82
86
  export async function POST(request: Request): Promise<NextResponse> {
83
87
  const denied = await requireAuthJWT(request);
84
88
  if (denied) return denied;
85
89
 
86
- // Spawn rev4a update in background, then write restart flag directly
87
- // from this process so rev4a serve detects it and restarts.
88
90
  const { spawn } = require('child_process');
89
91
  const { join } = require('path');
90
- const { writeFileSync } = require('fs');
92
+ const { openSync, closeSync, appendFileSync } = require('fs');
91
93
 
92
94
  const dataDir = process.env.REV4A_DATA_DIR ||
93
95
  join(process.env.HOME || '/root', '.config', 'rev4a', 'data');
96
+ const logPath = join(dataDir, 'update.log');
97
+
98
+ let log: number | null = null;
99
+ try {
100
+ log = openSync(logPath, 'a');
101
+ appendFileSync(logPath, `\n[${new Date().toISOString()}] rev4a update, started from the dashboard\n`);
102
+ } catch {
103
+ // Unwritable log: run the update anyway, we just lose the record.
104
+ }
94
105
 
95
106
  const child = spawn('rev4a', ['update'], {
96
- stdio: 'ignore',
107
+ stdio: log === null ? 'ignore' : ['ignore', log, log],
97
108
  detached: true,
109
+ cwd: dataDir,
98
110
  });
111
+ if (log !== null) closeSync(log);
99
112
 
100
- // When rev4a update finishes, write the restart flag so rev4a serve
101
- // picks it up and exits with code 42, triggering the auto-restart loop.
102
- child.on('exit', () => {
103
- try { writeFileSync(join(dataDir, '.restart-flag'), ''); } catch {}
113
+ child.on('error', (e: Error) => {
114
+ try { appendFileSync(logPath, `[dashboard] could not start the update: ${e.message}\n`); } catch {}
104
115
  });
105
-
106
116
  child.unref();
107
117
 
108
118
  return NextResponse.json({
109
119
  success: true,
110
- message: 'Update started. The dashboard will restart once complete.',
120
+ message: 'Update started. Rev4a restarts itself once the new version is installed.',
121
+ log: 'update.log',
111
122
  });
112
123
  }
@@ -1,14 +1,42 @@
1
1
  'use client';
2
2
 
3
+ /**
4
+ * "Update available" banner for Rev4a itself.
5
+ *
6
+ * The update is spawned by `POST /api/update-check` and restarts the server, so this
7
+ * component cannot be told the outcome by the response. It records what it asked for,
8
+ * waits for the server to answer again, and on the next mount compares: if the
9
+ * installed version moved it was updated, and if the same update is still offered it
10
+ * did not complete (the log file says why).
11
+ */
3
12
  import { useEffect, useState } from 'react';
4
13
 
5
14
  interface VersionInfo {
6
15
  installed: string;
7
16
  latest: string | null;
8
17
  updateAvailable: boolean;
18
+ log?: string;
9
19
  }
10
20
 
11
21
  const DISMISS_KEY = 'rev4a-update-dismissed';
22
+ /** What we asked for and from which version, so the next mount can judge the outcome. */
23
+ const PENDING_KEY = 'rev4a-update-pending';
24
+
25
+ /** How long to poll for the restart before reloading anyway. */
26
+ const WAIT_MS = 120_000;
27
+
28
+ interface Pending { target: string; from: string }
29
+
30
+ function readPending(): Pending | null {
31
+ try {
32
+ const raw = sessionStorage.getItem(PENDING_KEY);
33
+ if (!raw) return null;
34
+ const parsed = JSON.parse(raw) as Pending;
35
+ return parsed && typeof parsed.target === 'string' && typeof parsed.from === 'string' ? parsed : null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
12
40
 
13
41
  export default function VersionBanner() {
14
42
  const [info, setInfo] = useState<VersionInfo | null>(null);
@@ -18,17 +46,37 @@ export default function VersionBanner() {
18
46
  const [updateOk, setUpdateOk] = useState<string | null>(null);
19
47
 
20
48
  useEffect(() => {
49
+ let active = true;
21
50
  fetch('/api/update-check?check=1')
22
51
  .then((r) => r.json())
23
52
  .then((d: VersionInfo) => {
53
+ if (!active) return;
54
+ const pending = readPending();
55
+ // Back from an update: did the installed version actually move?
56
+ if (pending && d.installed !== pending.from) {
57
+ sessionStorage.removeItem(PENDING_KEY);
58
+ setUpdateOk(`Updated to v${d.installed}`);
59
+ setTimeout(() => { if (active) window.location.reload(); }, 2000);
60
+ return;
61
+ }
62
+ if (pending) {
63
+ sessionStorage.removeItem(PENDING_KEY);
64
+ setInfo(d);
65
+ setDismissed(false);
66
+ setUpdateError(
67
+ `The update to v${pending.target} did not complete: Rev4a is still on v${d.installed}.`
68
+ + ` See ${d.log ?? 'update.log'} in the Rev4a data directory.`,
69
+ );
70
+ return;
71
+ }
24
72
  if (d.updateAvailable) {
25
73
  setInfo(d);
26
74
  const saved = localStorage.getItem(DISMISS_KEY);
27
- if (saved === d.latest) setDismissed(true);
28
- else setDismissed(false);
75
+ setDismissed(saved === d.latest);
29
76
  }
30
77
  })
31
78
  .catch(() => {});
79
+ return () => { active = false; };
32
80
  }, []);
33
81
 
34
82
  function handleDismiss() {
@@ -39,6 +87,7 @@ export default function VersionBanner() {
39
87
  }
40
88
 
41
89
  async function handleUpdate() {
90
+ if (!info) return;
42
91
  setUpdating(true);
43
92
  setUpdateError(null);
44
93
  setUpdateOk(null);
@@ -49,21 +98,37 @@ export default function VersionBanner() {
49
98
  headers: { 'Content-Type': 'application/json' },
50
99
  });
51
100
  const data = await res.json();
52
- if (data.success) {
53
- setUpdateOk('Update started. Restarting\u2026');
54
- localStorage.removeItem(DISMISS_KEY);
55
- setTimeout(function() { window.location.reload(); }, 5000);
56
- } else {
101
+ if (!data.success) {
57
102
  setUpdateError(data.error || 'Update failed');
58
103
  setUpdating(false);
104
+ return;
59
105
  }
106
+ sessionStorage.setItem(PENDING_KEY, JSON.stringify({ target: info.latest ?? '', from: info.installed }));
107
+ localStorage.removeItem(DISMISS_KEY);
108
+ setUpdateOk('Update started. Rev4a is restarting…');
109
+ // The server goes down and comes back on the new version: wait for its answer,
110
+ // then reload. A timeout reloads too — the next mount reports the outcome.
111
+ const deadline = Date.now() + WAIT_MS;
112
+ const poll = async () => {
113
+ if (Date.now() > deadline) { window.location.reload(); return; }
114
+ try {
115
+ const r = await fetch('/api/update-check', { cache: 'no-store' });
116
+ const d = (await r.json()) as VersionInfo;
117
+ if (d.installed !== info.installed) { window.location.reload(); return; }
118
+ } catch { /* the server is down for the update: expected */ }
119
+ setTimeout(poll, 3000);
120
+ };
121
+ setTimeout(poll, 3000);
60
122
  } catch (err) {
61
123
  setUpdateError(err instanceof Error ? err.message : 'Network error');
62
124
  setUpdating(false);
63
125
  }
64
126
  }
65
127
 
66
- if (!info || dismissed) return null;
128
+ // The result states stand on their own (there may be no `info` when the update just
129
+ // succeeded); the "available" state needs the version info and no dismissal.
130
+ const show = Boolean(updateOk) || Boolean(updateError) || Boolean(info?.updateAvailable && !dismissed);
131
+ if (!show) return null;
67
132
 
68
133
  return (
69
134
  <div className="update-banner">
@@ -73,25 +138,25 @@ export default function VersionBanner() {
73
138
  <span className="update-banner__ok">{updateOk}</span>
74
139
  ) : updateError ? (
75
140
  <span className="update-banner__err">{updateError}</span>
76
- ) : (
141
+ ) : info ? (
77
142
  <>
78
143
  <span className="update-banner__label">Update available</span>
79
144
  <span className="update-banner__sep">&mdash;</span>
80
145
  <span className="update-banner__version">v{info.latest}</span>
81
146
  <span className="update-banner__sep">(installed: v{info.installed})</span>
82
147
  </>
83
- )}
148
+ ) : null}
84
149
  </span>
85
150
 
86
151
  {!updateOk && (
87
152
  <div className="update-banner__actions">
88
153
  {updateError ? (
89
154
  <button className="ui-btn ui-btn--primary ui-btn--sm" onClick={handleUpdate} disabled={updating}>
90
- {updating ? 'Updating\u2026' : 'Retry'}
155
+ {updating ? 'Updating…' : 'Retry'}
91
156
  </button>
92
157
  ) : (
93
158
  <button className="ui-btn ui-btn--primary ui-btn--sm" onClick={handleUpdate} disabled={updating}>
94
- {updating ? 'Updating\u2026' : 'Update now'}
159
+ {updating ? 'Updating…' : 'Update now'}
95
160
  </button>
96
161
  )}
97
162
  <button className="ui-btn ui-btn--ghost ui-btn--sm" onClick={handleDismiss}>
@@ -2559,14 +2559,28 @@ Installed version against the latest published one.
2559
2559
  **Query params:** `check=1` queries the registry; without it the answer comes
2560
2560
  from local state only.
2561
2561
 
2562
- **Response:** `{ "installed": "1.1.15", "latest": "1.1.16", "updateAvailable": true, "package": "@flame0510/project-aether" }`
2562
+ **Response:** `{ "installed": "1.4.2", "latest": "1.5.0", "updateAvailable": true, "package": "@flame0510/project-aether", "log": "update.log" }`
2563
+
2564
+ `log` is the file name, in the Rev4a data directory, where the update's output is
2565
+ written.
2563
2566
 
2564
2567
  ### `POST /api/update-check`
2565
- Run `rev4a update` as a detached background process. The dashboard restarts
2566
- itself as part of the update, so the connection drops.
2568
+ Run `rev4a update` as a detached background process. Nothing here restarts anything:
2569
+ the update kills this process on its way in (`fuser -k <port>/tcp`) and `rev4a serve`
2570
+ brings the child back on the new binary via its `.restart-flag`, so the connection
2571
+ drops and the page has to wait for the server to answer again.
2572
+
2573
+ The child's output goes to `update.log` in the data directory — the update restarts
2574
+ the process that spawned it, so that file is the only record of what happened.
2567
2575
 
2568
2576
  **Auth:** browser cookie or bearer token
2569
2577
 
2578
+ **Response:** `{ "success": true, "message": "...", "log": "update.log" }`
2579
+
2580
+ The banner records what it asked for, polls `/api/version` until the installed version
2581
+ moves (or two minutes pass), reloads, and on the next mount either confirms
2582
+ "Updated to vX" or reports that the update did not complete and points at the log.
2583
+
2570
2584
  ### `GET /api/alerts/smoke`
2571
2585
  Send a test alert, to verify the Telegram alerting path end to end.
2572
2586
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flame0510/project-aether",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "Rev4a — Revolution for Agents. OpenClaw agent fleet orchestrator.",
5
5
  "keywords": [
6
6
  "openclaw",