From 1ff8df65ffefbd2eaa9100374fbcfb7828fdb717 Mon Sep 17 00:00:00 2001 From: Agando Date: Fri, 23 Oct 2026 19:01:46 +0200 Subject: feat(newos4): on-demand worker labels and a server-canonical predictor store (v4.6.70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (/_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)". --- modules/core/module-predictor.js | 107 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) (limited to 'modules/core/module-predictor.js') 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); -- cgit v1.2.3