diff --git a/.github/workflows/inneros-branch-ledger.yml b/.github/workflows/inneros-branch-ledger.yml new file mode 100644 index 00000000..0dc68223 --- /dev/null +++ b/.github/workflows/inneros-branch-ledger.yml @@ -0,0 +1,58 @@ +name: InnerOS Branch and Production Ledger + +on: + push: + branches: [main] + paths: + - ".github/workflows/inneros-branch-ledger.yml" + - "scripts/inneros_branch_ledger.py" + - "platform/tests/test_inneros_branch_ledger.py" + - "docs/INNEROS_RELEASE_ATTESTATION.json" + schedule: + # 12:00 UTC = 07:00 Ecuador, every day + - cron: "0 12 * * *" + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: inneros-canonical-branch-inventory + cancel-in-progress: true + +jobs: + audit: + if: github.repository == 'Rafa-Innerchispa/innerops-agentic-platform' + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Fetch trusted main only + uses: actions/checkout@v4 + with: + persist-credentials: false + fetch-depth: 1 + - name: Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Run offline regression tests + env: + PYTHONDONTWRITEBYTECODE: "1" + run: | + python -m unittest discover -s platform/tests -p 'test_inneros_branch_ledger.py' -v + - name: Inventory every branch and update only the ledger in main + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: Rafa-Innerchispa/innerops-agentic-platform + run: | + python scripts/inneros_branch_ledger.py --repo "$GITHUB_REPOSITORY" --workers 4 --publish + - name: Preserve full JSON and Markdown snapshot even if publication failed + if: always() + uses: actions/upload-artifact@v4 + with: + name: inneros-branch-ledger + path: | + inneros-branch-ledger.md + inneros-branch-ledger.json + if-no-files-found: warn + retention-days: 90 diff --git a/.github/workflows/inneros-runtime-gates.yml b/.github/workflows/inneros-runtime-gates.yml index 92af4693..f2a24884 100644 --- a/.github/workflows/inneros-runtime-gates.yml +++ b/.github/workflows/inneros-runtime-gates.yml @@ -6,6 +6,10 @@ on: - "platform/inneros_core_runtime/local_model_router.py" - "platform/raphiia_openai/__init__.py" - "platform/tests/test_local_model_router_fallback.py" + - "scripts/inneros_branch_ledger.py" + - "platform/tests/test_inneros_branch_ledger.py" + - ".github/workflows/inneros-branch-ledger.yml" + - "docs/INNEROS_RELEASE_ATTESTATION.json" - "platform/inneros_core_runtime/execution_binding.py" - "platform/inneros_core_runtime/capability_gateway.py" - "platform/inneros_core_runtime/capability_gateway_peer.py" @@ -41,6 +45,10 @@ on: - "platform/inneros_core_runtime/local_model_router.py" - "platform/raphiia_openai/__init__.py" - "platform/tests/test_local_model_router_fallback.py" + - "scripts/inneros_branch_ledger.py" + - "platform/tests/test_inneros_branch_ledger.py" + - ".github/workflows/inneros-branch-ledger.yml" + - "docs/INNEROS_RELEASE_ATTESTATION.json" - "platform/inneros_core_runtime/execution_binding.py" - "platform/inneros_core_runtime/capability_gateway.py" - "platform/inneros_core_runtime/capability_gateway_peer.py" @@ -95,6 +103,8 @@ jobs: platform/inneros_core_runtime/local_model_router.py \ platform/raphiia_openai/__init__.py \ platform/tests/test_local_model_router_fallback.py \ + scripts/inneros_branch_ledger.py \ + platform/tests/test_inneros_branch_ledger.py \ platform/inneros_core_runtime/execution_binding.py \ platform/inneros_core_runtime/capability_gateway.py \ platform/inneros_core_runtime/capability_gateway_peer.py \ @@ -123,6 +133,7 @@ jobs: platform/tests/test_global_coordination_guards.py \ platform/tests/test_execution_binding.py \ platform/tests/test_local_model_router_fallback.py \ + platform/tests/test_inneros_branch_ledger.py \ platform/tests/test_execution_receipts.py \ platform/tests/test_reconcile_ops_mongo_temporal.py \ platform/tests/test_governed_model_writes.py \ diff --git a/AGENTS.md b/AGENTS.md index b4dc74e9..8d6bb528 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,8 @@ > Vigente desde el 12 de octubre de 2026. Aplica a Cursor, Codex, Antigravity y agentes locales que trabajen en este repositorio. +**Inventario vivo de ramas y producción:** leer [docs/INNEROS_BRANCH_LEDGER.md](docs/INNEROS_BRANCH_LEDGER.md) (actualizado diariamente por GitHub Actions) y [docs/INNEROS_RELEASE_ATTESTATION.json](docs/INNEROS_RELEASE_ATTESTATION.json). Nunca considerar que main o CI implica despliegue. El auditor conserva ramas y NO las fusiona ni borra. + ## Al iniciar una sesión o reclamar trabajo 1. Leer **[platform/docs/INNEROS_COORDINATION_CONTINUITY_20261012.md](platform/docs/INNEROS_COORDINATION_CONTINUITY_20261012.md)**. Es el handoff actual; referencias de septiembre y manuales antiguos siguen como material histórico, no como autoridad de estado vivo. diff --git a/README.md b/README.md index 82374fc4..85a15361 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ ## Recovery and cross-agent continuity +**GitHub branch debt and production proof:** [automatic branch ledger](docs/INNEROS_BRANCH_LEDGER.md) and [per-host runtime attestation](docs/INNEROS_RELEASE_ATTESTATION.json). Updated by the daily InnerOS Branch and Production Ledger workflow. A merged branch is NOT evidence that AMD/Intel services imported that code. No auto-delete or blind merge. + + **Current active continuity (12 Oct 2026):** [`platform/docs/INNEROS_COORDINATION_CONTINUITY_20261012.md`](platform/docs/INNEROS_COORDINATION_CONTINUITY_20261012.md) and repository-wide [`AGENTS.md`](AGENTS.md). Agents must read this current handoff and verify live Mongo/Temporal/task leases/GitHub/runtime before accepting work. The [30 Sep handoff](docs/CANONICAL_CONTINUITY_HANDOFF_20260930.md) remains historical context, not current dispatch instructions. --- diff --git a/docs/INNEROS_RELEASE_ATTESTATION.json b/docs/INNEROS_RELEASE_ATTESTATION.json new file mode 100644 index 00000000..bd1d2de6 --- /dev/null +++ b/docs/INNEROS_RELEASE_ATTESTATION.json @@ -0,0 +1,29 @@ +{ + "schema": "inneros-runtime-host-attestation-v1", + "intent": "GitHub main does NOT mean AMD/Intel runtime deployed. Update only after an independent on-host executable code hash, commit version and E2E probe.", + "recorded_at_utc": "2026-10-12T15:00:00Z", + "hosts": { + "amd": { + "verified": false, + "runtime_git_sha": null, + "evidence_path": null, + "runtime_root": "/home/rlopez/inneros/inneros_core/platform", + "note": "LEP workspace HEAD bf43c6a5 observed; Cursor reported a7553157 at runtime earlier, source/process mapping not independently certified." + }, + "intel": { + "verified": false, + "runtime_git_sha": null, + "evidence_path": null, + "runtime_root": "/home/rlopez/inneros/inneros_core/platform", + "note": "Peer observability RC126 and workspace venv absence; no independently verified SHA for production import." + } + }, + "verification_contract": [ + "read-only actual process module paths and SHA256", + "exact main SHA pinned", + "compile and test suite exit codes", + "Temporal/Mongo task terminal", + "AMD and Intel canaries", + "backup and rollback provenance" + ] +} diff --git a/platform/docs/INNEROS_COORDINATION_CONTINUITY_20261012.md b/platform/docs/INNEROS_COORDINATION_CONTINUITY_20261012.md index 52114078..2cd4ab2a 100644 --- a/platform/docs/INNEROS_COORDINATION_CONTINUITY_20261012.md +++ b/platform/docs/INNEROS_COORDINATION_CONTINUITY_20261012.md @@ -3,6 +3,8 @@ **Destino:** documentación persistente en `main` de `Rafa-Innerchispa/innerops-agentic-platform`. **IMPORTANTE:** estados en este archivo son una instantánea, no certificación de que la situación seguirá igual. Recuperar telemetría viva antes de actuar. **No contiene credenciales.** +**Inventario vivo de código pendiente:** [docs/INNEROS_BRANCH_LEDGER.md](../../docs/INNEROS_BRANCH_LEDGER.md), actualizado automáticamente cada día por GitHub Actions, y [docs/INNEROS_RELEASE_ATTESTATION.json](../../docs/INNEROS_RELEASE_ATTESTATION.json), inicialmente NO_CERTIFICADO por nodo hasta evidencia host. El registro preserva todas las ramas; no debe confundirse CI/merge con producción. + ## 1. Objetivo del propietario (no perder) InnerOS debe tomar una orden en ChatGPT, voz o WhatsApp y **ejecutarla** usando el agente adecuado, interno o externo, sin intervención manual en una laptop, con cambios de código reales, verificación, posibilidad de rollback y cierre durable. No son suficientes mensajes ACK, modelos cargados, servicios activos, IDs `claimed` ni un PR fusionado. El propietario quiere un *único sistema* y no un router v2 ni otra instancia de Temporal. diff --git a/platform/tests/test_inneros_branch_ledger.py b/platform/tests/test_inneros_branch_ledger.py new file mode 100644 index 00000000..a6072e68 --- /dev/null +++ b/platform/tests/test_inneros_branch_ledger.py @@ -0,0 +1,64 @@ +"""Offline regression tests for branch preservation and truthful release flags.""" +import importlib.util +from pathlib import Path +import unittest +from unittest.mock import patch + +FILE = Path(__file__).resolve().parents[2] / "scripts" / "inneros_branch_ledger.py" +SPEC = importlib.util.spec_from_file_location("inneros_branch_ledger_under_test", FILE) +assert SPEC and SPEC.loader +mod = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(mod) + + +class InnerOSBranchLedgerTests(unittest.TestCase): + def test_unmerged_commits_are_not_considered_safe_to_merge(self): + candidate = {"name": "fix/valuable", "sha": "a" * 40, "ahead": 5, "behind": 50, "prs": []} + self.assertEqual(mod.classification(candidate), "REVISAR_SIN_PR") + candidate["prs"] = [119] + self.assertEqual(mod.classification(candidate), "PR_ABIERTO") + candidate["bulk_risk"] = True + self.assertEqual(mod.classification(candidate), "RIESGO_ALTO") + candidate["error"] = "API unavailable" + self.assertEqual(mod.classification(candidate), "ERROR") + + def test_branch_in_main_history_is_not_marked_as_missing(self): + self.assertEqual(mod.classification({"ahead": 0, "prs": [], "behind": 6}), "EN_HISTORIA_MAIN") + + def test_dangerous_bulk_deletion_is_flagged(self): + result = mod.summarize_compare({"ahead_by": 43, "behind_by": 337, + "files": [{"filename": "backups/deleted.py", "deletions": 216026, "additions": 0}]}) + self.assertEqual(result["ahead"], 43) + self.assertTrue(result["bulk_risk"]) + + def test_without_runtime_attestation_production_is_unknown(self): + result = mod.production_status("a" * 40, { + "hosts": {"amd": {"verified": False, "runtime_git_sha": "a" * 40}, + "intel": {"verified": False}}}) + self.assertEqual([r["status"] for r in result], ["NO_CERTIFICADO", "NO_CERTIFICADO"]) + + def test_attestation_mismatch_is_drift_not_deployed(self): + result = mod.production_status("a" * 40, { + "hosts": {"amd": {"verified": True, "runtime_git_sha": "b" * 40, + "evidence_path": "var/evidence/amd.json"}}}) + self.assertEqual(result[0]["status"], "DESFASADO") + + def test_report_enumerates_every_branch_including_legacy(self): + rows = [{"name": "chatgpt/r" + str(i), "sha": "a" * 40, + "prs": [], "ahead": 1, "behind": 7, "changed_files": 2, + "classification": "REVISAR_SIN_PR"} for i in range(263)] + report = {"repo": "Rafa-Innerchispa/innerops-agentic-platform", + "main_sha": "b" * 40, "generated_at": "2026-10-12T15:00:00Z", + "branches_total": 264, "prs": [], + "production": mod.production_status("b" * 40, {"hosts": {}}), + "rows": rows} + text = mod.render(report) + self.assertIn("chatgpt/r0", text) + self.assertIn("chatgpt/r262", text) + self.assertEqual(text.count("REVISAR_SIN_PR |"), 263) + self.assertIn("NO_CERTIFICADO", text) + self.assertNotIn("rm -rf", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/inneros_branch_ledger.py b/scripts/inneros_branch_ledger.py new file mode 100644 index 00000000..cbed8df1 --- /dev/null +++ b/scripts/inneros_branch_ledger.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""Read-only branch audit; optionally publish one generated Markdown file on main. + +Never merges, deletes, rebases, checks out, or executes a remote branch. +The only write on --publish is docs/INNEROS_BRANCH_LEDGER.md. +""" +from __future__ import annotations + +import argparse +import base64 +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import time +from urllib.error import HTTPError, URLError +from urllib.parse import quote +from urllib.request import Request, urlopen + +REPORT = "docs/INNEROS_BRANCH_LEDGER.md" +ATTESTATION = Path("docs/INNEROS_RELEASE_ATTESTATION.json") + + +def request(repo, endpoint, method="GET", payload=None, missing_ok=False): + token = os.environ.get("GITHUB_TOKEN") + if not token: + raise RuntimeError("GITHUB_TOKEN_required") + url = "https://api.github.com/repos/" + repo + "/" + endpoint.lstrip("/") + body = json.dumps(payload).encode() if payload is not None else None + headers = {"Accept": "application/vnd.github+json", + "Authorization": "Bearer " + token, + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "InnerOS-Branch-Ledger"} + if body is not None: + headers["Content-Type"] = "application/json" + for attempt in range(3): + try: + req = Request(url, data=body, method=method, headers=headers) + with urlopen(req, timeout=40) as response: + return json.load(response) + except HTTPError as exc: + if missing_ok and exc.code == 404: + return None + if exc.code not in (429, 502, 503, 504) or attempt == 2: + raise RuntimeError("GitHub_HTTP_" + str(exc.code) + "_" + endpoint[:80]) from exc + except (URLError, TimeoutError) as exc: + if attempt == 2: + raise RuntimeError("GitHub_unreachable_" + endpoint[:80]) from exc + time.sleep(2 ** attempt) + raise RuntimeError("request_failed") + + +def pages(repo, endpoint): + result = [] + for page in range(1, 100): + separator = "&" if "?" in endpoint else "?" + batch = request(repo, endpoint + separator + "per_page=100&page=" + str(page)) + if not isinstance(batch, list): + raise RuntimeError("unexpected_pagination_shape") + result.extend(batch) + if len(batch) < 100: + return result + raise RuntimeError("pagination_limit") + + +def summarize_compare(data): + files = data.get("files") or [] + deletions = sum(int(f.get("deletions") or 0) for f in files) + return {"ahead": int(data.get("ahead_by") or 0), + "behind": int(data.get("behind_by") or 0), + "changed_files": len(files), + "deletions": deletions, + "bulk_risk": len(files) >= 100 or deletions >= 5000, + "sample": [f.get("filename") for f in files[:12]]} + + +def classification(row): + if row.get("error"): + return "ERROR" + if not row.get("ahead"): + return "EN_HISTORIA_MAIN" + if row.get("bulk_risk"): + return "RIESGO_ALTO" + if row.get("prs"): + return "PR_ABIERTO" + return "REVISAR_SIN_PR" + + +def production_status(main_sha, attestations): + hosts = attestations.get("hosts") or {} + result = [] + for node in ("amd", "intel"): + entry = hosts.get(node) or {} + sha = entry.get("runtime_git_sha") + evidence = entry.get("evidence_path") + verified = entry.get("verified") is True and bool(sha and evidence) + status = "NO_CERTIFICADO" + if verified: + status = "ALINEADO" if sha == main_sha else "DESFASADO" + result.append({"node": node, "sha": sha, "evidence": evidence, + "status": status}) + return result + + +def audit(repo, workers=4): + main = request(repo, "branches/main") + main_sha = (main.get("commit") or {}).get("sha") + if not main_sha or len(main_sha) != 40: + raise RuntimeError("main_sha_invalid") + branches = pages(repo, "branches") + prs = pages(repo, "pulls?state=open") + pr_map = {} + open_prs = [] + for pr in prs: + head = pr.get("head") or {} + if ((head.get("repo") or {}).get("full_name")) != repo: + continue + name = head.get("ref") or "" + pr_map.setdefault(name, []).append(pr["number"]) + open_prs.append({"number": pr["number"], "head": name}) + unique_heads = {} + for branch in branches: + name = branch.get("name") or "" + sha = (branch.get("commit") or {}).get("sha") or "" + if name != "main" and sha != main_sha: + unique_heads.setdefault(sha, name) + + def compare(item): + sha, name = item + try: + encoded = quote(name, safe="") + value = request(repo, "compare/main..." + encoded) + if not isinstance(value, dict): + raise RuntimeError("compare_invalid_shape") + return sha, summarize_compare(value) + except RuntimeError as exc: + return sha, {"error": str(exc)} + + comparisons = {} + with ThreadPoolExecutor(max_workers=max(1, min(workers, 6))) as pool: + futures = [pool.submit(compare, x) for x in unique_heads.items()] + for future in as_completed(futures): + sha, data = future.result() + comparisons[sha] = data + rows = [] + for branch in branches: + name = branch.get("name") or "" + if name == "main": + continue + sha = (branch.get("commit") or {}).get("sha") or "" + diff = ({"ahead": 0, "behind": 0, "changed_files": 0} + if sha == main_sha else comparisons.get(sha, {"error": "comparison_missing"})) + row = {"name": name, "sha": sha, "prs": pr_map.get(name, []), **diff} + row["classification"] = classification(row) + rows.append(row) + attestations = json.loads(ATTESTATION.read_text(encoding="utf-8")) + return {"schema": "inneros-branch-ledger-v1", + "generated_at": datetime.now(timezone.utc).isoformat(), + "repo": repo, "main_sha": main_sha, + "branches_total": len(branches), + "prs": open_prs, + "production": production_status(main_sha, attestations), + "rows": rows, "errors": sum(bool(r.get("error")) for r in rows)} + + +def clean(value): + return str(value).replace("|", "/").replace("\n", " ").replace(chr(96), "'") + + +def render(report): + rows = report["rows"] + counts = {} + for row in rows: + k = row["classification"] + counts[k] = counts.get(k, 0) + 1 + lines = [ + "# InnerOS - Inventario permanente de ramas y produccion", + "", + "Generado automaticamente; no ejecuta ramas, ni fusiona ni elimina codigo.", + "Un commit adelantado no implica cambios utiles ausentes del main: revisar", + "equivalencia semantica, squash y cherry-pick antes de integrar.", + "", + "Auditoria UTC: " + report["generated_at"], + "Repositorio: " + report["repo"], + "main: " + report["main_sha"], + "Ramas incluyendo main: " + str(report["branches_total"]), + "PR abiertos: " + str(len(report["prs"])), + "Resumen: " + ", ".join(k + "=" + str(v) for k, v in sorted(counts.items())), + "", + "## Evidencia productiva (no inferir desde GitHub)", + "", + "| Nodo | Estado | SHA ejecutable probado | Evidencia host |", + "|---|---|---|---|", + ] + for host in report["production"]: + lines.append("| {node} | {status} | {sha} | {ev} |".format( + node=host["node"], status=host["status"], + sha=clean(host["sha"] or "sin evidencia"), + ev=clean(host["evidence"] or "sin evidencia"))) + lines.extend([ + "", + "## PR abiertos", + "", + "| PR | Rama |", + "|---|---|", + ]) + for pr in sorted(report["prs"], key=lambda p: p["number"], reverse=True): + url = "https://github.com/" + report["repo"] + "/pull/" + str(pr["number"]) + lines.append("| [#{number}]({url}) | {name} |".format( + number=pr["number"], url=url, name=clean(pr["head"]))) + lines.extend([ + "", + "## Todas las ramas: NO BORRAR antes de triage", + "", + "| Rama | Ahead | Behind | Estado | PR | Archivos |", + "|---|---:|---:|---|---|---:|", + ]) + priority = {"ERROR": 0, "RIESGO_ALTO": 1, + "PR_ABIERTO": 2, "REVISAR_SIN_PR": 3, "EN_HISTORIA_MAIN": 4} + ordered = sorted(rows, key=lambda r: (priority.get(r["classification"], 5), + -r.get("ahead", 0), r["name"])) + for row in ordered: + link = "https://github.com/" + report["repo"] + "/tree/" + quote(row["name"], safe="/") + label = clean(row["name"]) + prlist = ",".join("#" + str(n) for n in row["prs"]) or "-" + lines.append("| [{name}]({url}) | {ahead} | {behind} | {status} | {prs} | {files} |".format( + name=label, url=link, ahead=row.get("ahead", "?"), + behind=row.get("behind", "?"), status=row["classification"], + prs=prlist, files=row.get("changed_files", "?"))) + lines.extend([ + "", + "## Politica de rescate y publicacion", + "", + "1. No mezclar todas las ramas ni borrar ninguna por este inventario.", + "2. Revisar equivalencias de contenido; si hay trabajo nuevo, PR acotado.", + "3. CI PASS y merge main con responsables de archivos coordinados.", + "4. Desplegar SHA pinneado con backup, hashes de runtime y rollback.", + "5. Certificar AMD e Intel con pruebas operativas, no solo git HEAD.", + "", + "Los detalles JSON y las muestras de archivos estan en el artefacto", + "inneros-branch-ledger.json de cada ejecucion de GitHub Actions.", + "", + ]) + return "\n".join(lines) + + +def publish(repo, markdown): + endpoint = "contents/docs/INNEROS_BRANCH_LEDGER.md" + prev = request(repo, endpoint, missing_ok=True) + data = {"message": "chore(inneros): refresh automated branch-debt ledger", + "content": base64.b64encode(markdown.encode("utf-8")).decode("ascii"), + "branch": "main"} + if isinstance(prev, dict) and prev.get("sha"): + data["sha"] = prev["sha"] + result = request(repo, endpoint, method="PUT", payload=data) + if not isinstance(result, dict) or not (result.get("commit") or {}).get("sha"): + raise RuntimeError("publication_unconfirmed") + return result["commit"]["sha"] + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--repo", default=os.getenv("GITHUB_REPOSITORY", "")) + parser.add_argument("--workers", type=int, default=4) + parser.add_argument("--publish", action="store_true") + args = parser.parse_args() + if args.repo.count("/") != 1: + parser.error("--repo owner/name required") + report = audit(args.repo, args.workers) + text = render(report) + Path("inneros-branch-ledger.md").write_text(text + "\n", encoding="utf-8") + Path("inneros-branch-ledger.json").write_text( + json.dumps(report, indent=2) + "\n", encoding="utf-8") + print(json.dumps({"sha": report["main_sha"], "branches": report["branches_total"], + "prs": len(report["prs"]), "errors": report["errors"]})) + # Keep individual API comparison failures visible in main; one oversized + # legacy branch must not prevent inventorying all other branches. + # Fail closed if the API failed for too many refs to be meaningful. + max_errors = max(10, len(report["rows"]) // 4) + if report["errors"] > max_errors: + raise RuntimeError("branch_comparisons_unreliable; refusing_publication") + if report["errors"]: + print("::warning::Some branch comparisons failed; see ERROR rows in ledger") + if args.publish: + print("published_main_sha=" + publish(args.repo, text + "\n")) + + +if __name__ == "__main__": + main()