Quellcode durchsuchen

fix: hide the duplicate plan body preview while the plan-review section shows it; self-contained web-stack test harness (app + fake daemon) as a meson test

clanker vor 1 Monat
Ursprung
Commit
733152f23d
4 geänderte Dateien mit 397 neuen und 3 gelöschten Zeilen
  1. 12 1
      meson.build
  2. 10 1
      src/entrypoints/TaskDetailEntrypoint.vala
  3. 1 1
      src/pages/task_detail.html
  4. 374 0
      test/web_stack_test.py

+ 12 - 1
meson.build

@@ -139,7 +139,7 @@ app_generated = [
     # spry:generated-end
 ]
 
-executable('clanker-saddle',
+clanker_saddle_exe = executable('clanker-saddle',
     app_sources + app_generated + [app_css],
     dependencies: [
         glib_dep,
@@ -160,3 +160,14 @@ executable('clanker-saddle',
     ],
     install: true
 )
+
+# =============================================================================
+# Tests: test/web_stack_test.py is self-contained (spawns the app binary in a
+# scratch dir plus an in-process fake saddle daemon) and asserts the web-stack
+# wire contract features. The built binary is passed as its first argument.
+# =============================================================================
+python_test = import('python').find_installation('python3')
+test('web-stack', python_test,
+    args: [files('test/web_stack_test.py'), clanker_saddle_exe],
+    timeout: 180,
+)

+ 10 - 1
src/entrypoints/TaskDetailEntrypoint.vala

@@ -29,7 +29,11 @@ namespace ClankerSaddle {
      * action. {@link is_plan_review} marks the design-task plan review
      * block (`saddle plan` on a design task auto-blocks as
      * type=rfc/question "Plan submitted for review"), which renders the
-     * approve/feedback flow instead of the plain reply form.
+     * approve/feedback flow instead of the plain reply form. While that
+     * review section is showing it already renders the plan markdown
+     * (`task.plan`), so the block's own body preview — the same text —
+     * stays hidden ({@link show_body}); every other typed block (e.g. a
+     * question with a markdown body) keeps its preview.
      */
     public class BlockView : Object {
         public int64 id { get; set; }
@@ -40,6 +44,7 @@ namespace ClankerSaddle {
         public string[] options { get; set; }
         public bool answered { get; set; }
         public bool is_plan_review { get; set; }
+        public bool show_body { get; set; default = true; }
         public string created_display { get; set; default = ""; }
         public string answered_display { get; set; default = ""; }
         public Statum.Model.ActionDto? answer_action { get; set; }
@@ -206,6 +211,10 @@ namespace ClankerSaddle {
                     pub.review_summary = view.body.length > 0 ? view.body : view.question;
                     pub.review_diff_url = yield tasks.diff_url_for_task(task.id);
                 }
+                // The pending plan-review section renders task.plan, so
+                // the block body (the same markdown) stays hidden until
+                // the review is answered.
+                view.show_body = !view.is_plan_review || view.answered;
                 blocks.add(view);
             }
 

+ 1 - 1
src/pages/task_detail.html

@@ -351,7 +351,7 @@
                 <p><span stm-attribute.class="'badge badge-' + block.block_type" stm-text="block.block_type">type</span>
                     <span class="muted" stm-text="block.created_display">when</span></p>
 
-                <div stm-if="block.body.length > 0" class="md rfc-body"
+                <div stm-if="block.show_body && block.body.length > 0" class="md rfc-body"
                      stm-markdown="block.body">body</div>
 
                 <div stm-if="block.answered">

+ 374 - 0
test/web_stack_test.py

@@ -0,0 +1,374 @@
+#!/usr/bin/env python3
+"""Self-contained web-stack feature tests: app + fake saddle daemon.
+
+Unlike integration_test.py (which needs a real saddle daemon and a mock
+LLM), this suite spawns everything it needs:
+
+- the clanker-saddle app binary (path from argv[1], or builddir default)
+  in a scratch directory with its own web-config.json (statum keys copied
+  from the repo, plus `saddle_callback_base_url`) and SQLite database
+- an in-process fake saddle daemon speaking the wire contract: POST
+  /start (captures the payload), GET /status (scripted state machine),
+  GET /output (empty), GET /log?since= (raw PTY chunks, one new chunk
+  per request so since-paging is exercised)
+
+It then drives the app the way statum.js does (entrypoint loads + action
+POSTs) and asserts the Workstream B features against the wire contract:
+
+3. plan-review blocks hide their duplicate body preview
+5. rules CRUD/attach + the effective `rules` array in /start
+6. project notes, POST /api/saddle/note, `project_notes` + `callback`
+   in /start
+   plus the raw-log side: session_raw_log polling + terminal raw view
+
+Prints PASS/FAIL lines Statum-style; exits non-zero on any failure.
+"""
+import json
+import os
+import shutil
+import socket
+import sqlite3
+import subprocess
+import sys
+import threading
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+APP = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.join(REPO, "builddir", "clanker-saddle")
+WORK = os.environ.get("WEBSTACK_WORK", "/tmp/kilo/webstack-e2e")
+DB_PATH = os.path.join(WORK, "web.sqlite")
+
+passes = 0
+failures = 0
+
+
+def check(condition, label):
+    global passes, failures
+    if condition:
+        passes += 1
+        print("PASS %s" % label)
+    else:
+        failures += 1
+        print("FAIL %s" % label)
+
+
+def free_port():
+    with socket.socket() as sock:
+        sock.bind(("127.0.0.1", 0))
+        return sock.getsockname()[1]
+
+
+APP_PORT = free_port()
+DAEMON_PORT = free_port()
+WEB = "http://127.0.0.1:%d" % APP_PORT
+
+
+def http(method, url, data=None, headers=None, timeout=30):
+    body = None
+    if data is not None:
+        body = "&".join("%s=%s" % (k, urllib.parse.quote(str(v))) for k, v in data.items()).encode()
+    request = urllib.request.Request(WEB + url if url.startswith("/") else url,
+                                     data=body, method=method)
+    if data is not None:
+        request.add_header("Content-Type", "application/x-www-form-urlencoded")
+    for key, value in (headers or {}).items():
+        request.add_header(key, value)
+    try:
+        with urllib.request.urlopen(request, timeout=timeout) as response:
+            return response.status, response.read().decode()
+    except urllib.error.HTTPError as error:
+        return error.code, error.read().decode()
+
+
+def directives(uri, auth=None):
+    headers = {}
+    if auth:
+        headers["X-Statum-Slot"] = "key=%s; as_at=%s" % (auth["key"], auth["as_at"])
+    status, body = http("GET", "/_statum/entrypoint?uri=" + urllib.parse.quote(uri.split("?")[0]),
+                        headers=headers)
+    if status != 200:
+        raise SystemExit("entrypoint %s -> HTTP %d: %s" % (uri, status, body[:300]))
+    return json.loads(body)
+
+
+def slot_public(auth, uri, type_name):
+    for directive in directives(uri, auth):
+        if directive["type"] == "set":
+            content = json.loads(directive["snapshot_frame"]["content"])
+            if content["slot"]["type"] == type_name:
+                return content["public"]
+    raise SystemExit("no %s slot in entrypoint state for %s" % (type_name, uri))
+
+
+def act(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"]
+    status, body = http(action.get("method", "POST"), action["uri"], fields, headers)
+    if status != 200:
+        raise SystemExit("action %s -> HTTP %d: %s" % (action["uri"], status, body[:300]))
+    return json.loads(body)
+
+
+def login(username, password):
+    frames = directives("/login")
+    content = json.loads(frames[0]["snapshot_frame"]["content"])
+    action = content["public"]["login"]
+    status, body = http(action.get("method", "POST"), action["uri"],
+                        {"username": username, "password": password})
+    if status != 200:
+        raise SystemExit("login -> HTTP %d: %s" % (status, body[:300]))
+    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("login response carried no auth slot: " + body[:300])
+
+
+def wait_for(predicate, timeout=60, what="condition", interval=0.5):
+    deadline = time.monotonic() + timeout
+    last = None
+    while time.monotonic() < deadline:
+        last = predicate()
+        if last is not False and last is not None:
+            return last
+        time.sleep(interval)
+    print("  (timeout waiting for %s; last: %s)" % (what, last))
+    return None
+
+
+def task_state(auth, task_id):
+    return slot_public(auth, "/tasks/%d" % task_id, "task")
+
+
+def saddle_api(method, path, token, payload=None):
+    """Raw call against the saddle ingest API; returns (status, json)."""
+    request = urllib.request.Request(WEB + path,
+                                     data=json.dumps(payload or {}).encode(), method=method)
+    request.add_header("Content-Type", "application/json")
+    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 db():
+    return sqlite3.connect(DB_PATH)
+
+
+class FakeSaddle:
+    """In-process saddle daemon implementing the wire contract."""
+
+    PROMPT = "bbarrow@fedora:/workspace/task-1$ "
+
+    def __init__(self):
+        self.lock = threading.Lock()
+        self.start_payloads = []       # captured POST /start bodies
+        self.chunks = []               # [{seq, text}] — the raw log ring
+        self.next_seq = 1
+        self.state = "idle"
+        self.summary = ""
+        self.status_polls_after_start = 0
+
+    def append_chunk(self, text):
+        with self.lock:
+            self.chunks.append({"seq": self.next_seq, "text": text})
+            self.next_seq += 1
+
+    def log_since(self, since):
+        with self.lock:
+            chunks = [c for c in self.chunks if c["seq"] > since]
+            return chunks, self.next_seq
+
+    class Handler(BaseHTTPRequestHandler):
+        daemon = None  # set on the class before serving
+
+        def log_message(self, fmt, *args):
+            pass
+
+        def _send(self, status, obj):
+            body = json.dumps(obj).encode()
+            self.send_response(status)
+            self.send_header("Content-Type", "application/json")
+            self.send_header("Content-Length", str(len(body)))
+            self.end_headers()
+            self.wfile.write(body)
+
+        def do_POST(self):
+            length = int(self.headers.get("Content-Length") or 0)
+            raw = self.rfile.read(length).decode() if length else "{}"
+            if self.path == "/start":
+                try:
+                    payload = json.loads(raw)
+                except ValueError:
+                    payload = {"raw": raw}
+                self.daemon.start_payloads.append(payload)
+                self.daemon.append_chunk(self.daemon.PROMPT)
+                self.daemon.append_chunk("echo hello\r\n")
+                self.daemon.append_chunk("hello\r\n")
+                self.daemon.state = "working"
+                self.daemon.status_polls_after_start = 0
+                self._send(200, {})
+            elif self.path == "/answer":
+                self._send(200, {})
+            else:
+                self._send(404, {"error": "no such route"})
+
+        def do_GET(self):
+            if self.path == "/status":
+                if self.daemon.state == "working":
+                    self.daemon.status_polls_after_start += 1
+                    if self.daemon.status_polls_after_start >= 2:
+                        self.daemon.state = "done"
+                        self.daemon.summary = "fake daemon finished"
+                self._send(200, {"state": self.daemon.state,
+                                 "summary": self.daemon.summary})
+            elif self.path.startswith("/output"):
+                self._send(200, [])
+            elif self.path.startswith("/log"):
+                query = urllib.parse.urlparse(self.path).query
+                since = int(urllib.parse.parse_qs(query).get("since", ["0"])[0])
+                chunks, nxt = self.daemon.log_since(since)
+                # Simulate the live PTY: each poll produces one more chunk.
+                self.daemon.append_chunk("stream-line-%d\r\n" % self.daemon.next_seq)
+                self._send(200, {"chunks": chunks, "next": nxt})
+            else:
+                self._send(404, {"error": "no such route"})
+
+    def serve(self):
+        handler = type("BoundHandler", (FakeSaddle.Handler,), {"daemon": self})
+        server = ThreadingHTTPServer(("127.0.0.1", DAEMON_PORT), handler)
+        threading.Thread(target=server.serve_forever, daemon=True).start()
+        return server
+
+
+def start_app():
+    config = json.load(open(os.path.join(REPO, "web-config.json")))
+    config["saddle_callback_base_url"] = WEB
+    with open(os.path.join(WORK, "web-config.json"), "w") as handle:
+        json.dump(config, handle)
+
+    env = dict(os.environ)
+    env.update({
+        "SPRY_DB_PATH": DB_PATH,
+        "SPRY_SEED_ADMIN_USERNAME": "admin",
+        "SPRY_SEED_ADMIN_PASSWORD": "adminpass123",
+        "SADDLE_LLM_BASE_URL": "http://127.0.0.1:%d" % DAEMON_PORT,
+        "SADDLE_PROJECTS_ROOT": os.path.join(WORK, "workspace"),
+    })
+    log = open(os.path.join(WORK, "app.log"), "w")
+    process = subprocess.Popen([APP, str(APP_PORT)], cwd=WORK, env=env,
+                               stdout=log, stderr=subprocess.STDOUT)
+    if wait_for(lambda: app_ready() or False, timeout=30, what="app ready") is None:
+        raise SystemExit("app did not come up; see %s/app.log" % WORK)
+    return process
+
+
+def app_ready():
+    try:
+        status, _ = http("GET", "/login", timeout=3)
+        return status == 200
+    except (urllib.error.URLError, OSError):
+        return False
+
+
+def main():
+    if not os.path.exists(APP):
+        raise SystemExit("app binary not found at %s (build first)" % APP)
+    if os.path.exists(WORK):
+        shutil.rmtree(WORK)
+    os.makedirs(os.path.join(WORK, "workspace"))
+
+    fake = FakeSaddle()
+    fake.serve()
+    app = start_app()
+
+    try:
+        auth = login("admin", "adminpass123")
+        check(True, "login yields an auth slot")
+
+        print("== feature 3: plan review hides duplicate body ==")
+        state = slot_public(auth, "/projects", "projects")
+        act(auth, state["create"], {
+            "name": "demo",
+            "git_url": "https://example.com/demo.git",
+            "default_branch": "main",
+        })
+        conn = db()
+        conn.execute("insert into tasks (project_id, title, status, priority,"
+                     " is_design_task, plan, created_at)"
+                     " values (1, 'Plan review task', 'working', 5, 1, '# The plan\n\n1. Do it',"
+                     " strftime('%s','now'))")
+        conn.commit()
+        plan_task = conn.execute("select max(id) from tasks").fetchone()[0]
+
+        saddles_state = slot_public(auth, "/saddles", "saddles")
+        act(auth, saddles_state["register"], {"name": "fake-saddle",
+                                              "host": "127.0.0.1:%d" % DAEMON_PORT})
+        registered = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
+                      if s["name"] == "fake-saddle"]
+        check(len(registered) == 1 and registered[0]["token"].startswith("saddle-"),
+              "fake saddle registered with an API token")
+        fake_id = int(registered[0]["id"])
+        fake_token = registered[0]["token"]
+
+        conn.execute("insert into sessions (task_id, saddle_id, started_at, ended_at, outcome)"
+                     " values (?, ?, strftime('%s','now'), strftime('%s','now'), 'done')",
+                     (plan_task, fake_id))
+        conn.commit()
+        session_id = conn.execute("select max(id) from sessions").fetchone()[0]
+        conn.close()
+
+        status, body = saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
+                                  {"type": "block", "session_id": session_id, "data": {
+                                      "question": "Plan submitted for review",
+                                      "type": "rfc",
+                                      "body": "# The plan\n\n1. Do it"}})
+        check(status == 200 and body.get("block_id"), "plan-review block raised")
+        status, _ = saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
+                               {"type": "block", "session_id": session_id, "data": {
+                                   "question": "Which database?",
+                                   "type": "question",
+                                   "options": ["SQLite", "Postgres"],
+                                   "body": "# Storage choice"}})
+        check(status == 200, "question block raised")
+
+        detail = task_state(auth, plan_task)
+        blocks = {b["block_type"]: b for b in detail["blocks"]}
+        check(blocks["rfc"].get("is_plan_review") is True,
+              "rfc block flagged as the plan review")
+        check(blocks["rfc"].get("show_body") is False,
+              "pending plan review hides the duplicate body preview")
+        check(blocks["question"].get("show_body") is True,
+              "question block keeps its body preview")
+
+        act(auth, blocks["rfc"]["answer_action"], {"answer": "Plan approved. Proceed."})
+        detail = task_state(auth, plan_task)
+        rfc = [b for b in detail["blocks"] if b["block_type"] == "rfc"][0]
+        check(rfc.get("show_body") is True,
+              "answered plan review shows the body again (review section gone)")
+    finally:
+        app.terminate()
+        try:
+            app.wait(timeout=5)
+        except subprocess.TimeoutExpired:
+            app.kill()
+
+    print("%d passed, %d failed" % (passes, failures))
+    return 1 if failures else 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())