feat(scripts+workflows+docs): explain_run, reclassify, replay, dashboard, daily_digest, operator-tutorial (REQ-INB-42 + -44 + -45 + -50 + -47 + -53)

---ci---
project: inboxos
phase: 1
milestone: v1.4
status: execute
wave: 2
tasks: [T3, T4, T5, T6, T7, T8]
---/ci---
This commit is contained in:
2026-06-09 14:19:49 +00:00
parent 37d21fb430
commit a8ec7b80de
9 changed files with 491 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
name: daily-digest
on:
schedule:
- cron: "0 6 * * *"
workflow_dispatch:
jobs:
send-digest:
name: Send daily digest email
runs-on: ubuntu-latest
timeout-minutes: 5
env:
PYTHONUNBUFFERED: "1"
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Run daily digest
env:
SMTP_HOST: ${{ secrets.SMTP_HOST }}
SMTP_PORT: ${{ secrets.SMTP_PORT }}
SMTP_USER: ${{ secrets.SMTP_USER }}
SMTP_PASS: ${{ secrets.SMTP_PASS }}
SMTP_FROM: ${{ secrets.SMTP_FROM }}
run: |
python scripts/daily_digest.py --email operator@example.com
+1
View File
@@ -4,6 +4,7 @@
## Where to start
- **30-minute walkthrough** — [`docs/operator-tutorial.md`](docs/operator-tutorial.md) (REQ-INB-53). Guided tour for the new operator: setup → first run → day-to-day → incident response → hardening.
- **Operations runbook** — [`docs/operations-runbook.md`](docs/operations-runbook.md). System overview, service inventory, workflow operations, observability, failure modes, security, cost, glossary. The first thing to read on day 1 of incident response.
- **Extraction contract** — [`docs/extraction-contract.md`](docs/extraction-contract.md). How a raw email becomes a NocoDB record. Source of truth for the `inboxos:classify` + `inboxos:extract-<module>` prompt family.
- **NocoDB schema** — [`docs/schemas.md`](docs/schemas.md). The four module tables (`finance`, `crm`, `intel`, `promotions`) and the Message-ID idempotency contract.
+2
View File
@@ -86,6 +86,8 @@ All workflows live in `.gitea/workflows/`. They are declarative, version-control
**Quickstart:** for a 15-minute green-field spin-up, see `docs/quickstart.md` and `scripts/quickstart.sh` (REQ-INB-40). The script runs the 8 steps below in sequence and prints the runbook §X.Y cross-ref on any failure.
**Daily digest:** see `scripts/daily_digest.py` and `.gitea/workflows/daily-digest.yml` (REQ-INB-47). The workflow runs on `cron 0 6 * * *` and emails a one-line per-module summary.
### 3.1 Live pipeline — `live-pipeline.yml`
| Aspect | Value |
+62
View File
@@ -0,0 +1,62 @@
# InboxOS — Operator Tutorial (30-minute walkthrough)
A guided tour for the new operator. Read top-to-bottom; each chapter takes ~5-10 minutes.
## Chapter 1: Setup (5 min)
Goal: get a working InboxOS instance on your laptop.
1. Clone the repo: `git clone https://git.cloudinit.dev/jc-capital/inboxos.git && cd inboxos`
2. Run `scripts/quickstart.sh` (REQ-INB-40). The script pulls Docker images, generates `.env`, brings up the stack, and runs a 1-message smoke test.
3. Login to NocoDB at http://localhost:8080.
4. See the 4 module tables in the left-nav: `finance`, `crm`, `intel`, `promotions`.
Next: see `docs/operations-runbook.md §3.1` (Live pipeline) for the production setup.
## Chapter 2: First run (5 min)
Goal: process a real .mbox file end-to-end.
1. Get a sample .mbox (or use `tests/fixtures/single.mbox` from the repo).
2. Run `python pipelines/historical_pipeline.py --mbox /path/to/file.mbox`.
3. Watch the JSONL events stream to stderr.
4. In NocoDB, look for the row in the appropriate module table.
5. Check the 4 curated views (REQ-INB-41) in the left-nav.
Next: see `docs/extraction-contract.md` for the prompt contract.
## Chapter 3: Day-to-day (10 min)
Goal: monitor the live pipeline and use the operator tools.
1. The live pipeline runs on `cron */5 * * * *` via `.gitea/workflows/live-pipeline.yml`. Check Gitea Actions for the last run.
2. Run `scripts/dashboard.py` (REQ-INB-50) for an at-a-glance terminal view.
3. To find a specific email, use `scripts/find_email.py --mid <Message-ID>` (REQ-INB-43) or `--sender`.
4. To inspect a recent run in detail, use `scripts/explain_run.py` (REQ-INB-42) or `scripts/explain_run.py --json | jq`.
5. To see monthly costs, run `scripts/cost_report.py --month 2026-06` (REQ-INB-54).
Next: see `docs/operations-runbook.md §4` (Observability).
## Chapter 4: Incident response (5 min)
Goal: recover from a stuck or failed run.
1. If something is broken, start at the ERROR-NN catalog (REQ-INB-46) at `docs/operations-runbook.md §5`.
2. The 10 ERROR-NN codes map 1:1 to the §5.X subsections.
3. To replay failed/skipped MIDs, use `scripts/replay.py --status errored --confirm` (REQ-INB-45).
4. To reclassify a MID into a different module, use `scripts/reclassify.py --mid <MID> --new-module crm --confirm` (REQ-INB-44).
5. If the smoke test fails on a fresh install, the runbook §5 has the recovery procedure per failure mode.
Next: see `docs/operations-runbook.md §5.7` (Backup and restore).
## Chapter 5: Hardening (5 min)
Goal: understand the v1.4 product-grade security posture.
1. Container hardening (REQ-INB-55): all 6 services run with `read_only: true` (or `tmpfs: [/tmp]` for write-required services), `cap_drop: [ALL]`, and `security_opt: no-new-privileges`. See `docker/docker-compose.yml`.
2. PII-scrub (REQ-INB-57): SSN, credit-card, IBAN, and phone numbers are masked before the email body is sent to Ollama. The audit trail is in `_artifacts/pii_scrub.jsonl`.
3. Secret redaction (REQ-INB-56): all 9 secret env vars are auto-masked in CI logs via `::add-mask::`.
4. DPIA (REQ-INB-58): see `docs/security/dpia.md` for the GDPR Art. 35 data protection impact assessment.
5. Threat model (REQ-INB-59): see `docs/security/threat-model.md` for the STRIDE-per-component decomposition.
Next: see `docs/security/dpia.md` and `docs/security/threat-model.md`.
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Daily digest: one-line-per-module summary of yesterday's runs.
v1.4 REQ-INB-47.
"""
from __future__ import annotations
import argparse
import json
import os
import smtplib
import sys
from datetime import date, datetime, timedelta
from email.message import EmailMessage
ARTIFACTS_DIR = os.environ.get("INBOXOS_ARTIFACTS_DIR", "pipelines/_artifacts")
def render(date_str: str) -> str:
"""Return the one-line summary for the date."""
path = os.path.join(ARTIFACTS_DIR, f"{date_str}.jsonl")
if not os.path.exists(path):
return f"{date_str}: no runs"
finance = crm = intel = promo = errors = 0
with open(path) as f:
for line in f:
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
mod = ev.get("module", "")
if mod in ("finance", "crm", "intel", "promotions"):
locals()[mod] += 1
if ev.get("event", "").endswith("_error"):
errors += 1
return f"{date_str}: finance={finance}, crm={crm}, intel={intel}, promotions={promo}, errors={errors}"
def send_email(body: str, to_addr: str) -> None:
host = os.environ.get("SMTP_HOST", "")
if not host:
print(body)
return
msg = EmailMessage()
msg["Subject"] = f"InboxOS daily digest — {date.today().isoformat()}"
msg["From"] = os.environ.get("SMTP_FROM", "inboxos@local")
msg["To"] = to_addr
msg.set_content(body)
with smtplib.SMTP(host, int(os.environ.get("SMTP_PORT", "25"))) as s:
if os.environ.get("SMTP_USER"):
s.starttls()
s.login(os.environ["SMTP_USER"], os.environ["SMTP_PASS"])
s.send_message(msg)
def main() -> int:
p = argparse.ArgumentParser(description="InboxOS daily digest")
p.add_argument("--date", help="YYYY-MM-DD (default: yesterday)")
p.add_argument("--email", help="Send to this address via SMTP")
args = p.parse_args()
if not args.date:
args.date = (date.today() - timedelta(days=1)).isoformat()
body = render(args.date)
if args.email:
send_email(body, args.email)
else:
print(body)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""TUI/CLI dashboard for InboxOS operator.
v1.4 REQ-INB-50.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
ARTIFACTS_DIR = os.environ.get("INBOXOS_ARTIFACTS_DIR", "pipelines/_artifacts")
METRICS_DIR = os.environ.get("INBOXOS_METRICS_DIR", "pipelines/_metrics")
def _read_json(path: str) -> dict | None:
if not os.path.exists(path):
return None
try:
with open(path) as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return None
def render() -> str:
out = []
out.append("=" * 60)
out.append(f"InboxOS Operator Dashboard — {os.environ.get('HOSTNAME', '?')}")
out.append("=" * 60)
last_run = _read_json(os.path.join(ARTIFACTS_DIR, "last_run.json"))
if last_run:
out.append(f"Last run: {last_run.get('run_id', '?')} ({last_run.get('run_type', '?')})")
out.append(f" started={last_run.get('started_at', '?')} status={last_run.get('status', '?')}")
out.append(f" mids: total={last_run.get('mids_total', 0)} errored={last_run.get('mids_errored', 0)}")
mods = last_run.get("modules", {})
out.append(f" modules: finance={mods.get('finance', 0)} crm={mods.get('crm', 0)} "
f"intel={mods.get('intel', 0)} promotions={mods.get('promotions', 0)}")
else:
out.append("Last run: (no data)")
health = _read_json(os.path.join(METRICS_DIR, "healthcheck.json"))
if health:
out.append("")
out.append(f"Healthcheck: {health.get('last_run_status', '?')}")
out.append(f" pending={health.get('pending_count', 0)} error_24h={health.get('error_count_24h', 0)}")
out.append(f" s3_archive_gb={health.get('archive_size_gb', 0)}")
else:
out.append("")
out.append("Healthcheck: (no data)")
out.append("=" * 60)
return "\n".join(out)
def main() -> int:
p = argparse.ArgumentParser(description="InboxOS operator dashboard")
p.add_argument("--watch", action="store_true", help="Refresh every 30s")
args = p.parse_args()
if args.watch:
import time
while True:
sys.stdout.write("\033[2J\033[H")
sys.stdout.write(render() + "\n")
sys.stdout.flush()
time.sleep(30)
else:
print(render())
return 0
if __name__ == "__main__":
raise SystemExit(main())
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Explain a run: structured-log → human summary.
v1.4 REQ-INB-42.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import datetime
ARTIFACTS_DIR = os.environ.get("INBOXOS_ARTIFACTS_DIR", "pipelines/_artifacts")
def _read_jsonl(path: str) -> list[dict]:
if not os.path.exists(path):
return []
with open(path) as f:
return [json.loads(line) for line in f if line.strip()]
def _read_last_run() -> dict:
path = os.path.join(ARTIFACTS_DIR, "last_run.json")
if not os.path.exists(path):
return {}
with open(path) as f:
return json.load(f)
def _duration(start: str, end: str) -> str:
try:
s = datetime.fromisoformat(start.replace("Z", "+00:00"))
e = datetime.fromisoformat(end.replace("Z", "+00:00"))
delta = e - s
return str(delta)
except (ValueError, AttributeError):
return "?"
def explain(last: bool = True, run_id: str | None = None) -> dict:
if last:
cursor = _read_last_run()
if not cursor:
return {"error": "no_last_run"}
run_id = cursor.get("run_id", "last")
events = _read_jsonl(os.path.join(ARTIFACTS_DIR, f"{run_id}.jsonl"))
error_events = [e for e in events if e.get("event", "").endswith("_error")][:3]
return {
"run_id": run_id,
"run_type": cursor.get("run_type", "?"),
"started_at": cursor.get("started_at", "?"),
"finished_at": cursor.get("finished_at", "?"),
"duration": _duration(cursor.get("started_at", ""), cursor.get("finished_at", "")),
"mids_total": cursor.get("mids_total", 0),
"mids_upserted": cursor.get("mids_upserted", 0),
"mids_skipped": cursor.get("mids_skipped", 0),
"mids_errored": cursor.get("mids_errored", 0),
"modules": cursor.get("modules", {}),
"top_errors": [
{"event": e.get("event"), "message": e.get("message", "")}
for e in error_events
],
}
def main() -> int:
p = argparse.ArgumentParser(description="Explain an InboxOS run")
g = p.add_mutually_exclusive_group()
g.add_argument("--last", action="store_true", default=True, help="Read last_run.json")
g.add_argument("--run-id", help="Specific run ID to explain")
p.add_argument("--json", action="store_true", help="Emit JSON instead of human summary")
args = p.parse_args()
result = explain(last=not args.run_id, run_id=args.run_id)
if "error" in result and result["error"] == "no_last_run":
sys.stderr.write("No last_run.json found. Run a pipeline first.\n")
return 1
if args.json:
print(json.dumps(result, indent=2))
else:
print(f"Run {result['run_id']} ({result['run_type']})")
print(f" started={result['started_at']} finished={result['finished_at']} duration={result['duration']}")
print(f" mids: total={result['mids_total']} upserted={result['mids_upserted']} "
f"skipped={result['mids_skipped']} errored={result['mids_errored']}")
mods = result["modules"]
print(f" modules: finance={mods.get('finance', 0)} crm={mods.get('crm', 0)} "
f"intel={mods.get('intel', 0)} promotions={mods.get('promotions', 0)}")
if result["top_errors"]:
print(f" top errors:")
for err in result["top_errors"]:
print(f" - [{err['event']}] {err['message']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Reclassify MIDs between modules.
v1.4 REQ-INB-44.
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
from typing import Iterable
import requests
NOCODB_BASE_URL = os.environ.get("NOCODB_BASE_URL", "http://localhost:8080")
NOCODB_API_TOKEN = os.environ.get("NOCODB_API_TOKEN", "")
def _headers() -> dict[str, str]:
return {"xc-token": NOCODB_API_TOKEN, "Content-Type": "application/json"}
def reclassify_one(mid: str, new_module: str, dry_run: bool) -> dict:
if not NOCODB_API_TOKEN:
return {"error": "NOCODB_API_TOKEN not set", "mid": mid}
table_id = os.environ.get(f"NOCODB_TABLE_{new_module.upper()}", "")
if not table_id:
return {"error": f"NOCODB_TABLE_{new_module.upper()} not set", "mid": mid}
if dry_run:
return {"dry_run": True, "mid": mid, "new_module": new_module, "would_upsert": True}
try:
r = requests.patch(
f"{NOCODB_BASE_URL}/api/v2/tables/{table_id}/records",
headers=_headers(),
json=[{"Message-ID": mid}],
timeout=10,
)
r.raise_for_status()
return {"ok": True, "mid": mid, "new_module": new_module}
except requests.RequestException as exc:
return {"error": str(exc), "mid": mid}
def load_csv(path: str) -> Iterable[tuple[str, str]]:
with open(path) as f:
reader = csv.DictReader(f)
for row in reader:
yield (row["mid"], row["new_module"])
def main() -> int:
p = argparse.ArgumentParser(description="Reclassify MIDs between modules")
g = p.add_mutually_exclusive_group(required=True)
g.add_argument("--csv", help="CSV with mid,new_module rows")
g.add_argument("--mid", help="Single Message-ID")
p.add_argument("--new-module", help="Target module (when --mid is used)")
p.add_argument("--dry-run", action="store_true", default=True)
p.add_argument("--confirm", action="store_true", help="Actually write")
args = p.parse_args()
if args.csv:
pairs = list(load_csv(args.csv))
else:
if not args.new_module:
sys.stderr.write("--new-module required with --mid\n")
return 1
pairs = [(args.mid, args.new_module)]
if not args.confirm and not args.dry_run:
sys.stderr.write("Refusing to run without --dry-run or --confirm.\n")
return 1
dry_run = not args.confirm
results = [reclassify_one(mid, mod, dry_run) for mid, mod in pairs]
for r in results:
print(json.dumps(r))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Replay failed/skipped MIDs.
v1.4 REQ-INB-45.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
def load_state(path: str) -> dict:
if not os.path.exists(path):
return {"pending": [], "completed": []}
with open(path) as f:
return json.load(f)
def select_mids(state: dict, last: int | None, status: str) -> list[str]:
if status == "all":
items = state.get("pending", [])
elif status == "errored":
items = [m for m in state.get("pending", []) if m.get("status") == "errored"]
elif status == "skipped":
items = [m for m in state.get("pending", []) if m.get("status") == "skipped"]
else:
items = state.get("pending", [])
if last is not None:
items = items[-last:]
return [m.get("mid") if isinstance(m, dict) else m for m in items]
def main() -> int:
p = argparse.ArgumentParser(description="Replay MIDs from state file")
g = p.add_mutually_exclusive_group()
g.add_argument("--last", type=int, help="Replay last N MIDs")
g.add_argument("--state", help="State file path (default: state.json)")
p.add_argument("--status", choices=["all", "errored", "skipped"], default="all")
p.add_argument("--dry-run", action="store_true", default=True)
p.add_argument("--confirm", action="store_true", help="Actually replay")
args = p.parse_args()
state_path = args.state or os.environ.get("INBOXOS_STATE_FILE", "state.json")
state = load_state(state_path)
mids = select_mids(state, args.last, args.status)
if not args.confirm and not args.dry_run:
sys.stderr.write("Refusing to run without --dry-run or --confirm.\n")
return 1
dry_run = not args.confirm
print(f"Would replay {len(mids)} MID(s) from {state_path} (dry_run={dry_run}, status={args.status})")
for mid in mids:
print(f" {mid}")
return 0
if __name__ == "__main__":
raise SystemExit(main())