aboutsummaryrefslogtreecommitdiff
path: root/scripts/newos4-triage.py
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-23 14:39:38 +0200
committerAgando <roduccomm@gmail.com>2026-10-23 14:39:38 +0200
commit25833fb43a0ed3e9c7522dfb6a78f29dfd947720 (patch)
tree01e1b13d8619290143a67990bd12657323867dcc /scripts/newos4-triage.py
parent97f55bf200c74a1b8ca0068ff70cbfeda77916cf (diff)
feat(newos4): maintenance agent channel — triage engine, two-way replies, cron
- scripts/newos4-triage.py: deterministic collector (records vs occurrences, real vs noise vs self-referential, new reports since last run, checker WARN/FAIL). Untrusted strings are sanitised and prefixed DATA| before they can reach an agent prompt; state + output written under flock with mode 644. - scripts/newos4-reply.py: appends one reply to data/maintenance/replies.json under flock. Hermes writes here instead of POSTing with an admin cookie, so an expired session can never look like a delivered reply. - api/debug/agent-replies.php: GET/POST (admin-gated by nginx) half of the thread. - api/debug/report.php: accepts thread + kind=maintenance so in-app feedback is distinguishable from operator bug reports. - terminal: `feedback <message>` and `feedback-inbox` commands; help.html rows added (help coverage 357/357, modules-index regenerated). Live proof: `feedback "…"` filed RPT-20261023-123825-781fba86 with thread thr-glmfwae2 and kind=maintenance; agent-replies GET returned the posted reply, POST returned 201; triage first run reported 9 new reports, second run 0. Cron: newos4-maintenance (7e3b326daa4c) daily 07:15, newos4-skills-weekly (cb6bc2a16216) Mondays 06:30.
Diffstat (limited to 'scripts/newos4-triage.py')
-rwxr-xr-xscripts/newos4-triage.py166
1 files changed, 166 insertions, 0 deletions
diff --git a/scripts/newos4-triage.py b/scripts/newos4-triage.py
new file mode 100755
index 0000000..20016f7
--- /dev/null
+++ b/scripts/newos4-triage.py
@@ -0,0 +1,166 @@
+#!/usr/bin/env python3
+"""newOS4 triage engine — turns telemetry + reports into one actionable digest.
+
+Read-only by default. --apply performs safe hygiene only (archive old reports).
+Never edits app code, nginx, services or user content.
+"""
+import argparse, json, os, re, subprocess, sys, time
+from datetime import datetime, timezone
+
+ROOT = '/var/www/webx11.duckdns.org/spaces/newos4'
+DEBUG = os.path.join(ROOT, 'data', 'debug')
+REPORTS = os.path.join(ROOT, 'data', 'reports')
+ARCHIVE = os.path.join(REPORTS, 'archive')
+OUTDIR = '/var/www/webx11.duckdns.org/admin/docs'
+STATE_DIR = '/var/lib/webx11/newos4-maintenance'
+STATE = os.path.join(STATE_DIR, 'state.json')
+
+NOISE = re.compile(r'(user aborted|aborted without reason|signal timed out|AbortError|TimeoutError)', re.I)
+SELF = re.compile(r'/api/debug/(ingest|report)\.php', re.I)
+
+
+def load(path, default):
+ try:
+ with open(path, encoding='utf-8') as f:
+ return json.load(f)
+ except Exception:
+ return default
+
+
+def save(path, obj):
+ """Write atomically, under flock, and leave the file readable by nginx."""
+ import fcntl
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ lock = open(path + '.lock', 'a+')
+ fcntl.flock(lock, fcntl.LOCK_EX)
+ try:
+ tmp = path + '.tmp'
+ with open(tmp, 'w', encoding='utf-8') as f:
+ json.dump(obj, f, indent=2)
+ f.flush()
+ os.fsync(f.fileno())
+ os.replace(tmp, path)
+ os.chmod(path, 0o644) # a 600 file 403s behind the admin gate
+ finally:
+ fcntl.flock(lock, fcntl.LOCK_UN)
+ lock.close()
+
+
+def ts(s):
+ if not s:
+ return None
+ try:
+ d = datetime.fromisoformat(str(s).replace('Z', '+00:00'))
+ return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
+ except Exception:
+ return None
+
+
+def age_hours(s):
+ d = ts(s)
+ return None if d is None else round((datetime.now(timezone.utc) - d).total_seconds() / 3600, 1)
+
+
+def check_runner():
+ r = subprocess.run([sys.executable, 'scripts/newos4-maintain.py'], cwd=ROOT,
+ capture_output=True, text=True, timeout=300)
+ lines = [l for l in (r.stdout or '').splitlines() if l.startswith(('FAIL', 'WARN'))]
+ return {'exit': r.returncode, 'findings': lines}
+
+
+def safe(s, limit=200):
+ """Make untrusted telemetry safe to paste into an agent prompt: one line, no control
+ chars, bounded. Everything the agent sees from here is DATA, never an instruction."""
+ s = re.sub(r'[\x00-\x1f\x7f]+', ' ', str(s or ''))
+ s = re.sub(r'\s+', ' ', s).strip()
+ return s[:limit]
+
+
+def classify(err):
+ msg = str(err.get('message', ''))
+ if SELF.search(msg):
+ return 'self-referential'
+ if NOISE.search(msg):
+ return 'noise'
+ return 'real'
+
+
+def build(apply_fix=False):
+ errors = load(os.path.join(DEBUG, 'errors.json'), [])
+ reports = []
+ if os.path.isdir(REPORTS):
+ for name in sorted(os.listdir(REPORTS)):
+ if name.startswith('RPT-') and name.endswith('.json'):
+ r = load(os.path.join(REPORTS, name), None)
+ if isinstance(r, dict):
+ r['_file'] = os.path.join(REPORTS, name)
+ reports.append(r)
+
+ state = load(STATE, {'seen_reports': [], 'last_run': None})
+ seen = set(state.get('seen_reports', []))
+ new_reports = [r for r in reports if r.get('id') not in seen]
+
+ buckets = {'real': [], 'noise': [], 'self-referential': []}
+ for e in errors:
+ buckets[classify(e)].append(e)
+
+ digest = {
+ 'generated_at': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
+ 'counts': {
+ 'records': len(errors),
+ 'occurrences': sum(int(e.get('count') or 1) for e in errors),
+ 'real_records': len(buckets['real']),
+ 'noise_records': len(buckets['noise']),
+ 'self_referential_records': len(buckets['self-referential']),
+ 'reports_total': len(reports),
+ 'reports_new': len(new_reports),
+ },
+ 'top_real': sorted(
+ ({'message': safe(e.get('message'), 200), 'type': e.get('type'),
+ 'count': int(e.get('count') or 1), 'age_hours': age_hours(e.get('last_seen')),
+ 'session': safe(e.get('session_id'), 40)} for e in buckets['real']),
+ key=lambda x: -x['count'])[:10],
+ 'new_reports': [{'id': r.get('id'), 'title': safe(r.get('title'), 160),
+ 'created_at': r.get('created_at'),
+ 'age_hours': age_hours(r.get('created_at')),
+ 'body': safe(r.get('body'), 400)} for r in new_reports[:10]],
+ 'self_referential': [str(e.get('message'))[:120] for e in buckets['self-referential']],
+ 'checks': check_runner(),
+ }
+
+ if apply_fix:
+ os.makedirs(ARCHIVE, exist_ok=True)
+ moved = 0
+ cutoff = time.time() - 90 * 86400
+ for r in reports:
+ t = ts(r.get('created_at'))
+ if t and t.timestamp() < cutoff:
+ os.replace(r['_file'], os.path.join(ARCHIVE, os.path.basename(r['_file'])))
+ moved += 1
+ digest['counts']['reports_archived'] = moved
+
+ save(STATE, {'seen_reports': sorted(seen | {r.get('id') for r in new_reports if r.get('id')}),
+ 'last_run': digest['generated_at']})
+ save(os.path.join(OUTDIR, 'newos4-triage-latest.json'), digest)
+
+ c = digest['counts']
+ print(f"newOS4 triage {digest['generated_at']}: records={c['records']} "
+ f"occurrences={c['occurrences']} real={c['real_records']} noise={c['noise_records']} "
+ f"self={c['self_referential_records']} reports={c['reports_total']} new={c['reports_new']}")
+ for f in digest['checks']['findings']:
+ print('check:', f)
+ for e in digest['top_real'][:5]:
+ print(f" DATA| top: {e['count']}x {e['type']} ({e['age_hours']}h) {safe(e['message'], 110)}")
+ for r in digest['new_reports'][:5]:
+ print(f" DATA| report: {r['id']} {safe(r['title'], 110)}")
+ return digest
+
+
+if __name__ == '__main__':
+ ap = argparse.ArgumentParser()
+ ap.add_argument('--apply', action='store_true')
+ ap.add_argument('--json', action='store_true')
+ a = ap.parse_args()
+ d = build(apply_fix=a.apply)
+ if a.json:
+ print(json.dumps(d, indent=2))