@flame0510/project-aether 1.6.0 → 1.6.2

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.
@@ -59,7 +59,10 @@ export async function GET(request: Request): Promise<NextResponse> {
59
59
  const now = Date.now();
60
60
  if (cache && now - cache.checkedAt < CACHE_TTL_MS) {
61
61
  latest = cache.latest;
62
- updateAvailable = cache.updateAvailable;
62
+ // Recomputed against the version reading us now: `installed` is not cached, and
63
+ // reusing the cached flag made the banner offer an update that had just been
64
+ // installed, for up to the cache's fifteen minutes (seen right after an update).
65
+ updateAvailable = !!latest && latest !== installed;
63
66
  } else {
64
67
  try {
65
68
  const pkgName = pkg.name || FALLBACK_PKG_NAME;
@@ -4,19 +4,22 @@
4
4
  * "Update available" banner for Rev4a itself.
5
5
  *
6
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.
7
+ * component cannot be told the outcome by the response. It records what it asked for and
8
+ * follows the restart from the browser.
9
9
  *
10
- * Two rules learned the hard way (2026-09-23):
10
+ * Three rules learned from the 1.6.0 update (2026-09-23):
11
11
  * - a **success** is only a version that is *readable* and different. During the update
12
- * npm rewrites `package.json`, and a read that fails used to answer `0.0.0`, which read
13
- * as "the version moved" — the banner then said "Updated to v0.0.0" and reloaded in the
14
- * middle of the install.
15
- * - the banner never reloads **blindly**: if the version has not moved by the time the
16
- * wait is over, it says the update has not finished (it may still be running, or it
17
- * failed) and offers a Reload, instead of pretending.
12
+ * npm rewrites `package.json`, and a read that failed used to answer `0.0.0`, which read
13
+ * as "the version moved" — the banner said "Updated to v0.0.0" and reloaded mid-install.
14
+ * - while the restart is in flight the banner shows an **updating** state, never the
15
+ * "Update now" button again: nobody should think the update failed and start it twice.
16
+ * A page loaded in that window resumes the wait by itself and reloads when the new
17
+ * version answers.
18
+ * - the banner never reloads **blindly**: if the version has not moved by the deadline it
19
+ * says the update has not finished (it may still be running, or it failed), points at
20
+ * `update.log` and offers Retry and Reload.
18
21
  */
19
- import { useEffect, useState } from 'react';
22
+ import { useCallback, useEffect, useRef, useState } from 'react';
20
23
  import { Button } from '../components/ui';
21
24
 
22
25
  interface VersionInfo {
@@ -27,20 +30,23 @@ interface VersionInfo {
27
30
  }
28
31
 
29
32
  const DISMISS_KEY = 'rev4a-update-dismissed';
30
- /** What we asked for and from which version, so the next mount can judge the outcome. */
33
+ /** What we asked for and from which version, so a reload can resume the wait. */
31
34
  const PENDING_KEY = 'rev4a-update-pending';
32
35
 
33
36
  /** How long to wait for the restart before saying the update has not finished. */
34
- const WAIT_MS = 120_000;
37
+ const WAIT_MS = 150_000;
38
+ /** How long a recorded request stays believable across reloads. */
39
+ const PENDING_TTL_MS = 15 * 60 * 1000;
35
40
 
36
- interface Pending { target: string; from: string }
41
+ interface Pending { target: string; from: string; at: number }
37
42
 
38
43
  function readPending(): Pending | null {
39
44
  try {
40
45
  const raw = sessionStorage.getItem(PENDING_KEY);
41
46
  if (!raw) return null;
42
47
  const parsed = JSON.parse(raw) as Pending;
43
- return parsed && typeof parsed.target === 'string' && typeof parsed.from === 'string' ? parsed : null;
48
+ if (!parsed || typeof parsed.target !== 'string' || typeof parsed.from !== 'string') return null;
49
+ return parsed;
44
50
  } catch {
45
51
  return null;
46
52
  }
@@ -57,6 +63,33 @@ export default function VersionBanner() {
57
63
  const [updating, setUpdating] = useState(false);
58
64
  const [updateError, setUpdateError] = useState<string | null>(null);
59
65
  const [updateOk, setUpdateOk] = useState<string | null>(null);
66
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
67
+
68
+ /**
69
+ * Follow the restart until the server answers with a different, readable version, then
70
+ * reload. `from` is the version we started from, so a resumed wait knows what to expect.
71
+ */
72
+ const followRestart = useCallback((from: string) => {
73
+ const deadline = Date.now() + WAIT_MS;
74
+ const poll = async () => {
75
+ try {
76
+ const res = await fetch('/api/update-check', { cache: 'no-store' });
77
+ const d = (await res.json()) as VersionInfo;
78
+ if (usableVersion(d.installed) && d.installed !== from) {
79
+ sessionStorage.removeItem(PENDING_KEY);
80
+ window.location.reload();
81
+ return;
82
+ }
83
+ } catch { /* the server is down for the restart: expected */ }
84
+ if (Date.now() > deadline) {
85
+ setUpdating(false);
86
+ setUpdateError('The update has not finished after a couple of minutes. It may still be running — see update.log, then reload.');
87
+ return;
88
+ }
89
+ timerRef.current = setTimeout(poll, 3000);
90
+ };
91
+ timerRef.current = setTimeout(poll, 3000);
92
+ }, []);
60
93
 
61
94
  useEffect(() => {
62
95
  let active = true;
@@ -72,13 +105,22 @@ export default function VersionBanner() {
72
105
  setTimeout(() => { if (active) window.location.reload(); }, 2500);
73
106
  return;
74
107
  }
108
+ if (pending && Date.now() - (pending.at ?? 0) < PENDING_TTL_MS) {
109
+ // The restart is probably in flight (a page loaded inside that window, or a
110
+ // reload before the new version answered): resume the wait, show no action.
111
+ setInfo(d);
112
+ setDismissed(false);
113
+ setUpdating(true);
114
+ followRestart(pending.from);
115
+ return;
116
+ }
75
117
  if (pending) {
76
- // Keep the record: a reload asks again. Say what we know, claim nothing.
118
+ sessionStorage.removeItem(PENDING_KEY);
77
119
  setInfo(d);
78
120
  setDismissed(false);
79
121
  setUpdateError(
80
122
  `The update to v${pending.target} has not finished: ${usableVersion(d.installed) ? `Rev4a is still on v${d.installed}` : 'Rev4a is not answering with a version'}.`
81
- + ` It may still be running, or it failed — see ${d.log ?? 'update.log'} in the Rev4a data directory, then reload.`,
123
+ + ` See ${d.log ?? 'update.log'} in the Rev4a data directory, then reload.`,
82
124
  );
83
125
  return;
84
126
  }
@@ -89,7 +131,10 @@ export default function VersionBanner() {
89
131
  })
90
132
  .catch(() => {});
91
133
  return () => { active = false; };
92
- }, []);
134
+ }, [followRestart]);
135
+
136
+ // Clear a timer the component no longer owns (unmount or a full reload).
137
+ useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
93
138
 
94
139
  function handleDismiss() {
95
140
  if (info?.latest) localStorage.setItem(DISMISS_KEY, info.latest);
@@ -113,37 +158,16 @@ export default function VersionBanner() {
113
158
  setUpdating(false);
114
159
  return;
115
160
  }
116
- sessionStorage.setItem(PENDING_KEY, JSON.stringify({ target: info.latest ?? '', from: info.installed }));
161
+ sessionStorage.setItem(PENDING_KEY, JSON.stringify({ target: info.latest ?? '', from: info.installed, at: Date.now() }));
117
162
  localStorage.removeItem(DISMISS_KEY);
118
- setUpdateOk('Update started. Rev4a is restarting…');
119
- // The server goes down and comes back on the new version: reload once it answers
120
- // with a different, readable version. If it never does, say so rather than reloading
121
- // into a state the operator cannot interpret.
122
- const deadline = Date.now() + WAIT_MS;
123
- const poll = async () => {
124
- try {
125
- const r = await fetch('/api/update-check', { cache: 'no-store' });
126
- const d = (await r.json()) as VersionInfo;
127
- if (usableVersion(d.installed) && d.installed !== info.installed) { window.location.reload(); return; }
128
- } catch { /* the server is down for the update: expected */ }
129
- if (Date.now() > deadline) {
130
- setUpdating(false);
131
- setUpdateOk(null);
132
- setUpdateError('The update has not finished after two minutes. It may still be running — reload in a moment, or check update.log.');
133
- return;
134
- }
135
- setTimeout(poll, 3000);
136
- };
137
- setTimeout(poll, 3000);
163
+ followRestart(info.installed);
138
164
  } catch (err) {
139
165
  setUpdateError(err instanceof Error ? err.message : 'Network error');
140
166
  setUpdating(false);
141
167
  }
142
168
  }
143
169
 
144
- // The result states stand on their own (there may be no `info` when the update just
145
- // succeeded); the "available" state needs the version info and no dismissal.
146
- const show = Boolean(updateOk) || Boolean(updateError) || Boolean(info?.updateAvailable && !dismissed);
170
+ const show = Boolean(updateOk) || Boolean(updateError) || Boolean(updating) || Boolean(info?.updateAvailable && !dismissed);
147
171
  if (!show) return null;
148
172
 
149
173
  return (
@@ -154,6 +178,8 @@ export default function VersionBanner() {
154
178
  <span className="update-banner__ok">{updateOk}</span>
155
179
  ) : updateError ? (
156
180
  <span className="update-banner__err">{updateError}</span>
181
+ ) : updating ? (
182
+ <span className="update-banner__ok">Update started. Rev4a is restarting — this page reloads on its own when the new version answers.</span>
157
183
  ) : (
158
184
  <>
159
185
  <span className="update-banner__label">Update available</span>
@@ -166,19 +192,21 @@ export default function VersionBanner() {
166
192
 
167
193
  {!updateOk && (
168
194
  <div className="update-banner__actions">
169
- {updateError ? (
170
- <Button variant="primary" size="sm" onClick={handleUpdate} disabled={updating}>
171
- {updating ? 'Updating…' : 'Retry'}
172
- </Button>
195
+ {updating ? (
196
+ // No action while the restart runs: the only honest one is to wait.
197
+ <Button variant="ghost" size="sm" onClick={() => window.location.reload()}>Reload</Button>
198
+ ) : updateError ? (
199
+ <>
200
+ <Button variant="primary" size="sm" onClick={handleUpdate}>Retry</Button>
201
+ <Button variant="secondary" size="sm" onClick={() => window.location.reload()}>Reload</Button>
202
+ <Button variant="ghost" size="sm" onClick={handleDismiss}>Dismiss</Button>
203
+ </>
173
204
  ) : (
174
- <Button variant="primary" size="sm" onClick={handleUpdate} disabled={updating}>
175
- {updating ? 'Updating…' : 'Update now'}
176
- </Button>
177
- )}
178
- {updateError && (
179
- <Button variant="secondary" size="sm" onClick={() => window.location.reload()}>Reload</Button>
205
+ <>
206
+ <Button variant="primary" size="sm" onClick={handleUpdate}>Update now</Button>
207
+ <Button variant="ghost" size="sm" onClick={handleDismiss}>Dismiss</Button>
208
+ </>
180
209
  )}
181
- <Button variant="ghost" size="sm" onClick={handleDismiss}>Dismiss</Button>
182
210
  </div>
183
211
  )}
184
212
  </div>
@@ -2596,6 +2596,10 @@ from local state only.
2596
2596
 
2597
2597
  **Response:** `{ "installed": "1.4.2", "latest": "1.5.0", "updateAvailable": true, "package": "@flame0510/project-aether", "log": "update.log" }`
2598
2598
 
2599
+ `updateAvailable` is recomputed against the version reading the request, even when the
2600
+ registry answer comes from the fifteen-minute cache: reusing the cached flag made the
2601
+ banner offer an update that had just been installed, right after an update.
2602
+
2599
2603
  `installed` is `null` when `package.json` cannot be read — during an update npm rewrites
2600
2604
  it, and a transient failure must not look like a version. With `installed` unknown there
2601
2605
  is nothing to compare, so `latest` is `null` and `updateAvailable` false. `installed` is
@@ -206,6 +206,29 @@ block, and every number there carries its source:
206
206
  No generated field ever replaces a curated one silently: prices are separate files, and the
207
207
  modal shows the generated part's date (`asOf`).
208
208
 
209
+ ### Curating an `info` block
210
+
211
+ Nothing here is estimated, and every number carries its source and date. The workflow has
212
+ three steps, one command each:
213
+
214
+ ```bash
215
+ npm run info:suggest -- <model id> … # candidate facts from the model card (read-only)
216
+ # the maintainer, or a worker agent asked to follow the same contract, proposes the block
217
+ npm run info:verify -- proposals.json # the gate: refuses what the card does not support
218
+ ```
219
+
220
+ - `scripts/model-info-suggest.mjs` prints the card's size sentences, its benchmark tables
221
+ (headers included) and its doc links, so the reading is a lookup rather than a hunt.
222
+ - `scripts/model-info-verify.mjs` checks a proposal against the card: `evidence` present,
223
+ every number in `params` in the card text, every benchmark value in a row named after it
224
+ **under the column that names this model** (never a sibling variant), and `docUrl` when
225
+ asked with `--check-links`. It exits non-zero on a rejected proposal.
226
+ - The proposal contract is what makes this safe: one JSON object per model with an
227
+ `evidence` entry per field, the verbatim line, the column used and the card's own date.
228
+ A worker that skips the evidence — or takes a value from a variant's column — is caught
229
+ here, not in the catalogue. Benchmarks that a card states only for a sibling
230
+ (`-Max`, `-Base`, `-HighSpeed`) are dropped.
231
+
209
232
  ---
210
233
 
211
234
  ## Per-agent model assignment