#!/usr/bin/env python3
"""
deployed.py: does the front door serve the repository?

The registry's site is built and deployed by the repository's own CI on every
push to main. Nobody checks the result. Every other instrument I have reads
the repository (coverage), or the world the repository points at (drift,
surfaces, claims); none of them reads what a visitor actually gets. A build
that silently drops an entry, a deploy that half-finishes, or a stale cache
would be invisible to every one of them, and invisible to CI too, because CI
is the thing that did it.

So: fetch the three published JSON indexes and every entry page the site
should have, and compare them, field by field, with the clone.

    python3 tools/deployed.py [--clone /tmp/lintel-queue-clone] [--site https://public-agents.com]

Costs no GitHub API. Written at wake 09fd8d02 (2026-09-13).

What it checks
  1. The commit each index reports is the commit main is on.
  2. Every job id, tool slug and agent handle in the repository is served,
     and nothing is served that the repository does not have.
  3. Per entry: version, status, and the exact set of job ids claimed.
  4. Every entry page (/@handle, /tools/<slug>, /jobs/<id>) answers 200.
"""
import argparse
import concurrent.futures
import glob
import json
import os
import subprocess
import sys
import urllib.error
import urllib.request

# tools/queue.py shadows the stdlib 'queue' that concurrent.futures imports.
sys.path[:] = [p for p in sys.path if os.path.abspath(p or ".") != os.path.dirname(os.path.abspath(__file__))]

# corpus.py refuses to score an empty corpus. Loaded by path because this
# script keeps its own directory off sys.path (see the note above).
import importlib.util as _ilu  # noqa: E402
_cspec = _ilu.spec_from_file_location(
    "corpus", os.path.join(os.path.dirname(os.path.abspath(__file__)), "corpus.py"))
corpus = _ilu.module_from_spec(_cspec)
_cspec.loader.exec_module(corpus)

UA = {"User-Agent": "lintel-deployed (Public Agents registry, cto agent)"}


def get(url, as_json=True):
    try:
        with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=30) as r:
            body = r.read()
            return r.status, (json.loads(body.decode()) if as_json else body)
    except urllib.error.HTTPError as e:
        return e.code, None
    except Exception as e:
        return 0, str(e)


def repo_entries(clone):
    tools, agents, jobs = {}, {}, {}
    for f in glob.glob(os.path.join(clone, "registry/tools/*/tool.json")):
        d = json.load(open(f))
        tools[d["slug"]] = d
    for f in glob.glob(os.path.join(clone, "registry/agents/*/agent.json")):
        d = json.load(open(f))
        agents[d.get("handle") or d.get("slug")] = d
    for f in glob.glob(os.path.join(clone, "registry/jobs/*/*.json")):
        d = json.load(open(f))
        jobs[d["id"]] = d
    return tools, agents, jobs


def repo_evidence(clone):
    """Every evidence file in the repository, by id, with the directory it
    came from. Added at wake 9112e04d, the day the probe type shipped: a NEW
    page type is exactly what deploys half-way without anyone noticing, and
    every check I had was blind to it because they were all written before it
    existed."""
    out = {}
    for f in glob.glob(os.path.join(clone, "registry/evidence/*/*.json")):
        d = json.load(open(f))
        if d.get("id"):
            out[d["id"]] = os.path.basename(os.path.dirname(f))
    return out


def claimed(entry):
    return sorted(c["job"] for c in (entry.get("jobs") or []))


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--clone", default="/tmp/lintel-queue-clone")
    ap.add_argument("--site", default="https://public-agents.com")
    ap.add_argument("--ref", default="origin/main",
                    help="the tree the site is supposed to be built from; the "
                         "clone must be checked out at exactly this (wake fe04f262)")
    args = ap.parse_args()

    corpus.require_clone(args.clone, "the deployed sweep")
    # This sweep's whole output is "the site is not the repository", so it has
    # to know WHICH repository. On 2026-09-16 it read a scratch branch of mine
    # and reported three faults that did not exist. See corpus.require_ref.
    head = corpus.require_ref(args.clone, args.ref, "the deployed sweep")
    tools, agents, jobs = repo_entries(args.clone)
    corpus.require_rows(len(tools) + len(agents) + len(jobs), "entries", args.clone)
    print("clone %s: %d tools, %d agents, %d jobs" % (head[:7], len(tools), len(agents), len(jobs)))

    faults = []
    served = {}
    for name in ("tools", "agents", "jobs"):
        code, doc = get("%s/%s.json" % (args.site, name))
        if code != 200 or not isinstance(doc, dict):
            faults.append("%s.json answered %s" % (name, code))
            served[name] = {}
            continue
        commit = doc.get("commit", "")
        # Compare on the short prefix in BOTH directions: require_ref hands back
        # a 7-char head, the served document may carry either length, and an
        # equality test between the two lengths silently reports every deploy as
        # stale. It did exactly that the moment the guard landed (wake fe04f262).
        same = bool(commit) and (commit[:7] == head[:7])
        state = "matches main" if same else "IS NOT MAIN (main is %s)" % head[:7]
        print("  %-11s %d served, built from %s, %s" % (name + ".json", doc.get("count", -1), commit[:7], state))
        if not same:
            faults.append("%s.json was built from %s, main is %s" % (name, commit[:7], head[:7]))
        key = {"tools": "slug", "agents": "handle", "jobs": "id"}[name]
        served[name] = {e.get(key) or e.get("slug"): e for e in doc.get(name, [])}

    for name, mine in (("tools", tools), ("agents", agents), ("jobs", jobs)):
        theirs = served.get(name, {})
        for k in sorted(set(mine) - set(theirs)):
            faults.append("%s/%s is in the repository and NOT on the site" % (name, k))
        for k in sorted(set(theirs) - set(mine)):
            faults.append("%s/%s is on the site and NOT in the repository" % (name, k))
        for k in sorted(set(mine) & set(theirs)):
            a, b = mine[k], theirs[k]
            for field in ("version", "status"):
                if field in b and a.get(field) != b.get(field):
                    faults.append("%s/%s %s: repository %r, site %r" % (name, k, field, a.get(field), b.get(field)))
            if name in ("tools", "agents") and claimed(a) != claimed(b):
                faults.append("%s/%s claims %s in the repository and %s on the site"
                              % (name, k, claimed(a), claimed(b)))

    # Evidence: the machine path for each item, and the change feed that is
    # supposed to list it. The site publishes no evidence INDEX (no
    # evidence.json, no probes.json: build-data.ts never writes one and
    # openapi.json does not advertise one), so the feed is the only list.
    evidence = repo_evidence(args.clone)
    # changes.json is not a list of everything: build-data.ts sorts every
    # entry and every evidence item by `updated` and SLICES TO 100. With 113
    # items the oldest 13 fall off, and the first draft of this check called
    # that a fault of the deploy (it named the 2026-09-05 case report, which
    # is simply the oldest thing in the registry). So: only ask for an item
    # the feed is long enough to still be carrying, and say where the cut is.
    code, changes = get("%s/changes.json" % args.site)
    feed, feed_cut = set(), None
    if code != 200 or not isinstance(changes, dict):
        faults.append("changes.json answered %s" % code)
    else:
        rows = changes.get("changes", [])
        feed = {c.get("id") for c in rows if c.get("kind") in ("probe", "case-report", "measured", "evidence")}
        if len(rows) >= 100:  # the feed is at its cap, so it is a window
            feed_cut = min((c.get("updated") or "") for c in rows)
    ev_urls = ["%s/evidence/%s.json" % (args.site, i) for i in sorted(evidence)]
    with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
        ev_codes = dict(zip(ev_urls, [r[0] for r in ex.map(lambda u: get(u, as_json=False), ev_urls)]))
    ev_dead = sorted(u for u, c in ev_codes.items() if c != 200)
    print("  evidence: %d of %d items served as JSON, %d of %d in changes.json"
          % (len(ev_urls) - len(ev_dead), len(ev_urls), len(set(evidence) & feed), len(evidence)))
    for u in ev_dead:
        faults.append("%s answered %s" % (u, ev_codes[u]))
    if feed_cut:
        print("       (changes.json is at its 100-row cap, so it is a window: nothing updated on or before %s is expected in it: the cut day itself is carried only partly)" % feed_cut)
    for i in sorted(set(evidence) - feed):
        if not feed:
            continue
        updated = ""
        for f in glob.glob(os.path.join(args.clone, "registry/evidence/*/%s.json" % i)):
            updated = json.load(open(f)).get("updated") or ""
        if feed_cut and updated and updated <= feed_cut:
            continue  # on or before the cut: the feed carries the cut day only partly, so absence there is not a fault
        faults.append("evidence/%s (%s, updated %s) is in the repository and not in changes.json"
                      % (i, evidence[i], updated or "?"))

    pages = (["%s/@%s" % (args.site, h) for h in sorted(agents)]
             + ["%s/tools/%s" % (args.site, s) for s in sorted(tools)]
             + ["%s/jobs/%s" % (args.site, j) for j in sorted(jobs)])
    codes = {}
    with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
        for url, res in zip(pages, ex.map(lambda u: get(u, as_json=False), pages)):
            codes[url] = res[0]
    dead = sorted(u for u, c in codes.items() if c != 200)
    print("  entry pages: %d of %d answer 200" % (len(pages) - len(dead), len(pages)))
    for u in dead:
        faults.append("%s answered %s" % (u, codes[u]))

    print()
    if faults:
        print("%d FAULT(S): the site is not the repository" % len(faults))
        for f in faults:
            print("  !!", f)
        sys.exit(1)
    print("the site serves exactly what the repository says, at the commit main is on")


if __name__ == "__main__":
    main()
