aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-27 11:23:10 +0100
committerAgando <roduccomm@gmail.com>2026-10-27 11:23:23 +0100
commit97279c0eb918701ac915187f5deb9f0adf9236bd (patch)
tree003916029e996d0a3e7fbc68f23b87308e9203e9
parentf1734dfddd88aad22818911e6e5f00bd68675017 (diff)
fix(newos4): rollback keeps scaffolding, checkpoints cannot collide, tests gate the build
Review of the 08:30 implementer run (v4.6.102, checkpoint name fix). The shipped fix was correct; these are the defects it introduced or left behind, each measured first. - virtual-fs.js rollback(): the snapshot strips _snapshots/_sessions/.trash for bloat. Restoring it verbatim deleted every checkpoint, every agent session and the trash, made rollback irreversible and unrepeatable. Re-attach the live copies after the root swap. - checkpoint(): names are millisecond-resolution, so tight calls overwrote each other (measured 6 calls -> 3 snapshots). Dedupe with a -N suffix, refuse names containing "/" (written but unlistable), and prune by snapshot createdAt instead of filename sort, which let a custom name ("aaa-oldest") evict newer auto-named checkpoints. - module-predictor.js: legacy pre-v2 points have no `usable`, so a 0.079 s aborted run re-entered similarity as evidence and floored every prediction at 1 s. Backfill `usable` from the measurement on load, drop the ones it rejects, add a belt-and-braces guard in _findSimilar and count only usable points as actual executions. - newos4-maintain.py: run every node-runnable modules/test/test-*.js on each maintenance pass. The v4.6.102 regression was written but no job ever executed it. - newos4-steer.py: `done` now requires --commit <sha> that exists in the repo, and gains `reopen`. A written plan was being closed as done at 06:17 while the predictor fix shipped nowhere, so the implementer's pre-flight queue was empty. - steering.json: predictor item reopened, checkpoint item stamped with f7249ff. - maintenance board: MAINT-002/005/015 were fixed in v4.6.81-85 but still listed open/high; closed with their commit ids, and MAINT-010/016 annotated with the probes that keep them open. Tests: test-vfs-checkpoint 23/23 (RED 14/9 before), test-predictor-accuracy 38/38 (T1-T3 RED on HEAD), maintain --fix all PASS, browser round-trip "Checkpoint created: <name>" -> "Rolled back to checkpoint: <name>" on v4.6.103. Also carries the already-deployed 2026-10-26 syntax-gate hunk in newos4-maintain.py (syntax_failures + gated rebuild) that had never been committed.
-rw-r--r--main.html6
-rw-r--r--modules-index.json9
-rw-r--r--modules/core/core-bundle.js47
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/core/module-predictor.js15
-rw-r--r--modules/core/virtual-fs.js32
-rw-r--r--modules/test/test-predictor-accuracy.js33
-rw-r--r--modules/test/test-vfs-checkpoint.js55
-rw-r--r--scripts/newos4-maintain.py63
-rwxr-xr-xscripts/newos4-steer.py26
10 files changed, 256 insertions, 32 deletions
diff --git a/main.html b/main.html
index b3d2c39..1c64cf1 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.102"></script>
+<script src="modules/core/core-bundle.js?v=v4.6.103"></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.102"></script>
+<script src="modules/core/module-loader.js?v=v4.6.103"></script>
<!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work -->
-<script src="modules/ext/critical-ext-bundle.js?v=v4.6.102"></script>
+<script src="modules/ext/critical-ext-bundle.js?v=v4.6.103"></script>
<script>
// ═══ ONE TRUE VERSION SOURCE ═══
diff --git a/modules-index.json b/modules-index.json
index 5a55ebe..2f95365 100644
--- a/modules-index.json
+++ b/modules-index.json
@@ -1,5 +1,5 @@
{
- "version": "2026-10-23-atlas",
+ "version": "2026-10-27-atlas",
"generated": "2026-06-30T00:00:00.000Z",
"modules": {
"module-agent-registry": {
@@ -169,6 +169,7 @@
"empty-trash",
"find",
"grep",
+ "guides",
"head",
"ls",
"ls-trash",
@@ -1075,7 +1076,11 @@
"debug-sync-url",
"debug-trends",
"feedback",
- "feedback-inbox"
+ "feedback-admin",
+ "feedback-inbox",
+ "hermes",
+ "hermes-poll",
+ "hermes-reset"
],
"tier": "core-bundle"
},
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index cd510b7..84d194d 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -942,6 +942,17 @@ class VirtualFileSystem {
}
checkpoint(name) {
if (!name) name = new Date().toISOString();
+ name = String(name).trim();
+ if (!name || name.indexOf('/') !== -1) {
+ throw new Error('checkpoint name must be non-empty and must not contain "/"');
+ }
+ // Two unnamed calls can land in the same millisecond; without this the second silently
+ // overwrites the first (measured: 6 calls -> 3 distinct names).
+ if (this.listCheckpoints().indexOf(name) !== -1) {
+ let n = 2;
+ while (this.listCheckpoints().indexOf(name + '-' + n) !== -1) n++;
+ name = name + '-' + n;
+ }
const createdAt = new Date().toISOString();
// Strip system dirs to prevent fractal bloat
const rootClone = JSON.parse(JSON.stringify(this.root));
@@ -961,12 +972,15 @@ class VirtualFileSystem {
}
this.fsWriteJSON('/_snapshots/checkpoint-' + name + '.json', data);
this.saveToStorage();
- // Auto-manage: keep max 3 checkpoints, delete oldest
+ // Auto-manage: keep the 3 NEWEST checkpoints — by createdAt, not by name, so a custom
+ // name ("aaa-oldest") cannot pin itself forever while newer snapshots are deleted.
const all = this.listCheckpoints();
if (all.length > 3) {
- const sorted = all.slice().sort();
- const toDelete = sorted.slice(0, all.length - 3);
- for (const old of toDelete) this.deleteCheckpoint(old);
+ const aged = all.map(n => {
+ const snap = this.fsReadJSON('/_snapshots/checkpoint-' + n + '.json');
+ return { name: n, at: (snap && snap.createdAt) || '' };
+ }).sort((a, b) => (a.at < b.at ? -1 : (a.at > b.at ? 1 : 0)));
+ for (const old of aged.slice(0, aged.length - 3)) this.deleteCheckpoint(old.name);
}
return name;
}
@@ -976,8 +990,18 @@ class VirtualFileSystem {
console.error('Rollback failed: checkpoint not found or invalid');
return false;
}
+ // The snapshot deliberately omits scaffolding (bloat guard at :944-950). Restoring the
+ // clone verbatim deletes every checkpoint, every agent session and the trash, so keep the
+ // live copies and re-attach them after the root swap.
+ const keep = {};
+ for (const dir of ['_snapshots', '_sessions', '.trash']) {
+ const node = this.root && this.root.content ? this.root.content[dir] : null;
+ if (node) keep[dir] = node;
+ }
this.root = raw.root;
this.currentPath = raw.currentPath || [];
+ if (!this.root.content) this.root.content = {};
+ for (const dir of Object.keys(keep)) this.root.content[dir] = keep[dir];
this.saveToStorage();
return true;
}
@@ -18261,6 +18285,7 @@ class PredictorModule {
return this._dataPoints.filter(dp => {
if (!dp.observedDuration) return false; // outcomes only, never predictions
if (dp.usable === false) return false; // aborted runs are not evidence
+ if (!(dp.observedDuration >= 1.5)) return false; // belt and braces: never evidence
if (dp.features) return this._similarity(features, dp.features) > 0.3;
return false;
});
@@ -18412,7 +18437,7 @@ class PredictorModule {
// ─── Commands ───
showStatus() {
- const actualExecutions = this._dataPoints.filter(dp => dp.observedDuration).length;
+ const actualExecutions = this._dataPoints.filter(dp => dp.observedDuration && dp.usable !== false).length;
const paired = this._accuracy.total;
const unpaired = this._dataPoints.filter(dp => dp.paired === false).length;
const accPct = paired > 0 ? ((this._accuracy.correct / paired) * 100).toFixed(0) + '%' : 'n/a';
@@ -18517,8 +18542,16 @@ class PredictorModule {
const data = fs.fsReadJSON('/system/metrics/predictor.json');
if (!data) return;
- // Prediction-only points are noise for similarity; keep outcomes.
- this._dataPoints = (data.dataPoints || []).filter(dp => dp && dp.observedDuration);
+ // Legacy (pre-v2) points carry no `usable`. Backfill it from the measurement — an
+ // aborted run must not enter similarity as evidence (measured: a 0.079 s point
+ // dragged avgDuration to ~0 and floored every prediction at 1 s).
+ this._dataPoints = (data.dataPoints || [])
+ .filter(dp => dp && dp.observedDuration)
+ .map(dp => {
+ if (typeof dp.usable !== 'boolean') dp.usable = dp.observedDuration >= 1.5;
+ return dp;
+ })
+ .filter(dp => dp.usable !== false);
this._predictions = data.predictions || 0;
if (data.v === this._SCHEMA) {
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index b278f51..18137ec 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.102';
+ const LATEST_VER = 'v4.6.103';
const CACHE = '?v=' + LATEST_VER;
const stored = localStorage.getItem('webx11_cache_ver');
if (stored !== LATEST_VER) {
diff --git a/modules/core/module-predictor.js b/modules/core/module-predictor.js
index aebda87..b534d34 100644
--- a/modules/core/module-predictor.js
+++ b/modules/core/module-predictor.js
@@ -118,6 +118,7 @@ class PredictorModule {
return this._dataPoints.filter(dp => {
if (!dp.observedDuration) return false; // outcomes only, never predictions
if (dp.usable === false) return false; // aborted runs are not evidence
+ if (!(dp.observedDuration >= 1.5)) return false; // belt and braces: never evidence
if (dp.features) return this._similarity(features, dp.features) > 0.3;
return false;
});
@@ -269,7 +270,7 @@ class PredictorModule {
// ─── Commands ───
showStatus() {
- const actualExecutions = this._dataPoints.filter(dp => dp.observedDuration).length;
+ const actualExecutions = this._dataPoints.filter(dp => dp.observedDuration && dp.usable !== false).length;
const paired = this._accuracy.total;
const unpaired = this._dataPoints.filter(dp => dp.paired === false).length;
const accPct = paired > 0 ? ((this._accuracy.correct / paired) * 100).toFixed(0) + '%' : 'n/a';
@@ -374,8 +375,16 @@ class PredictorModule {
const data = fs.fsReadJSON('/system/metrics/predictor.json');
if (!data) return;
- // Prediction-only points are noise for similarity; keep outcomes.
- this._dataPoints = (data.dataPoints || []).filter(dp => dp && dp.observedDuration);
+ // Legacy (pre-v2) points carry no `usable`. Backfill it from the measurement — an
+ // aborted run must not enter similarity as evidence (measured: a 0.079 s point
+ // dragged avgDuration to ~0 and floored every prediction at 1 s).
+ this._dataPoints = (data.dataPoints || [])
+ .filter(dp => dp && dp.observedDuration)
+ .map(dp => {
+ if (typeof dp.usable !== 'boolean') dp.usable = dp.observedDuration >= 1.5;
+ return dp;
+ })
+ .filter(dp => dp.usable !== false);
this._predictions = data.predictions || 0;
if (data.v === this._SCHEMA) {
diff --git a/modules/core/virtual-fs.js b/modules/core/virtual-fs.js
index 09b3548..3ed1b40 100644
--- a/modules/core/virtual-fs.js
+++ b/modules/core/virtual-fs.js
@@ -940,6 +940,17 @@ class VirtualFileSystem {
}
checkpoint(name) {
if (!name) name = new Date().toISOString();
+ name = String(name).trim();
+ if (!name || name.indexOf('/') !== -1) {
+ throw new Error('checkpoint name must be non-empty and must not contain "/"');
+ }
+ // Two unnamed calls can land in the same millisecond; without this the second silently
+ // overwrites the first (measured: 6 calls -> 3 distinct names).
+ if (this.listCheckpoints().indexOf(name) !== -1) {
+ let n = 2;
+ while (this.listCheckpoints().indexOf(name + '-' + n) !== -1) n++;
+ name = name + '-' + n;
+ }
const createdAt = new Date().toISOString();
// Strip system dirs to prevent fractal bloat
const rootClone = JSON.parse(JSON.stringify(this.root));
@@ -959,12 +970,15 @@ class VirtualFileSystem {
}
this.fsWriteJSON('/_snapshots/checkpoint-' + name + '.json', data);
this.saveToStorage();
- // Auto-manage: keep max 3 checkpoints, delete oldest
+ // Auto-manage: keep the 3 NEWEST checkpoints — by createdAt, not by name, so a custom
+ // name ("aaa-oldest") cannot pin itself forever while newer snapshots are deleted.
const all = this.listCheckpoints();
if (all.length > 3) {
- const sorted = all.slice().sort();
- const toDelete = sorted.slice(0, all.length - 3);
- for (const old of toDelete) this.deleteCheckpoint(old);
+ const aged = all.map(n => {
+ const snap = this.fsReadJSON('/_snapshots/checkpoint-' + n + '.json');
+ return { name: n, at: (snap && snap.createdAt) || '' };
+ }).sort((a, b) => (a.at < b.at ? -1 : (a.at > b.at ? 1 : 0)));
+ for (const old of aged.slice(0, aged.length - 3)) this.deleteCheckpoint(old.name);
}
return name;
}
@@ -974,8 +988,18 @@ class VirtualFileSystem {
console.error('Rollback failed: checkpoint not found or invalid');
return false;
}
+ // The snapshot deliberately omits scaffolding (bloat guard at :944-950). Restoring the
+ // clone verbatim deletes every checkpoint, every agent session and the trash, so keep the
+ // live copies and re-attach them after the root swap.
+ const keep = {};
+ for (const dir of ['_snapshots', '_sessions', '.trash']) {
+ const node = this.root && this.root.content ? this.root.content[dir] : null;
+ if (node) keep[dir] = node;
+ }
this.root = raw.root;
this.currentPath = raw.currentPath || [];
+ if (!this.root.content) this.root.content = {};
+ for (const dir of Object.keys(keep)) this.root.content[dir] = keep[dir];
this.saveToStorage();
return true;
}
diff --git a/modules/test/test-predictor-accuracy.js b/modules/test/test-predictor-accuracy.js
index 3688b03..74f0fcc 100644
--- a/modules/test/test-predictor-accuracy.js
+++ b/modules/test/test-predictor-accuracy.js
@@ -247,5 +247,38 @@ function ok(name, cond, detail) {
ok('S3 pending consumed', p._pending.length === 0);
}
+// T. v1 payload carrying an aborted garbage point must not train similarity (2026-10-27)
+{
+ const { p, term, out } = mk();
+ const feats = p._extractFeatures('read the logs');
+ term.fs.fsWriteJSON('/system/metrics/predictor.json', {
+ dataPoints: [{ features: feats, observedDuration: 0.079, observedTurns: 1 },
+ { features: feats, observedDuration: 30, observedTurns: 3 }],
+ predictions: 2
+ });
+ p._loadFromVFS();
+ ok('T1 legacy aborted point dropped on load', p._dataPoints.length === 1, 'n=' + p._dataPoints.length);
+ const similar = p._findSimilar(p._extractFeatures('read the logs'));
+ ok('T2 similarity ignores the aborted point',
+ similar.length === 1 && similar[0].observedDuration === 30,
+ JSON.stringify(similar.map(s => s.observedDuration)));
+ out.length = 0; p.showStatus();
+ const line = out.find(l => l.includes('Actual executions')) || '';
+ ok('T3 status counts only usable executions', /Actual executions:\s*1\s*$/.test(line.trim()), line.trim());
+}
+
+// U. a lone aborted point cannot floor the next prediction
+{
+ const { p, term } = mk();
+ term.fs.fsWriteJSON('/system/metrics/predictor.json', {
+ dataPoints: [{ features: { hasRead: true }, observedDuration: 0.079, observedTurns: 1 }],
+ predictions: 1
+ });
+ p._loadFromVFS();
+ const pred = p.notePrediction('read the logs', 'test');
+ ok('U1 prediction is not floored by a lone aborted point', pred && pred.floored === false,
+ JSON.stringify(pred && { duration: pred.duration, floored: pred.floored }));
+}
+
console.log(passes + '/' + (passes + fails) + ' PASS');
process.exit(fails ? 1 : 0);
diff --git a/modules/test/test-vfs-checkpoint.js b/modules/test/test-vfs-checkpoint.js
index 03a8c8a..65f32dc 100644
--- a/modules/test/test-vfs-checkpoint.js
+++ b/modules/test/test-vfs-checkpoint.js
@@ -84,6 +84,61 @@ function ok(name, cond, detail) {
'output=' + JSON.stringify(out.join('\n')));
}
+// D. same-millisecond calls must not overwrite each other
+{
+ const fs = mkFs();
+ const names = [];
+ // 4 calls: one more than the cap of 3, so every name is still stored when the next one is
+ // minted (at 6+, prune legitimately frees a name and it may be reused).
+ for (let i = 0; i < 4; i++) names.push(fs.checkpoint(''));
+ ok('D1 four unnamed checkpoints are four distinct names', new Set(names).size === 4,
+ 'names=' + JSON.stringify(names));
+ ok('D2 prune still caps the store at three', fs.listCheckpoints().length === 3,
+ 'stored=' + JSON.stringify(fs.listCheckpoints()));
+}
+
+// E. prune keeps the NEWEST three, not the alphabetically last
+{
+ const fs = mkFs();
+ fs.checkpoint('aaa-oldest');
+ fs.checkpoint('');
+ fs.checkpoint('');
+ fs.checkpoint(''); // 4th -> prune
+ const left = fs.listCheckpoints();
+ ok('E1 oldest custom-named checkpoint was pruned', !left.includes('aaa-oldest'), 'left=' + JSON.stringify(left));
+ ok('E2 three checkpoints remain', left.length === 3, 'left=' + JSON.stringify(left));
+ ok('E3 rollback still works after pruning', fs.rollback(left[0]) === true);
+}
+
+// F. rollback must not destroy scaffolding
+{
+ const fs = mkFs();
+ fs.mkdirRecursive('/_sessions/agent1');
+ fs.writeFile('/_sessions/agent1/log.json', '{"turns":3}');
+ fs.mkdirRecursive('/.trash');
+ fs.writeFile('/.trash/junk.txt', 'x');
+ fs.writeFile('/work.txt', 'before');
+ const name = fs.checkpoint('keep-me');
+ fs.writeFile('/work.txt', 'after');
+ ok('F1 rollback succeeds', fs.rollback(name) === true);
+ ok('F2 file restored', fs.readFile('/work.txt') === 'before', 'got ' + fs.readFile('/work.txt'));
+ ok('F3 checkpoint survived rollback', fs.listCheckpoints().includes('keep-me'),
+ 'list=' + JSON.stringify(fs.listCheckpoints()));
+ ok('F4 sessions survived rollback', !!fs.readDir('/_sessions'));
+ ok('F5 trash survived rollback', !!fs.readDir('/.trash'));
+ ok('F6 rollback is repeatable', fs.rollback('keep-me') === true);
+}
+
+// G. a name with a slash is refused, not silently unlistable
+{
+ const fs = mkFs();
+ let threw = false;
+ try { fs.checkpoint('a/b'); } catch (e) { threw = true; }
+ ok('G1 slash in name throws', threw);
+ ok('G2 nothing was written', fs.listCheckpoints().length === 0,
+ 'list=' + JSON.stringify(fs.listCheckpoints()));
+}
+
if (fails === 0) {
console.log('test-vfs-checkpoint: ' + passes + ' passed, 0 failed');
} else {
diff --git a/scripts/newos4-maintain.py b/scripts/newos4-maintain.py
index dd6c898..c28fb8b 100644
--- a/scripts/newos4-maintain.py
+++ b/scripts/newos4-maintain.py
@@ -21,22 +21,41 @@ def check_versions():
n = html.count(ver)
add('PASS' if n == 3 else 'FAIL', f'version {ver} x{n} in main.html (expect 3)')
-def check_bundles():
+def syntax_failures():
+ bad = []
+ for p in glob.glob(os.path.join(ROOT, 'modules/core/*.js')) + glob.glob(os.path.join(ROOT, 'modules/ext/*.js')):
+ if os.path.basename(p) in ('core-bundle.js', 'critical-ext-bundle.js'): continue
+ if run(['node', '--check', p]).returncode: bad.append(os.path.basename(p))
+ return bad
+
+def check_bundles(bad_syntax=None):
+ """Rebuild only when every module parses.
+
+ rebuild-bundles.py concatenates blindly (no validation), so a module with a
+ syntax error would be baked straight into the served core-bundle.js. Since
+ --fix now runs daily, the rebuild is gated on check_syntax and the produced
+ bundles are re-checked before the run is called clean.
+ """
paths = ('modules/core/core-bundle.js', 'modules/ext/critical-ext-bundle.js')
before = {p: sh(f'md5sum {p}') for p in paths}
if not FIX:
add('PASS', 'bundle check skipped (use --fix)'); return
+ if bad_syntax is None: bad_syntax = syntax_failures()
+ if bad_syntax:
+ add('FAIL', 'bundles rebuild BLOCKED: syntax fail in ' + ', '.join(bad_syntax[:5])); return
r = run([sys.executable, 'scripts/rebuild-bundles.py'], cwd=ROOT)
+ if r.returncode:
+ add('FAIL', 'bundles FAILED'); return
+ invalid = [p for p in paths if run(['node', '--check', os.path.join(ROOT, p)]).returncode]
+ if invalid:
+ add('FAIL', 'bundles rebuilt but INVALID: ' + ', '.join(invalid)); return
after = {p: sh(f'md5sum {p}') for p in paths}
changed = [p for p in paths if before[p] != after[p]]
- add('FAIL' if r.returncode else ('WARN' if changed else 'PASS'),
- 'bundles ' + ('FAILED' if r.returncode else ('rebuilt: ' + ', '.join(changed) if changed else 'clean')))
+ add('WARN' if changed else 'PASS',
+ 'bundles ' + ('rebuilt: ' + ', '.join(changed) if changed else 'clean'))
-def check_syntax():
- bad = []
- for p in glob.glob(os.path.join(ROOT, 'modules/core/*.js')) + glob.glob(os.path.join(ROOT, 'modules/ext/*.js')):
- if os.path.basename(p) in ('core-bundle.js', 'critical-ext-bundle.js'): continue
- if run(['node', '--check', p]).returncode: bad.append(os.path.basename(p))
+def check_syntax(bad=None):
+ bad = syntax_failures() if bad is None else bad
add('PASS' if not bad else 'FAIL', 'syntax ' + ('OK' if not bad else 'fail: ' + ', '.join(bad)))
def check_perms():
@@ -128,6 +147,31 @@ def check_help_coverage():
f'help coverage {len(cmds) - len(missing)}/{len(cmds)} commands'
+ ('' if not missing else f' — undocumented: {", ".join(missing[:8])}'))
+def check_regression_tests():
+ """Every node-runnable test under modules/test/ must pass (2026-10-27).
+
+ The v4.6.102 checkpoint regression was written but no job executed it, so drift could ship
+ silently. Two header conventions exist: '// ... run: node' and '// node modules/test/x.js'.
+ test-suite.js starts with a class (browser-only) and is correctly skipped.
+ """
+ tests, failed = [], []
+ for p in sorted(glob.glob(os.path.join(ROOT, 'modules/test/test-*.js'))):
+ head = ''.join(open(p, encoding='utf-8', errors='ignore').readlines()[:3])
+ if 'run: node' not in head and not head.startswith('// node modules/test/'):
+ continue
+ tests.append(p)
+ try:
+ r = subprocess.run(['node', p], cwd=ROOT, capture_output=True, text=True, timeout=120)
+ except subprocess.TimeoutExpired:
+ failed.append(os.path.basename(p) + ': timeout')
+ continue
+ if r.returncode != 0:
+ lines = (r.stdout or r.stderr).strip().splitlines() or ['no output']
+ failed.append(os.path.basename(p) + ': ' + lines[-1][:120])
+ add('PASS' if not failed else 'FAIL',
+ 'node tests %d/%d pass' % (len(tests) - len(failed), len(tests))
+ + ('' if not failed else ' — ' + '; '.join(failed[:3])))
+
def check_links():
if not DEEP: return
broken = []
@@ -143,7 +187,8 @@ def check_links():
def main():
os.chdir(ROOT)
- check_versions(); check_bundles(); check_syntax(); check_perms(); check_index(); check_data(); check_live(); check_atlas(); check_course(); check_help_coverage(); check_links()
+ bad = syntax_failures() # computed once, gates the bundle rebuild
+ check_versions(); check_syntax(bad); check_regression_tests(); check_bundles(bad); check_perms(); check_index(); check_data(); check_live(); check_atlas(); check_course(); check_help_coverage(); check_links()
fails = [m for l, m in issues if l == 'FAIL']
print('newOS4 maintenance — ' + datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%MZ') + (' [fix]' if FIX else '') + (' [deep]' if DEEP else ''))
for l, m in issues: print(f'{l}: {m}')
diff --git a/scripts/newos4-steer.py b/scripts/newos4-steer.py
index de3b87c..1e387f8 100755
--- a/scripts/newos4-steer.py
+++ b/scripts/newos4-steer.py
@@ -15,6 +15,7 @@ import argparse
import fcntl
import json
import os
+import subprocess
import time
ROOT = '/var/www/webx11.duckdns.org/spaces/newos4'
@@ -48,7 +49,8 @@ def main():
sub = ap.add_subparsers(dest='cmd', required=True)
a = sub.add_parser('add'); a.add_argument('text'); a.add_argument('--priority', default='normal', choices=['high', 'normal', 'low'])
sub.add_parser('list').add_argument('--all', action='store_true')
- d = sub.add_parser('done'); d.add_argument('id')
+ d = sub.add_parser('done'); d.add_argument('id'); d.add_argument('--commit', default='')
+ r = sub.add_parser('reopen'); r.add_argument('id'); r.add_argument('--reason', default='')
sub.add_parser('clear')
args = ap.parse_args()
@@ -76,11 +78,29 @@ def main():
elif args.cmd == 'done':
hit = [r for r in data if r.get('id') == args.id]
if not hit:
- raise SystemExit(f"no such id: {args.id}")
+ raise SystemExit("no such id: " + args.id)
+ if not args.commit:
+ raise SystemExit('refusing: --commit <sha> required - a written plan is not a ship')
+ chk = subprocess.run(['git', '-C', '/var/www/webx11.duckdns.org', 'cat-file', '-e',
+ args.commit + '^{commit}'], capture_output=True, text=True)
+ if chk.returncode != 0:
+ raise SystemExit('refusing: ' + args.commit + ' is not a commit in the repo')
hit[0]['done'] = True
hit[0]['done_at'] = time.strftime('%Y-%m-%dT%H:%M:%S+00:00', time.gmtime())
+ hit[0]['done_commit'] = args.commit
_save(data)
- print('done:', args.id)
+ print('done:', args.id, 'at', args.commit)
+ elif args.cmd == 'reopen':
+ hit = [r for r in data if r.get('id') == args.id]
+ if not hit:
+ raise SystemExit("no such id: " + args.id)
+ hit[0]['done'] = False
+ hit[0].pop('done_at', None)
+ hit[0].pop('done_commit', None)
+ hit[0]['reopened_at'] = time.strftime('%Y-%m-%dT%H:%M:%S+00:00', time.gmtime())
+ hit[0]['reopen_reason'] = (args.reason or '')[:300]
+ _save(data)
+ print('reopened:', args.id)
elif args.cmd == 'clear':
n = 0
for r in data: