aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-23 19:01:46 +0200
committerAgando <roduccomm@gmail.com>2026-10-23 19:01:46 +0200
commit1ff8df65ffefbd2eaa9100374fbcfb7828fdb717 (patch)
treee42d2c6404211547ff4444596f79e24060580b8e
parentb0404b1c9154c3438d0e124d69dc25d9140cfac4 (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.html4
-rw-r--r--main.html6
-rw-r--r--modules/core/core-bundle.js125
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/core/module-orchestrator.js18
-rw-r--r--modules/core/module-predictor.js107
6 files changed, 245 insertions, 17 deletions
diff --git a/help.html b/help.html
index 4de3a9c..8967db2 100644
--- a/help.html
+++ b/help.html
@@ -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>&amp;&amp;</code> and <code>||</code> are refused with an explicit message instead of being folded into the command's argv (where <code>wc -l f &amp;&amp; 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/&lt;Class&gt;.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 &gt; description/tags &gt; 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>==&gt; file &lt;==</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>
diff --git a/main.html b/main.html
index 8a6ee5d..5107a40 100644
--- a/main.html
+++ b/main.html
@@ -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);