Browse Source

Merge branch 'goal/advisor' of clanker/clanker-saddle into web-stack

Billy Barrow 4 weeks ago
parent
commit
0792a1b31f

+ 1 - 1
MANIFEST.usm

@@ -1,6 +1,6 @@
 {
   "name": "clanker-saddle",
-  "version": "1.0",
+  "version": "1.1",
   "summary": "ClankerSaddle — LLM task orchestration platform (Statum + Spry)",
   "licences": [],
   "flags": ["ninjaStyleProgress"],

+ 1 - 1
meson.build

@@ -1,5 +1,5 @@
 project('clanker-saddle', ['c', 'vala'],
-  version: '1.0',
+  version: '1.1',
 )
 
 glib_dep = dependency('glib-2.0')

+ 29 - 12
src/Services/CanonService.vala

@@ -181,9 +181,12 @@ namespace ClankerSaddle.Services {
         /**
          * The resolved canon directory: $CLANKER_CANON_DIR wins, then
          * the installed <exe>/../share/clanker-saddle/canon, then the
-         * source tree (<exe>/../../canon, <exe>/../canon, ./canon).
-         * Empty when nothing resolves — the endpoints then report an
-         * absent canon.
+         * absolute system shares (/usr/share, /usr/local/share — the
+         * packaged layout where /proc/self/exe's neighbours are not
+         * the install prefix, e.g. a container image with the binary
+         * copied elsewhere), then the source tree (<exe>/../../canon,
+         * <exe>/../canon, ./canon). Empty when nothing resolves — the
+         * endpoints then report an absent canon.
          */
         public string directory() {
             if (directory_cache != null) {
@@ -195,11 +198,33 @@ namespace ClankerSaddle.Services {
                 directory_cache = override.strip();
                 return directory_cache;
             }
+            foreach (var candidate in candidates()) {
+                if (GLib.FileUtils.test(candidate, GLib.FileTest.IS_DIR)) {
+                    directory_cache = candidate;
+                    return directory_cache;
+                }
+            }
+            directory_cache = "";
+            return directory_cache;
+        }
+
+        /**
+         * Every candidate directory the resolver tries, in order —
+         * the override aside (it wins first and never appears here).
+         * The startup health line and the empty-shelf warning both
+         * name these paths so a deploying admin can see exactly
+         * where the app looked.
+         */
+        public string[] candidates() {
             var exe_dir = exe_directory();
-            var candidates = new string[] {
+            return {
                 // installed: <prefix>/bin/clanker-saddle + share/…
                 GLib.Path.build_filename(exe_dir, "..", "share",
                     "clanker-saddle", "canon"),
+                // packaged layout where the binary's neighbours are not
+                // the install prefix: the absolute system shares
+                "/usr/share/clanker-saddle/canon",
+                "/usr/local/share/clanker-saddle/canon",
                 // build dir run (builddir sits beside src/)
                 GLib.Path.build_filename(exe_dir, "..", "..", "canon"),
                 GLib.Path.build_filename(exe_dir, "..", "canon"),
@@ -207,14 +232,6 @@ namespace ClankerSaddle.Services {
                 // a bare ./canon next to the cwd (tests, dev runs)
                 "canon"
             };
-            foreach (var candidate in candidates) {
-                if (GLib.FileUtils.test(candidate, GLib.FileTest.IS_DIR)) {
-                    directory_cache = candidate;
-                    return directory_cache;
-                }
-            }
-            directory_cache = "";
-            return directory_cache;
         }
 
         /**

+ 12 - 0
src/entrypoints/HomeEntrypoint.vala

@@ -134,6 +134,13 @@ namespace ClankerSaddle {
          * with a notice when false (goal 27).
          */
         public bool advisor_configured { get; set; }
+        /**
+         * The canon text count (goal 27 deploy hardening): 0 means no
+         * canon directory resolved — the hero's muted line under the
+         * composer then says so, so a deploying admin sees the empty
+         * shelf on the dashboard instead of only in the logs.
+         */
+        public int64 canon_texts { get; set; }
         /**
          * Starts a new advisor chat from the dashboard's hero
          * composer (goal 27): creates the chat, records the message
@@ -364,6 +371,11 @@ namespace ClankerSaddle {
             // disable itself with a notice when no endpoint is set.
             pub.advisor_configured =
                 Services.AdvisorLlmEndpoint.from_environment() != null;
+            // The canon shelf count (same static resolution shape):
+            // a fresh Canon per build is fine — its scan cache is
+            // per-instance and the endpoint objects do the same.
+            var canon = new Services.Canon();
+            pub.canon_texts = canon.texts().length;
             // Web Push bell state (plan 11 task E; per-browser since
             // the user-global flag lied to second browsers): the VAPID
             // key the browser needs, whether this user subscribed

+ 36 - 0
src/main.vala

@@ -292,6 +292,8 @@ int main(string[] args) {
             return false;
         });
 
+        log_canon_health();
+
         application.run();
         return 0;
     } catch (Error e) {
@@ -349,3 +351,37 @@ private async void start_advisor_callback(Container container) {
             e.message);
     }
 }
+
+/**
+ * The canon health line (goal 27 deploy hardening): the resolved
+ * directory and the text count at startup, or — when nothing
+ * resolves — a LOUD warning naming every candidate path tried and
+ * the CLANKER_CANON_DIR override, so an empty canon shelf is
+ * visible in the container logs without asking the bot. The same
+ * fact rides the dashboard's public data as `canon_texts`.
+ */
+private void log_canon_health() {
+    var canon = new ClankerSaddle.Services.Canon();
+    var directory = canon.directory();
+    if (directory.length > 0) {
+        print("[ClankerSaddle] Canon: %d texts from %s\n",
+            canon.texts().length, directory);
+        var errors = canon.errors();
+        if (errors.length > 0) {
+            printerr("[ClankerSaddle] WARNING: canon at %s carries %d "
+                + "unparseable file(s): %s\n", directory, errors.length,
+                string.joinv("; ", errors));
+        }
+        return;
+    }
+    var candidates = canon.candidates();
+    printerr("[ClankerSaddle] WARNING: no canon directory resolved — the "
+        + "canonical texts will be ABSENT (/api/saddle/canon answers an "
+        + "empty library and the ethos has nothing to point at). Tried:\n");
+    foreach (var candidate in candidates) {
+        printerr("[ClankerSaddle]   %s\n", candidate);
+    }
+    printerr("[ClankerSaddle] Set CLANKER_CANON_DIR to the directory "
+        + "holding the canon *.md files (or install the "
+        + "share/clanker-saddle/canon package files).\n");
+}

+ 3 - 0
src/pages/home.html

@@ -274,6 +274,9 @@
             <p class="notice" stm-if="!dashboard.advisor_configured">The advisor is not
                 configured - set <code>ADVISOR_LLM_BASE_URL</code> (or
                 <code>SADDLE_LLM_BASE_URL</code>) to enable it.</p>
+            <p class="notice" stm-if="dashboard.canon_texts == 0">Canon unavailable -
+                no canonical texts found. Set <code>CLANKER_CANON_DIR</code> or install
+                the <code>share/clanker-saddle/canon</code> package files.</p>
         </section>
 
         <h2>Dashboard</h2>

+ 361 - 0
test/bundles/canon_deploy.py

@@ -0,0 +1,361 @@
+"""The canon deploy-hardening bundle (goal 27, after the third review
+rejection "the canon never gets installed"): the class of miss was NOT
+the install rules or the endpoints — a `meson install --destdir` image
+boots and answers the canon API — it was that the running instance had
+the canon CODE but no canon DIR (a stale cached package: MANIFEST.usm
+1.0 pre-dated canon/, so `usm manifest deploy` satisfied the deploy from
+an old artifact). This bundle pins the deploy-time truths:
+
+- the packaged archive REALLY carries the canon files (a tar inspection
+  of the .usm package -- skipped when usm is absent)
+- the DEPLOYED layout resolves: `meson install --destdir` with a /usr
+  prefix, then the app booted FROM THAT TREE (binary at
+  <tmp>/usr/bin/clanker-saddle) in a cwd with NO ./canon and no
+  CLANKER_CANON_DIR answers /api/saddle/canon with the 15 texts and
+  serves one document by slug — the exe-relative installed share is the
+  resolver's first non-override candidate
+- the startup health line names the resolved directory and count (and
+  the empty-shelf shape logs every candidate + the override)
+- MANIFEST.usm/meson.build versions moved past 1.0 (the stale-package
+  class: a re-deploy must build a fresh artifact)
+
+Mind the machine: XDG_DATA_DIRS must include /usr/share (GLib schemas),
+web-config.json exists only through `statum-genkeys --out` (a bare
+redirect corrupts it), and the deployed app runs OUTSIDE the harness's
+WORK dir with its own scratch db and its own port so nothing collides
+with the primary shard app.
+"""
+
+import os
+import shutil
+import subprocess
+import tarfile
+import tempfile
+import urllib.error
+import urllib.request
+
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+
+
+def http_raw(base, method, path, token=None):
+    """A raw call against an arbitrary base URL (the deployed tree runs
+    on its own port, not the harness's WEB)."""
+    request = urllib.request.Request(base + path, method=method)
+    if token is not None:
+        request.add_header("Authorization", "Bearer " + token)
+    try:
+        with urllib.request.urlopen(request, timeout=15) as response:
+            return response.status, json.loads(response.read().decode() or "{}")
+    except urllib.error.HTTPError as error:
+        body = error.read().decode()
+        try:
+            return error.code, json.loads(body)
+        except ValueError:
+            return error.code, {"raw": body}
+
+
+def deploy_tree(target):
+    """`meson install` the existing build with a /usr prefix into
+    <target>/usr; returns (ok, log tail). The builddir may carry meson's
+    default /usr/local prefix (a fresh `meson setup` does) — pin /usr so
+    the deployed layout is exactly an image-style install: binary at
+    <target>/usr/bin, canon at <target>/usr/share/clanker-saddle/canon.
+    The reconfigure regenerates but never rebuilds, and the prefix never
+    affects the test-run binary's location."""
+    configure = subprocess.run(
+        ["meson", "configure", os.path.join(REPO, "builddir"), "-Dprefix=/usr"],
+        capture_output=True, text=True, timeout=120)
+    if configure.returncode != 0:
+        print(configure.stdout[-1000:])
+        print(configure.stderr[-1000:])
+        return False, "meson configure -Dprefix=/usr failed"
+    command = ["meson", "install", "-C", os.path.join(REPO, "builddir"),
+               "--destdir", target]
+    done = subprocess.run(command, capture_output=True, text=True, timeout=300)
+    if done.returncode != 0:
+        print(done.stdout[-2000:])
+        print(done.stderr[-2000:])
+        return False, "meson install failed"
+    return True, done.stdout[-500:]
+
+
+def boot_deployed(root, port):
+    """Boots the DEPLOYED binary (root/usr/bin/clanker-saddle) in a cwd
+    with no ./canon; returns the process handle (caller terminates)."""
+    run_dir = os.path.join(root, "run")
+    os.makedirs(run_dir, exist_ok=True)
+    config = json.load(open(os.path.join(REPO, "web-config.json")))
+    with open(os.path.join(run_dir, "web-config.json"), "w") as handle:
+        json.dump(config, handle)
+    env = dict(os.environ)
+    env.update({
+        "SPRY_DB_PATH": os.path.join(root, "deploy.sqlite"),
+        "SPRY_SEED_ADMIN_USERNAME": "admin",
+        "SPRY_SEED_ADMIN_PASSWORD": "adminpass123",
+        # A registered saddle's bearer token is needed for the canon
+        # routes; the deployed app shares nothing with the primary
+        # shard, so point its ingest at the shard's fake daemon — its
+        # /register handshake delivers a token we can read from the
+        # primary app's /saddles slot.  The SADDLE_* knobs keep the
+        # poller quiet while the shard tears down.
+        "SADDLE_LLM_BASE_URL": "http://127.0.0.1:%d" % DAEMON_PORT,
+        "SADDLE_HTTP_TIMEOUT_S": "2",
+        "SADDLE_DISPATCH_TICK_S": "1",
+        "LD_LIBRARY_PATH": "",
+        "XDG_DATA_DIRS": "/usr/share",
+    })
+    env.pop("CLANKER_CANON_DIR", None)
+    log = open(os.path.join(root, "deploy-app.log"), "w")
+    process = subprocess.Popen(
+        [os.path.join(root, "usr", "bin", "clanker-saddle"), str(port)],
+        cwd=run_dir, env=env, stdout=log, stderr=subprocess.STDOUT)
+    return process
+
+
+def wait_boot(base, process, timeout=30):
+    def ready():
+        if process.poll() is not None:
+            return "exited-%d" % process.poll()
+        try:
+            with urllib.request.urlopen(base + "/login", timeout=3) as response:
+                return response.status == 200 and "ready" or False
+        except (urllib.error.URLError, OSError):
+            return False
+    return wait_for(ready, timeout=timeout, what="deployed app ready")
+
+
+def register_saddle(base, auth, name, host):
+    """Registers a saddle THROUGH the deployed app (the canon routes
+    need its own bearer token) and returns the token."""
+    state = slot_public_on(base, auth, "/saddles", "saddles")
+    act_on(base, auth, state["register"], {"name": name, "host": host})
+    rows = [s for s in slot_public_on(base, auth, "/saddles", "saddles")["saddles"]
+            if s["name"] == name]
+    return rows[0]["token"]
+
+
+def act_on(base, auth, action, fields):
+    headers = {"X-Statum-Slot": "key=%s; as_at=%s" % (auth["key"], auth["as_at"])}
+    if action.get("private"):
+        headers["X-Statum-Private"] = action["private"]
+    request = urllib.request.Request(
+        base + action.get("uri", ""),
+        data=urllib.parse.urlencode(fields).encode(), method="POST")
+    request.add_header("Content-Type", "application/x-www-form-urlencoded")
+    for key, value in headers.items():
+        request.add_header(key, value)
+    with urllib.request.urlopen(request, timeout=15) as response:
+        return json.loads(response.read().decode() or "{}")
+
+
+def slot_public_on(base, auth, uri, type_name):
+    request = urllib.request.Request(
+        base + "/_statum/entrypoint?uri=" + urllib.parse.quote(uri.split("?")[0]))
+    request.add_header("X-Statum-Slot",
+                       "key=%s; as_at=%s" % (auth["key"], auth["as_at"]))
+    with urllib.request.urlopen(request, timeout=15) as response:
+        frames = json.loads(response.read().decode())
+    for directive in frames:
+        if directive["type"] == "set":
+            content = directive["snapshot_frame"].get("content")
+            if not content:
+                continue
+            content = json.loads(content)
+            if content["slot"]["type"] == type_name:
+                return content["public"]
+    raise SystemExit("no %s slot in deployed entrypoint state for %s"
+                     % (type_name, uri))
+
+
+def login_on(base, username, password, timeout=20):
+    """Login against an arbitrary base URL. The deployed app seeds its
+    admin from a GLib Idle callback, so /login answering 200 does NOT
+    mean the seed landed — poll until the auth slot arrives."""
+    deadline = time.monotonic() + timeout
+    last_body = ""
+    while time.monotonic() < deadline:
+        request = urllib.request.Request(base + "/_statum/entrypoint?uri=%2Flogin")
+        with urllib.request.urlopen(request, timeout=15) as response:
+            frames = json.loads(response.read().decode())
+        content = json.loads(frames[0]["snapshot_frame"]["content"])
+        action = content["public"]["login"]
+        request = urllib.request.Request(
+            base + action.get("uri", ""),
+            data=urllib.parse.urlencode(
+                {"username": username, "password": password}).encode(),
+            method="POST")
+        request.add_header("Content-Type", "application/x-www-form-urlencoded")
+        try:
+            with urllib.request.urlopen(request, timeout=15) as response:
+                body = response.read().decode()
+        except urllib.error.HTTPError as error:
+            last_body = error.read().decode()
+            time.sleep(0.2)
+            continue
+        for directive in json.loads(body):
+            if directive["type"] == "set":
+                frame = json.loads(directive["snapshot_frame"]["content"])
+                if frame["slot"]["type"] == "auth":
+                    return {"key": frame["slot"]["key"], "as_at": frame["as_at"]}
+    raise SystemExit("deployed login response carried no auth slot (last: %s)"
+                     % last_body[:300])
+
+
+def run(ctx):
+    auth, fake, fake_token = ctx.auth, ctx.fake, ctx.fake_token
+
+    print("== deploy: the manifest versions moved past 1.0 ==")
+    manifest = json.load(open(os.path.join(REPO, "MANIFEST.usm")))
+    check(manifest.get("version") != "1.0"
+          and manifest.get("version") == "1.1",
+          "MANIFEST.usm carries the bumped version 1.1 (%s)" % manifest.get("version"))
+
+    print("== deploy: the packaged archive carries the canon ==")
+    # `usm manifest package` writes ../<name>-<version>.usmc relative to
+    # the repo. Building it here keeps the check honest (a stale cached
+    # package is exactly the miss class this bundle pins).
+    usm = shutil.which("usm")
+    archive = None
+    if usm is None:
+        print("  (usm not on PATH — asserting the install rule instead)")
+    else:
+        done = subprocess.run([usm, "manifest", "package"],
+                              cwd=REPO, capture_output=True, text=True, timeout=600)
+        if done.returncode != 0:
+            print(done.stdout[-1000:])
+            print(done.stderr[-1000:])
+        check(done.returncode == 0, "usm manifest package succeeds")
+        archive = os.path.join(os.path.dirname(REPO),
+                               "clanker-saddle-%s.usmc" % manifest["version"])
+        check(os.path.exists(archive),
+              "the .usmc lands beside the repo (%s)" % archive)
+        if os.path.exists(archive):
+            with tarfile.open(archive) as bundle:
+                names = bundle.getnames()
+                canon_files = [n for n in names
+                               if n.startswith("./canon/") and n.endswith(".md")]
+                check(len(canon_files) >= 15,
+                      "the archive carries the 15 canon .md files (%d)"
+                      % len(canon_files))
+                check(any(n.endswith("gnu-manifesto.md") for n in canon_files)
+                      and any(n.endswith("magnifica-humanitas-summary.md")
+                              for n in canon_files),
+                      "the archive's canon includes the anchor texts")
+                # The version INSIDE the archive must be the bumped one
+                # (the stale-artifact class: an old 1.0 archive would
+                # satisfy a deploy with no canon at all).
+                member = bundle.extractfile("./MANIFEST.usm")
+                inside = json.loads(member.read().decode())
+                check(inside.get("version") == manifest["version"],
+                      "the archive's MANIFEST.usm carries version %s (%s)"
+                      % (manifest["version"], inside.get("version")))
+
+    print("== deploy: the deployed /usr layout serves the canon ==")
+    root = tempfile.mkdtemp(prefix="canon-deploy-")
+    deployed_port = free_port()
+    deployed_base = "http://127.0.0.1:%d" % deployed_port
+    ok, _ = deploy_tree(root)
+    check(ok, "meson install --destdir <tmp>/usr with a /usr prefix")
+    binary = os.path.join(root, "usr", "bin", "clanker-saddle")
+    share = os.path.join(root, "usr", "share", "clanker-saddle", "canon")
+    check(os.path.exists(binary), "the installed binary is at <tmp>/usr/bin")
+    installed_md = [n for n in (os.listdir(share) if os.path.isdir(share) else [])
+                    if n.endswith(".md")]
+    check(len(installed_md) >= 15,
+          "the installed share/clanker-saddle/canon carries 15 texts (%d)"
+          % len(installed_md))
+    if not ok or not os.path.exists(binary):
+        shutil.rmtree(root, ignore_errors=True)
+        return
+
+    process = boot_deployed(root, deployed_port)
+    try:
+        outcome = wait_boot(deployed_base, process)
+        check(outcome == "ready",
+              "the deployed tree boots from a canon-less cwd (%s)" % outcome)
+        if outcome != "ready":
+            return
+        deployed_auth = login_on(deployed_base, "admin", "adminpass123")
+        token = register_saddle(deployed_base, deployed_auth,
+                                "deploy-probe", "127.0.0.1:%d" % fake.port)
+        check(bool(token), "a saddle registered through the deployed app")
+
+        status, body = http_raw(deployed_base, "GET", "/api/saddle/canon", token)
+        check(status == 200 and body.get("ok") is True,
+              "GET /api/saddle/canon answers ok from the deployed tree")
+        texts = body.get("texts") or []
+        check(body.get("count") == 15 and len(texts) == 15,
+              "the deployed canon lists all 15 texts (%s)" % body.get("count"))
+        check(os.path.abspath(body.get("source_dir") or "") == share,
+              "the deployed canon resolved the installed share dir (%s)"
+              % body.get("source_dir"))
+        status, doc = http_raw(deployed_base, "GET",
+                               "/api/saddle/canon/free-software-definition", token)
+        check(status == 200 and doc.get("ok") is True
+              and doc.get("slug") == "free-software-definition"
+              and "freedom" in (doc.get("body") or "").lower(),
+              "the deployed canon serves one document by slug")
+
+        print("== deploy: the startup health line ==")
+        log_text = open(os.path.join(root, "deploy-app.log")).read()
+        health = [line for line in log_text.splitlines()
+                  if "Canon: 15 texts from" in line]
+        logged = (health[0].split(" from ", 1)[1].strip()
+                  if health else "")
+        check(bool(health) and os.path.normpath(logged) == share,
+              "the startup log names the resolved canon dir and count (%s)"
+              % (health[:1] or ["absent"]))
+
+        print("== deploy: the hero carries the canon count ==")
+        dash = slot_public_on(deployed_base, deployed_auth, "/", "dashboard")
+        check(int(dash.get("canon_texts", -1)) == 15,
+              "the dashboard's public data carries canon_texts = 15 (%s)"
+              % dash.get("canon_texts"))
+    finally:
+        process.terminate()
+        try:
+            process.wait(timeout=5)
+        except subprocess.TimeoutExpired:
+            process.kill()
+
+    print("== deploy: the empty shelf is loud ==")
+    # Boot the deployed binary in a cwd with no ./canon, no override,
+    # and the installed share moved AWAY — the loud warning naming every
+    # candidate is the deploying admin's only symptom, so it must be in
+    # the log. (The absolute /usr/share and /usr/local/share candidates
+    # do not exist on this machine — no install outside the tmpdir.)
+    empty_root = tempfile.mkdtemp(prefix="canon-empty-")
+    shutil.move(share, os.path.join(empty_root, "canon-parked"))
+    empty_port = free_port()
+    process = boot_deployed(root, empty_port)
+    try:
+        empty_base = "http://127.0.0.1:%d" % empty_port
+        outcome = wait_boot(empty_base, process)
+        check(outcome == "ready", "the canon-less deployed tree still boots (%s)"
+              % outcome)
+        log_text = open(os.path.join(root, "deploy-app.log")).read()
+        check("no canon directory resolved" in log_text
+              and "CLANKER_CANON_DIR" in log_text,
+              "the empty shelf logs the loud warning naming the override")
+        check("/usr/share/clanker-saddle/canon" in log_text
+              and "/usr/local/share/clanker-saddle/canon" in log_text,
+              "the warning names the absolute system share candidates")
+        empty_auth = login_on(empty_base, "admin", "adminpass123")
+        dash = slot_public_on(empty_base, empty_auth, "/", "dashboard")
+        check(int(dash.get("canon_texts", -1)) == 0,
+              "the canon-less dashboard reports canon_texts = 0 (%s)"
+              % dash.get("canon_texts"))
+        token = register_saddle(empty_base, empty_auth,
+                                "empty-probe", "127.0.0.1:%d" % fake.port)
+        status, body = http_raw(empty_base, "GET", "/api/saddle/canon", token)
+        check(status == 200 and body.get("count") == 0,
+              "the canon-less API answers an empty library, not an error")
+    finally:
+        process.terminate()
+        try:
+            process.wait(timeout=5)
+        except subprocess.TimeoutExpired:
+            process.kill()
+        shutil.move(os.path.join(empty_root, "canon-parked"), share)
+        shutil.rmtree(empty_root, ignore_errors=True)
+        shutil.rmtree(root, ignore_errors=True)

+ 2 - 1
test/harness.py

@@ -1325,7 +1325,8 @@ BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
                  "replay", "goalui", "forcewake", "users", "stacks", "notes",
                  "resurrect", "origins", "parity", "understanding",
                  "discoveries", "knowledge_cycle", "advisor", "chatui",
-                 "advisor_arc", "advisor_fix", "advisor_refine", "canon")
+                 "advisor_arc", "advisor_fix", "advisor_refine", "canon",
+                 "canon_deploy")
 
 
 def run_bundle(name):