diff options
| author | Agando <roduccomm@gmail.com> | 2026-10-23 19:01:46 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-10-23 19:01:46 +0200 |
| commit | 1ff8df65ffefbd2eaa9100374fbcfb7828fdb717 (patch) | |
| tree | e42d2c6404211547ff4444596f79e24060580b8e | |
| parent | b0404b1c9154c3438d0e124d69dc25d9140cfac4 (diff) | |
feat(newos4): on-demand worker labels and a server-canonical predictor store (v4.6.70)
Two decisions recorded by the operator on 2026-10-23, implemented.
6 — spawn-on-demand is the design, so stop reporting it as a defect:
- OrchestratorModule counts delegations inside delegateTask()
- status prints "Mode: on-demand — N/M worker(s) available, X delegation(s) this
session" and drops the "spawning is not dispatching" warning
- per-agent lines read "available, no delegations yet (spawned ...)" / "N
delegation(s), last ..."
10 — per-user, server file canonical, browser VFS a TTL'd read-through cache:
- store = the user's own WebDAV space (<space>/_data/predictor.json), so identity
comes from credentials the app already has; no new endpoint, no unauthenticated
write surface
- 5 min TTL: cache wins while fresh, server wins when it differs; a banner naming
both counts prints only on divergence
- write-through on save (debounced 5 s) with a MKCOL retry when the collection does
not exist yet; store mode is printed by predictor-status
- the cache write is guarded and verified — a missing /system/metrics made writeFile
fail silently, which is how the first version lost a pull
Verified live on v4.6.70 with a stubbed transport: pull applies 5 pts/5 paired and
writes a stamped cache, the divergence banner fires once and not on the second
(equal) pull, write-through PUT is issued, status reads "Store: server". A real
WebDAV round-trip still needs the user's WebDAV config in-app; the agando space has
no _data collection yet, so GET returns 404 -> "server (empty)".
| -rw-r--r-- | help.html | 4 | ||||
| -rw-r--r-- | main.html | 6 | ||||
| -rw-r--r-- | modules/core/core-bundle.js | 125 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 2 | ||||
| -rw-r--r-- | modules/core/module-orchestrator.js | 18 | ||||
| -rw-r--r-- | modules/core/module-predictor.js | 107 |
6 files changed, 245 insertions, 17 deletions
@@ -1689,13 +1689,15 @@ listmods</pre> <h2>📋 Changelog</h2> <div class="version-item"> - <div class="v-header"><span class="v-num">v4.6.57–66 — October 2026</span><span class="v-date">2026-10-23</span></div> + <div class="v-header"><span class="v-num">v4.6.57–68 — October 2026</span><span class="v-date">2026-10-23</span></div> <ul> <li><strong>No shell — and it now says so</strong> — <code>;</code>, <code>&&</code> and <code>||</code> are refused with an explicit message instead of being folded into the command's argv (where <code>wc -l f && grep -c x f</code> returned a plausible wrong number). A <code>|</code> arriving from the AI <code>run_command</code> tool is refused too, because that path bypasses the pipe splitter and degraded the pattern to empty. Documented at the top of the command reference.</li> <li><strong>Multiple paths in <code>wc</code></strong> — <code>wc -c a b</code> printed only <code>a</code> and exited clean. It now prints one line per file plus a <code>total</code>, and reports each missing file separately.</li> <li><strong><code>ls</code> prints text</strong> — it rendered only clickable chips (DOM-only), so any capture — the AI <code>run_command</code> tool, the diagnostics sweep — saw an empty result and reported "directory empty".</li> <li><strong><code>view_module</code> shows what is deployed</strong> — it returned the <code>_mods/<Class>.js</code> edit cache first, unversioned, so after a rebuild it showed pre-fix source while the bundle was new. It now fetches the deployed file and prefixes the version; the cache is a labelled fallback.</li> <li><strong><code>load_skills</code> searches tokens, not one phrase</strong> — the query used to be tested whole against name+description+tags, so <code>"telemetry dashboard"</code> returned 0 while <code>"telemetry"</code> returned 1, and a sentence could never match. Skill <em>bodies</em> were never searched. It now tokenises (stopwords dropped), scores name > description/tags > body, weights by how many tokens matched, keeps a phrase bonus so word order cannot zero a real hit, and reports <code>matchedTokens: 2/3</code> with each result.</li> + <li><strong>On-demand workers, named as such</strong> — <code>orchestrator-status</code> no longer reports a dispatch ratio as a defect: it prints <code>Mode: on-demand — N/M worker(s) available, X delegation(s) this session</code>, counted inside <code>delegateTask()</code>. Spawn-on-demand is the design; a worker with nothing to do is healthy.</li> + <li><strong>Predictor store: server canonical</strong> — the file under the user's WebDAV space is authoritative; the browser copy is a 5-minute read-through cache written through on save. A banner appears only when the two disagree, naming both counts. No new endpoint and no unauthenticated write path was added.</li> <li><strong><code>grep</code>, <code>head</code> and <code>tail</code> take several paths</strong> — they read only the first path argument and exited clean on the rest, the same silent drop <code>wc</code> had. Multiple files now print <code>file:line: text</code> (grep) or <code>==> file <==</code> (head/tail), and <code>grep -c</code> prints one count per file. <code>tail -f</code> stays single-file and says so.</li> <li><strong>An invalid <code>grep</code> pattern is an answer, not a crash</strong> — it used to throw out of the command handler into the error ledger, which is the ledger that has to stay trustworthy. It now prints <code>grep: invalid pattern: …</code> and returns.</li> <li><strong>Agent status says what it means</strong> — four agents showed <code>sleeping</code> with no way to tell "waiting for work" from "never handed a task". They now read <code>parked — NEVER SCHEDULED (0 tasks since …)</code> or <code>waiting for work (3 tasks, last …)</code>, plus a dispatch line and a warning when spawning happened but nothing was ever dispatched.</li> @@ -1349,13 +1349,13 @@ </script> <!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix --> -<script src="modules/core/core-bundle.js?v=v4.6.67"></script> +<script src="modules/core/core-bundle.js?v=v4.6.70"></script> <!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules --> -<script src="modules/core/module-loader.js?v=v4.6.67"></script> +<script src="modules/core/module-loader.js?v=v4.6.70"></script> <!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work --> -<script src="modules/ext/critical-ext-bundle.js?v=v4.6.67"></script> +<script src="modules/ext/critical-ext-bundle.js?v=v4.6.70"></script> <script> // ═══ ONE TRUE VERSION SOURCE ═══ diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js index e4cd832..303c151 100644 --- a/modules/core/core-bundle.js +++ b/modules/core/core-bundle.js @@ -15533,6 +15533,9 @@ class OrchestratorModule { this._a2aPollTimer = null; this._heartbeatInterval = null; this._a2aMessages = {}; + // v4.6.68: on-demand is the design (decision 2026-10-23). Delegations — not + // "agents ever scheduled" — is the number that means something. + this._delegations = 0; this._restartBackoffs = {}; this._eventBus = {}; this._idleSweepInterval = null; @@ -16585,6 +16588,7 @@ class OrchestratorModule { async delegateTask(task, opts = {}) { if (!task) return this.terminal.print('Usage: orchestrate <task description>', 'error'); + this._delegations = (this._delegations || 0) + 1; // Parallel fan-out: decompose and run sub-tasks concurrently if (opts.parallel) { @@ -16931,7 +16935,7 @@ class OrchestratorModule { // v4.6.63: say what the state MEANS. A parked agent with 0 assignments and a parked // agent between tasks looked identical from outside (packet §6). const agentsArr = Object.values(this._agents); - const everScheduled = agentsArr.filter(a => (a.tasksAssigned || 0) > 0).length; + const available = agentsArr.filter(a => a.status === 'idle' || a.status === 'sleeping').length; const describe = (a) => { const n = a.tasksAssigned || 0; const since = a.createdAt ? String(a.createdAt).slice(0, 16).replace('T', ' ') : '?'; @@ -16944,19 +16948,21 @@ class OrchestratorModule { } if (n === 0) { return (a.status === 'sleeping' ? 'parked' : a.status) + - ` — NEVER SCHEDULED (0 tasks since ${since})`; + ` — available, no delegations yet (spawned ${since})`; } return (a.status === 'sleeping' ? 'parked' : a.status) + - ` — waiting for work (${n} task(s), last ${last || '?'})`; + ` — ${n} delegation(s), last ${last || '?'}`; }; agentsArr.forEach(a => { const modelInfo = a.model ? ` [model: ${a.model}]` : ''; const backoff = a.nextRestartAt && a.nextRestartAt > Date.now() ? ` [restart in ${Math.round((a.nextRestartAt - Date.now()) / 1000)}s]` : ''; this.terminal.print(` ${a.id} [${a.role}] — ${describe(a)}${modelInfo}${backoff}`, 'info'); }); - this.terminal.print(` Dispatch: ${everScheduled}/${total} agent(s) have ever been given a task`, everScheduled === 0 && total > 0 ? 'warning' : 'info'); - if (everScheduled === 0 && total > 0) { - this.terminal.print(' ⚠️ No agent has ever been scheduled — spawning is not dispatching. Use orchestrate <task> or orchestrator-spawn <role> <task>.', 'warning'); + // v4.6.68: on-demand by design — a worker with nothing to do is healthy, not broken. + // The number to watch is how many delegations the AI actually made. + this.terminal.print(` Mode: on-demand — ${available}/${total} worker(s) available, ${this._delegations || 0} delegation(s) this session`, 'info'); + if (total > 0 && (this._delegations || 0) === 0) { + this.terminal.print(' Workers wait for delegate_task — spawn-on-demand, no background dispatch queue by design.', 'text-muted'); } } @@ -18070,6 +18076,9 @@ class PredictorModule { init(terminal) { this.terminal = terminal; this._loadFromVFS(); + this._storeMode = 'cache'; + // read-through after boot: a stale cache revalidates against the canonical file + setTimeout(() => { this._pullFromServer().catch(function () {}); }, 1500); this._loaded = true; this.terminal.print('📊 Predictor loaded — analyzing task patterns', 'info'); } @@ -18318,6 +18327,7 @@ class PredictorModule { this.terminal.print(` Awaiting outcome: ${this._pending.length}`, 'info'); this.terminal.print(` Unpaired runs: ${unpaired}`, 'info'); this.terminal.print(` Unscored pairs: ${unscored} (aborted run or floored prediction)`, 'info'); + this.terminal.print(` Store: ${this._storeMode || 'cache'} — server file canonical, browser copy is a ${Math.round(this._STORE_TTL_MS / 60000)} min read-through cache`, 'info'); this.terminal.print(` Confidence threshold: ${this._CONFIDENCE_THRESHOLD}`, 'info'); this.terminal.print(` Min data points: ${this._MIN_DATA_POINTS}`, 'info'); this.terminal.print(` Pair TTL: ${Math.round(this._PAIR_TTL_MS / 60000)}m`, 'info'); @@ -18355,6 +18365,46 @@ class PredictorModule { } // ─── Persistence ─── + // v4.6.68 (decision 2026-10-23): the SERVER file is canonical, the browser copy is a + // TTL'd read-through cache written through on save. Identity is the user's own WebDAV + // space — no new endpoint, no new credential, nothing unauthenticated added. + _STORE_TTL_MS = 300000; + + _wdConfig() { + try { + const raw = localStorage.getItem('webx11_webdav_config'); + if (!raw) return null; + const c = JSON.parse(raw); + if (!c || !c.url || String(c.url).indexOf('/spaces/') === -1) return null; + return { + base: String(c.url).replace(/\/+$/, ''), + user: c.user || c.username || '', + pass: c.pass || c.password || '' + }; + } catch (e) { return null; } + } + _storeUrl(cfg) { return cfg.base + '/_data/predictor.json'; } + // The VFS write fails silently when the parent directory is missing (writeFile does not + // create parents), so the cache is written through one guarded path that proves itself. + _writeCache(obj) { + const fs = this.terminal.fs; + if (!fs.readDir('/system/metrics')) fs.mkdirRecursive('/system/metrics'); + fs.fsWriteJSON('/system/metrics/predictor.json', obj); + if (!fs.readFile('/system/metrics/predictor.json')) { + this._storeMode = 'cache-only (cache write failed)'; + if (!this._writeWarned) { + this._writeWarned = true; + this.terminal.print('📊 Predictor: could not write its cache file — /system/metrics is not writable in this VFS', 'error'); + } + return false; + } + return true; + } + _storeHeaders(cfg) { + const h = { 'Content-Type': 'application/json' }; + if (cfg.user) h.Authorization = 'Basic ' + btoa(cfg.user + ':' + cfg.pass); + return h; + } _loadFromVFS() { try { @@ -18396,6 +18446,8 @@ class PredictorModule { if (!fs.readDir('/system/metrics')) fs.mkdirRecursive('/system/metrics'); fs.fsWriteJSON('/system/metrics/predictor.json', { v: this._SCHEMA, + cachedAt: Date.now(), + storeMode: 'cache', dataPoints: this._dataPoints.slice(-500), predictions: this._predictions, accuracy: this._accuracy, @@ -18408,11 +18460,72 @@ class PredictorModule { }, pending: this._pending.slice(-50) }); + this._debouncePush(); } catch (e) { console.error('[Predictor] Save error:', e.message); } } + _debouncePush() { + clearTimeout(this._pushTimer); + this._pushTimer = setTimeout(() => this._pushToServer(), 5000); + } + + async _pushToServer() { + const cfg = this._wdConfig(); + if (!cfg) { this._storeMode = 'cache-only (no WebDAV config)'; return false; } + try { + const body = this.terminal.fs.readFile('/system/metrics/predictor.json') || '{}'; + const put = () => fetch(this._storeUrl(cfg), { method: 'PUT', headers: this._storeHeaders(cfg), body: body }); + let resp = await put(); + // first push into a fresh space: the collection has to exist before the file can + if (resp.status === 409 || resp.status === 404) { + try { await fetch(cfg.base + '/_data', { method: 'MKCOL', headers: this._storeHeaders(cfg) }); } catch (e) {} + resp = await put(); + } + if (resp.ok) { this._storeMode = 'server'; this._serverStamp = Date.now(); return true; } + this._storeMode = 'cache-only (PUT ' + resp.status + ')'; + return false; + } catch (e) { + this._storeMode = 'cache-only (offline)'; + return false; + } + } + + // Read-through: the cache wins while fresh; the server wins when the two differ. + async _pullFromServer(force) { + const cfg = this._wdConfig(); + if (!cfg) { this._storeMode = 'cache-only (no WebDAV config)'; return false; } + if (!force && this._cacheStamp && (Date.now() - this._cacheStamp) < this._STORE_TTL_MS) return false; + try { + const resp = await fetch(this._storeUrl(cfg), { headers: this._storeHeaders(cfg) }); + if (resp.status === 404) { this._storeMode = 'server (empty)'; return false; } + if (!resp.ok) { this._storeMode = 'cache-only (GET ' + resp.status + ')'; return false; } + const remote = await resp.json(); + if (!remote || typeof remote !== 'object') return false; + const localPts = (this._dataPoints || []).length; + const localPaired = (this._accuracy || {}).total || 0; + const remotePts = (remote.dataPoints || []).length; + const remotePaired = (remote.accuracy || {}).total || 0; + const diverge = remotePts !== localPts || remotePaired !== localPaired; + remote.cachedAt = Date.now(); + remote.storeMode = 'server'; + if (!this._writeCache(remote)) return false; + this._cacheStamp = Date.now(); + this._serverStamp = Date.now(); + this._loadFromVFS(); + this._storeMode = 'server'; + if (diverge && !this._divergenceNoted) { + this._divergenceNoted = true; + this.terminal.print(`📊 Predictor store: server copy differs from the local cache (server ${remotePts} pts / ${remotePaired} paired, cache ${localPts} / ${localPaired}) — server wins`, 'warning'); + } + return true; + } catch (e) { + this._storeMode = 'cache-only (offline)'; + return false; + } + } + _debounceSave() { clearTimeout(this._saveTimer); this._saveTimer = setTimeout(() => this._saveNow(), 3000); diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index badaeed..634a0c4 100644 --- a/modules/core/module-loader.js +++ b/modules/core/module-loader.js @@ -4,7 +4,7 @@ // Core module bundle loaded synchronously in main.html. // This loader only handles version cache-busting, splash, terminal init, and optional ext modules. - const LATEST_VER = 'v4.6.67'; + const LATEST_VER = 'v4.6.70'; const CACHE = '?v=' + LATEST_VER; const stored = localStorage.getItem('webx11_cache_ver'); if (stored !== LATEST_VER) { diff --git a/modules/core/module-orchestrator.js b/modules/core/module-orchestrator.js index d03f7eb..b3b76cd 100644 --- a/modules/core/module-orchestrator.js +++ b/modules/core/module-orchestrator.js @@ -25,6 +25,9 @@ class OrchestratorModule { this._a2aPollTimer = null; this._heartbeatInterval = null; this._a2aMessages = {}; + // v4.6.68: on-demand is the design (decision 2026-10-23). Delegations — not + // "agents ever scheduled" — is the number that means something. + this._delegations = 0; this._restartBackoffs = {}; this._eventBus = {}; this._idleSweepInterval = null; @@ -1077,6 +1080,7 @@ class OrchestratorModule { async delegateTask(task, opts = {}) { if (!task) return this.terminal.print('Usage: orchestrate <task description>', 'error'); + this._delegations = (this._delegations || 0) + 1; // Parallel fan-out: decompose and run sub-tasks concurrently if (opts.parallel) { @@ -1423,7 +1427,7 @@ class OrchestratorModule { // v4.6.63: say what the state MEANS. A parked agent with 0 assignments and a parked // agent between tasks looked identical from outside (packet §6). const agentsArr = Object.values(this._agents); - const everScheduled = agentsArr.filter(a => (a.tasksAssigned || 0) > 0).length; + const available = agentsArr.filter(a => a.status === 'idle' || a.status === 'sleeping').length; const describe = (a) => { const n = a.tasksAssigned || 0; const since = a.createdAt ? String(a.createdAt).slice(0, 16).replace('T', ' ') : '?'; @@ -1436,19 +1440,21 @@ class OrchestratorModule { } if (n === 0) { return (a.status === 'sleeping' ? 'parked' : a.status) + - ` — NEVER SCHEDULED (0 tasks since ${since})`; + ` — available, no delegations yet (spawned ${since})`; } return (a.status === 'sleeping' ? 'parked' : a.status) + - ` — waiting for work (${n} task(s), last ${last || '?'})`; + ` — ${n} delegation(s), last ${last || '?'}`; }; agentsArr.forEach(a => { const modelInfo = a.model ? ` [model: ${a.model}]` : ''; const backoff = a.nextRestartAt && a.nextRestartAt > Date.now() ? ` [restart in ${Math.round((a.nextRestartAt - Date.now()) / 1000)}s]` : ''; this.terminal.print(` ${a.id} [${a.role}] — ${describe(a)}${modelInfo}${backoff}`, 'info'); }); - this.terminal.print(` Dispatch: ${everScheduled}/${total} agent(s) have ever been given a task`, everScheduled === 0 && total > 0 ? 'warning' : 'info'); - if (everScheduled === 0 && total > 0) { - this.terminal.print(' ⚠️ No agent has ever been scheduled — spawning is not dispatching. Use orchestrate <task> or orchestrator-spawn <role> <task>.', 'warning'); + // v4.6.68: on-demand by design — a worker with nothing to do is healthy, not broken. + // The number to watch is how many delegations the AI actually made. + this.terminal.print(` Mode: on-demand — ${available}/${total} worker(s) available, ${this._delegations || 0} delegation(s) this session`, 'info'); + if (total > 0 && (this._delegations || 0) === 0) { + this.terminal.print(' Workers wait for delegate_task — spawn-on-demand, no background dispatch queue by design.', 'text-muted'); } } diff --git a/modules/core/module-predictor.js b/modules/core/module-predictor.js index c8d526d..aebda87 100644 --- a/modules/core/module-predictor.js +++ b/modules/core/module-predictor.js @@ -37,6 +37,9 @@ class PredictorModule { init(terminal) { this.terminal = terminal; this._loadFromVFS(); + this._storeMode = 'cache'; + // read-through after boot: a stale cache revalidates against the canonical file + setTimeout(() => { this._pullFromServer().catch(function () {}); }, 1500); this._loaded = true; this.terminal.print('📊 Predictor loaded — analyzing task patterns', 'info'); } @@ -285,6 +288,7 @@ class PredictorModule { this.terminal.print(` Awaiting outcome: ${this._pending.length}`, 'info'); this.terminal.print(` Unpaired runs: ${unpaired}`, 'info'); this.terminal.print(` Unscored pairs: ${unscored} (aborted run or floored prediction)`, 'info'); + this.terminal.print(` Store: ${this._storeMode || 'cache'} — server file canonical, browser copy is a ${Math.round(this._STORE_TTL_MS / 60000)} min read-through cache`, 'info'); this.terminal.print(` Confidence threshold: ${this._CONFIDENCE_THRESHOLD}`, 'info'); this.terminal.print(` Min data points: ${this._MIN_DATA_POINTS}`, 'info'); this.terminal.print(` Pair TTL: ${Math.round(this._PAIR_TTL_MS / 60000)}m`, 'info'); @@ -322,6 +326,46 @@ class PredictorModule { } // ─── Persistence ─── + // v4.6.68 (decision 2026-10-23): the SERVER file is canonical, the browser copy is a + // TTL'd read-through cache written through on save. Identity is the user's own WebDAV + // space — no new endpoint, no new credential, nothing unauthenticated added. + _STORE_TTL_MS = 300000; + + _wdConfig() { + try { + const raw = localStorage.getItem('webx11_webdav_config'); + if (!raw) return null; + const c = JSON.parse(raw); + if (!c || !c.url || String(c.url).indexOf('/spaces/') === -1) return null; + return { + base: String(c.url).replace(/\/+$/, ''), + user: c.user || c.username || '', + pass: c.pass || c.password || '' + }; + } catch (e) { return null; } + } + _storeUrl(cfg) { return cfg.base + '/_data/predictor.json'; } + // The VFS write fails silently when the parent directory is missing (writeFile does not + // create parents), so the cache is written through one guarded path that proves itself. + _writeCache(obj) { + const fs = this.terminal.fs; + if (!fs.readDir('/system/metrics')) fs.mkdirRecursive('/system/metrics'); + fs.fsWriteJSON('/system/metrics/predictor.json', obj); + if (!fs.readFile('/system/metrics/predictor.json')) { + this._storeMode = 'cache-only (cache write failed)'; + if (!this._writeWarned) { + this._writeWarned = true; + this.terminal.print('📊 Predictor: could not write its cache file — /system/metrics is not writable in this VFS', 'error'); + } + return false; + } + return true; + } + _storeHeaders(cfg) { + const h = { 'Content-Type': 'application/json' }; + if (cfg.user) h.Authorization = 'Basic ' + btoa(cfg.user + ':' + cfg.pass); + return h; + } _loadFromVFS() { try { @@ -363,6 +407,8 @@ class PredictorModule { if (!fs.readDir('/system/metrics')) fs.mkdirRecursive('/system/metrics'); fs.fsWriteJSON('/system/metrics/predictor.json', { v: this._SCHEMA, + cachedAt: Date.now(), + storeMode: 'cache', dataPoints: this._dataPoints.slice(-500), predictions: this._predictions, accuracy: this._accuracy, @@ -375,11 +421,72 @@ class PredictorModule { }, pending: this._pending.slice(-50) }); + this._debouncePush(); } catch (e) { console.error('[Predictor] Save error:', e.message); } } + _debouncePush() { + clearTimeout(this._pushTimer); + this._pushTimer = setTimeout(() => this._pushToServer(), 5000); + } + + async _pushToServer() { + const cfg = this._wdConfig(); + if (!cfg) { this._storeMode = 'cache-only (no WebDAV config)'; return false; } + try { + const body = this.terminal.fs.readFile('/system/metrics/predictor.json') || '{}'; + const put = () => fetch(this._storeUrl(cfg), { method: 'PUT', headers: this._storeHeaders(cfg), body: body }); + let resp = await put(); + // first push into a fresh space: the collection has to exist before the file can + if (resp.status === 409 || resp.status === 404) { + try { await fetch(cfg.base + '/_data', { method: 'MKCOL', headers: this._storeHeaders(cfg) }); } catch (e) {} + resp = await put(); + } + if (resp.ok) { this._storeMode = 'server'; this._serverStamp = Date.now(); return true; } + this._storeMode = 'cache-only (PUT ' + resp.status + ')'; + return false; + } catch (e) { + this._storeMode = 'cache-only (offline)'; + return false; + } + } + + // Read-through: the cache wins while fresh; the server wins when the two differ. + async _pullFromServer(force) { + const cfg = this._wdConfig(); + if (!cfg) { this._storeMode = 'cache-only (no WebDAV config)'; return false; } + if (!force && this._cacheStamp && (Date.now() - this._cacheStamp) < this._STORE_TTL_MS) return false; + try { + const resp = await fetch(this._storeUrl(cfg), { headers: this._storeHeaders(cfg) }); + if (resp.status === 404) { this._storeMode = 'server (empty)'; return false; } + if (!resp.ok) { this._storeMode = 'cache-only (GET ' + resp.status + ')'; return false; } + const remote = await resp.json(); + if (!remote || typeof remote !== 'object') return false; + const localPts = (this._dataPoints || []).length; + const localPaired = (this._accuracy || {}).total || 0; + const remotePts = (remote.dataPoints || []).length; + const remotePaired = (remote.accuracy || {}).total || 0; + const diverge = remotePts !== localPts || remotePaired !== localPaired; + remote.cachedAt = Date.now(); + remote.storeMode = 'server'; + if (!this._writeCache(remote)) return false; + this._cacheStamp = Date.now(); + this._serverStamp = Date.now(); + this._loadFromVFS(); + this._storeMode = 'server'; + if (diverge && !this._divergenceNoted) { + this._divergenceNoted = true; + this.terminal.print(`📊 Predictor store: server copy differs from the local cache (server ${remotePts} pts / ${remotePaired} paired, cache ${localPts} / ${localPaired}) — server wins`, 'warning'); + } + return true; + } catch (e) { + this._storeMode = 'cache-only (offline)'; + return false; + } + } + _debounceSave() { clearTimeout(this._saveTimer); this._saveTimer = setTimeout(() => this._saveNow(), 3000); |