aboutsummaryrefslogtreecommitdiff
path: root/scripts/newos4-feedback-apply.py
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-26 13:03:11 +0100
committerAgando <roduccomm@gmail.com>2026-10-26 13:03:11 +0100
commita72e86bcb11d379289e02d868065f4dc86d0f23e (patch)
treec5e0202766c935c5b62b29e629b9d4d755772f59 /scripts/newos4-feedback-apply.py
parent5b05af91fa3fcc9ae7aff00feedc26d062babbc2 (diff)
feat(feedback): review dashboard, public guide, terminal commands (v4.6.94)
Dashboard (/admin/feedback.html, admin-gated) - User-feedback tab over the live :8908 API: list, filter, search, publish reply, archive/reopen. Reply is what the reporter sees in /stear -> Your reports & replies. - Agent-items tab: 3 plans parked at autonomy:review + 19 defect reports, one click each. - Snapshot age line + stale warning (>30 min), and the last apply outcome with failures. - Existing /admin/ tab relabelled "Client error log" (it renders feedback_log.json, not the feedback store) and now links to the review dashboard. Decision path - admin/api/feedback-decide.php: exact-match nginx location + auth_request, Origin check, item_id allowlist, append-only decisions.jsonl with fwrite+fsync verification. - scripts/newos4-feedback-apply.py (darkbot): last decision per item wins, superseded rows recorded, reject reverts safe->review, per-item try/except, ledger saved per mutation, realpath containment, exact standalone-line marker flip, file mode preserved, queue rotation at 1 MB. Steering directives now carry the report title. - scripts/newos4-feedback-collect.py: snapshot for the dashboard, 40-line plan head, steering shape assert (-1 = unknown), recent_apply outcomes. - newos4-feedback-sync.sh: flock -n so overlapping runs cannot double-apply. Guide + terminal - feedback-guide.html: which channel (terminal feedback / /stear / automatic reports), expectations, exact payload per channel, how to write a report that gets fixed. Linked from /stear, index.html footer and help.html. - newOS4: feedback list|read|reply|archive|reopen|stats|dashboard|help (+ feedback-admin alias), admin-token derived per call, argument-validated dispatch so a sentence like "feedback list is broken on mobile" is still a message, "feedback -- <text>" forces it. - help.html: 8 rows, coverage 362/362. Review: glm-5.3 REWORK -> 15 findings fixed, 5 refuted with evidence, 2 doc-only. Guide corrected (invented "60 MB per batch" replaced by the real 512 KB/5-per-10-min limits).
Diffstat (limited to 'scripts/newos4-feedback-apply.py')
-rwxr-xr-xscripts/newos4-feedback-apply.py203
1 files changed, 203 insertions, 0 deletions
diff --git a/scripts/newos4-feedback-apply.py b/scripts/newos4-feedback-apply.py
new file mode 100755
index 0000000..38188fb
--- /dev/null
+++ b/scripts/newos4-feedback-apply.py
@@ -0,0 +1,203 @@
+#!/usr/bin/env python3
+"""Apply queued operator decisions from data/feedback/decisions.jsonl.
+
+Runs as darkbot (cron). Mutates exactly two things, both assert-guarded:
+ approve + plan -> flip the exact '<!-- autonomy: review -->' line to '<!-- autonomy: safe -->'
+ reject + plan -> flip it back (an approval that was reconsidered before the night run)
+ approve + report -> append a high-priority directive via scripts/newos4-steer.py add
+Every decision is recorded in data/feedback/applied.json (darkbot-owned, 0600) so nothing is
+applied twice and php never needs write access to the sources.
+
+Ordering rules (2026-10-26 review):
+ * the LAST decision per item wins — approve followed by reject must not ship the plan;
+ * a decision that is superseded in the same run is recorded, not executed;
+ * one failing item never aborts the run, and applied.json is written after every mutation.
+"""
+import json, os, re, subprocess, sys
+from datetime import datetime, timezone
+
+ROOT = '/var/www/webx11.duckdns.org'
+FB = os.path.join(ROOT, 'spaces/newos4/data/feedback')
+DEC = os.path.join(FB, 'decisions.jsonl')
+APPLIED = os.path.join(FB, 'applied.json')
+STEER = os.path.join(ROOT, 'spaces/newos4/scripts/newos4-steer.py')
+PLANS = os.path.join(ROOT, '.hermes/plans')
+REPORTS = os.path.join(ROOT, 'spaces/newos4/data/reports')
+ROTATE_BYTES = 1024 * 1024 # move decisions.jsonl aside once it passes 1 MB
+
+
+def load_applied():
+ try:
+ with open(APPLIED, encoding='utf-8') as f:
+ d = json.load(f)
+ return d if isinstance(d, dict) else {}
+ except Exception:
+ return {}
+
+
+def save_applied(d):
+ os.makedirs(FB, exist_ok=True)
+ tmp = APPLIED + '.tmp'
+ with open(tmp, 'w', encoding='utf-8') as f:
+ json.dump(d, f, indent=1, ensure_ascii=False)
+ os.chmod(tmp, 0o600) # www-data must not be able to wipe the ledger
+ os.replace(tmp, APPLIED)
+
+
+def read_decisions():
+ rows = []
+ if not os.path.exists(DEC):
+ return rows
+ with open(DEC, encoding='utf-8', errors='ignore') as f:
+ for i, line in enumerate(f):
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ d = json.loads(line)
+ except Exception:
+ continue # torn write: skip, never crash the run
+ d['_row'] = i
+ rows.append(d)
+ return rows
+
+
+def _marker_count(src):
+ return sum(1 for l in src.splitlines() if l.strip() == '<!-- autonomy: review -->')
+
+
+def _flip_marker(path, src, old, new, note):
+ """Flip one standalone marker LINE (not a substring) and keep the file mode."""
+ lines = src.splitlines(keepends=True)
+ hits = [i for i, l in enumerate(lines) if l.strip() == old]
+ if len(hits) != 1:
+ return None, 'error: expected 1 standalone %s line, found %d' % (old, len(hits))
+ i = hits[0]
+ indent = lines[i][:len(lines[i]) - len(lines[i].lstrip())]
+ eol = '\n' if lines[i].endswith('\n') else ''
+ lines[i] = '%s%s%s' % (indent, new, eol)
+ stamp = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%MZ')
+ body = ''.join(lines)
+ if not body.endswith('\n'):
+ body += '\n'
+ body += '\n<!-- %s by operator via /admin/feedback.html %s%s -->\n' % (
+ 'approved' if new.endswith('safe -->') else 'reverted',
+ stamp, (' — ' + note.replace('-->', '')) if note else '')
+ return body, None
+
+
+def apply_plan(name, decision, note):
+ plans_dir = os.path.realpath(PLANS)
+ path = os.path.realpath(os.path.join(plans_dir, name))
+ if os.path.dirname(path) != plans_dir:
+ return 'error: refused, outside plans dir'
+ if not re.fullmatch(r'[A-Za-z0-9._-]+\.md', os.path.basename(path)):
+ return 'error: refused, bad plan name'
+ try:
+ src = open(path, encoding='utf-8').read()
+ mode = os.stat(path).st_mode & 0o777
+ except Exception as e:
+ return 'error: unreadable (%s)' % e
+
+ if decision == 'approve':
+ if _marker_count(src) != 1:
+ return 'error: expected 1 review marker, found %d' % _marker_count(src)
+ body, err = _flip_marker(path, src, '<!-- autonomy: review -->', '<!-- autonomy: safe -->', note)
+ elif decision == 'reject':
+ # only meaningful if a previous run already approved it
+ if src.count('<!-- autonomy: safe -->') == 0:
+ return 'recorded (no action: was never approved)'
+ body, err = _flip_marker(path, src, '<!-- autonomy: safe -->', '<!-- autonomy: review -->', note)
+ else:
+ return 'recorded (no action)'
+ if err or body is None:
+ return err or 'error: no output produced'
+ tmp = path + '.tmp'
+ with open(tmp, 'w', encoding='utf-8') as f:
+ f.write(body)
+ os.chmod(tmp, mode) # os.replace would otherwise apply the umask (600 -> 644)
+ os.replace(tmp, path)
+ return 'plan %s (%s, mode %o)' % ('approved' if decision == 'approve' else 'reverted', decision, mode)
+
+
+def report_title(rid):
+ """The bare id is useless in a directive once the report file rotates away."""
+ try:
+ with open(os.path.join(REPORTS, rid + '.json'), encoding='utf-8') as f:
+ d = json.load(f)
+ return str(d.get('title') or d.get('message') or rid)[:160]
+ except Exception:
+ return rid
+
+
+def apply_report(name, decision, note):
+ if decision != 'approve':
+ return 'recorded (no action)'
+ text = '%s — %s' % (report_title(name), name)
+ if note:
+ text = '%s — %s' % (text, note)
+ r = subprocess.run([sys.executable, STEER, 'add', text, '--priority', 'high'],
+ capture_output=True, text=True, timeout=60)
+ if r.returncode != 0:
+ return 'error: steer add exit %d (%s)' % (r.returncode, (r.stderr or '')[-120:])
+ return 'steering directive added'
+
+
+def main():
+ applied = load_applied()
+ rows = read_decisions()
+ if not rows:
+ return 0
+
+ # last decision per item wins; everything earlier in the file is superseded
+ last_row = {}
+ for d in rows:
+ last_row[d.get('item_id', '')] = d['_row']
+
+ done = 0
+ for d in rows:
+ item = d.get('item_id', '')
+ dec = d.get('decision', '')
+ note = d.get('note', '') or ''
+ key = '%s|%s|%s' % (d.get('ts', ''), item, dec)
+ if key in applied:
+ continue
+ kind, _, name = item.partition(':')
+
+ if last_row.get(item) != d['_row']:
+ applied[key] = {'item_id': item, 'decision': dec, 'result': 'superseded by a later decision',
+ 'applied_at': datetime.now(timezone.utc).isoformat(timespec='seconds')}
+ save_applied(applied)
+ continue
+
+ try:
+ if kind == 'plan':
+ result = apply_plan(name, dec, note)
+ elif kind == 'report':
+ result = apply_report(name, dec, note)
+ else:
+ result = 'error: unknown item kind'
+ except subprocess.TimeoutExpired:
+ result = 'error: steer add timed out'
+ except Exception as e: # one bad item must not replay the batch
+ result = 'error: %s: %s' % (type(e).__name__, e)
+
+ applied[key] = {'item_id': item, 'decision': dec, 'result': result,
+ 'applied_at': datetime.now(timezone.utc).isoformat(timespec='seconds')}
+ save_applied(applied)
+ done += 1
+ print(('APPLY FAILED %s: %s' if result.startswith('error') else 'applied %s -> %s (%s)')
+ % ((item, result) if result.startswith('error') else (item, dec, result)))
+
+ # keep the queue file bounded; applied.json stays as the permanent ledger
+ try:
+ if os.path.getsize(DEC) > ROTATE_BYTES:
+ os.replace(DEC, DEC + '.1')
+ print('rotated decisions.jsonl -> decisions.jsonl.1 (%d bytes)' % ROTATE_BYTES)
+ except Exception:
+ pass
+ return 0
+
+
+if __name__ == '__main__':
+ raise SystemExit(main())