integration_test.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. #!/usr/bin/env python3
  2. """End-to-end integration test: ClankerSaddle web app + saddle daemon.
  3. Drives the web app the way statum.js does (entrypoint state loads plus
  4. action POSTs carrying the auth slot and sealed private blobs) while a
  5. real saddle daemon works on a real git checkout under WORKSPACE/demo,
  6. powered by saddle/test/mock_llm.py with a deterministic three-task
  7. script: an explore task (bash + comment + done), a dependency-gated
  8. follow-up, and a blocking question answered through the web UI.
  9. Prerequisites (see README.md "Integration testing"):
  10. - the web app running with SPRY_SEED_ADMIN_USERNAME=admin,
  11. SPRY_SEED_ADMIN_PASSWORD=adminpass123, SADDLE_LLM_BASE_URL pointing at
  12. the mock (below), SADDLE_PROJECTS_ROOT=$WORKSPACE
  13. - a saddle daemon reachable at SADDLE
  14. - a git repo with at least two commits at $WORKSPACE/demo
  15. Environment:
  16. WEB web app base URL (default http://localhost:8080)
  17. SADDLE daemon control URL (default http://localhost:7100)
  18. SADDLE_HOST host:port to register (default localhost:7100)
  19. SADDLE_NAME registration name (default saddle-e2e-1)
  20. WORKSPACE demo repo parent dir (default /tmp/clanker-e2e/workspace)
  21. DB_PATH web app SQLite file (for audit assertions)
  22. MOCK mock_llm.py path (default <repo>/../saddle/test/mock_llm.py)
  23. MOCK_PORT local mock port (default 9999)
  24. MOCK_CMD run instead of the local mock (e.g. a podman exec line
  25. that starts the mock inside the saddle container); the
  26. script skips spawning the mock when set
  27. Prints PASS/FAIL lines Statum-style; exits non-zero on any failure.
  28. """
  29. import json
  30. import os
  31. import subprocess
  32. import sqlite3
  33. import sys
  34. import time
  35. import urllib.error
  36. import urllib.parse
  37. import urllib.request
  38. REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  39. WEB = os.environ.get("WEB", "http://localhost:8080")
  40. SADDLE = os.environ.get("SADDLE", "http://localhost:7100")
  41. SADDLE_HOST = os.environ.get("SADDLE_HOST", "localhost:7100")
  42. SADDLE_NAME = os.environ.get("SADDLE_NAME", "saddle-e2e-1")
  43. WORKSPACE = os.environ.get("WORKSPACE", "/tmp/clanker-e2e/workspace")
  44. DB_PATH = os.environ.get("DB_PATH", os.path.join(WORKSPACE, "..", "web", "clanker-saddle.sqlite"))
  45. MOCK = os.environ.get("MOCK", os.path.join(os.path.dirname(REPO), "saddle", "test", "mock_llm.py"))
  46. MOCK_PORT = os.environ.get("MOCK_PORT", "9999")
  47. MOCK_CMD = os.environ.get("MOCK_CMD", "")
  48. DEMO = os.path.join(WORKSPACE, "demo")
  49. passes = 0
  50. failures = 0
  51. def check(condition, label):
  52. global passes, failures
  53. if condition:
  54. passes += 1
  55. print("PASS %s" % label)
  56. else:
  57. failures += 1
  58. print("FAIL %s" % label)
  59. def http(method, url, data=None, headers=None, timeout=30):
  60. body = None
  61. if data is not None:
  62. body = "&".join("%s=%s" % (k, urllib.parse.quote(str(v))) for k, v in data.items()).encode()
  63. request = urllib.request.Request(WEB + url if url.startswith("/") else url,
  64. data=body, method=method)
  65. if data is not None:
  66. request.add_header("Content-Type", "application/x-www-form-urlencoded")
  67. for key, value in (headers or {}).items():
  68. request.add_header(key, value)
  69. try:
  70. with urllib.request.urlopen(request, timeout=timeout) as response:
  71. return response.status, response.read().decode()
  72. except urllib.error.HTTPError as error:
  73. return error.code, error.read().decode()
  74. def directives(uri, auth=None):
  75. headers = {}
  76. if auth:
  77. headers["X-Statum-Slot"] = "key=%s; as_at=%s" % (auth["key"], auth["as_at"])
  78. status, body = http("GET", "/_statum/entrypoint?uri=" + urllib.parse.quote(uri.split("?")[0]),
  79. headers=headers)
  80. if status != 200:
  81. raise SystemExit("entrypoint %s -> HTTP %d: %s" % (uri, status, body[:300]))
  82. return json.loads(body)
  83. def slot_public(auth, uri, type_name):
  84. for directive in directives(uri, auth):
  85. if directive["type"] == "set":
  86. content = json.loads(directive["snapshot_frame"]["content"])
  87. if content["slot"]["type"] == type_name:
  88. return content["public"]
  89. raise SystemExit("no %s slot in entrypoint state for %s" % (type_name, uri))
  90. def act(auth, action, fields):
  91. headers = {"X-Statum-Slot": "key=%s; as_at=%s" % (auth["key"], auth["as_at"])}
  92. if action.get("private"):
  93. headers["X-Statum-Private"] = action["private"]
  94. status, body = http(action.get("method", "POST"), action["uri"], fields, headers)
  95. if status != 200:
  96. raise SystemExit("action %s -> HTTP %d: %s" % (action["uri"], status, body[:300]))
  97. return json.loads(body)
  98. def login(username, password):
  99. frames = directives("/login")
  100. content = json.loads(frames[0]["snapshot_frame"]["content"])
  101. action = content["public"]["login"]
  102. status, body = http(action.get("method", "POST"), action["uri"],
  103. {"username": username, "password": password})
  104. if status != 200:
  105. raise SystemExit("login -> HTTP %d: %s" % (status, body[:300]))
  106. for directive in json.loads(body):
  107. if directive["type"] == "set":
  108. frame = json.loads(directive["snapshot_frame"]["content"])
  109. if frame["slot"]["type"] == "auth":
  110. return {"key": frame["slot"]["key"], "as_at": frame["as_at"]}
  111. raise SystemExit("login response carried no auth slot: " + body[:300])
  112. def saddle_status():
  113. try:
  114. status, body = http("GET", SADDLE + "/status", timeout=8)
  115. return json.loads(body) if status == 200 else None
  116. except (urllib.error.URLError, OSError):
  117. return None
  118. def wait_for(predicate, timeout=60, what="condition", interval=1.0):
  119. deadline = time.monotonic() + timeout
  120. last = None
  121. while time.monotonic() < deadline:
  122. last = predicate()
  123. if last is not False and last is not None:
  124. return last
  125. time.sleep(interval)
  126. print(" (timeout waiting for %s; last: %s)" % (what, last))
  127. return None
  128. def task_state(auth, task_id):
  129. return slot_public(auth, "/tasks/%d" % task_id, "task")
  130. def wait_task_status(auth, task_id, status, timeout=90):
  131. return wait_for(lambda: task_state(auth, task_id).get("status") == status or False,
  132. timeout=timeout, what="task %d -> %s" % (task_id, status))
  133. def start_mock():
  134. responses = [
  135. "# Inspecting the checkout\nls -la %s" % DEMO,
  136. "# Checking history\ngit -C %s log --oneline" % DEMO,
  137. 'saddle comment "Inspected the checkout"\nsaddle done "Explored the demo repository"',
  138. "# Starting the follow-up\necho task-two-running",
  139. 'saddle done "Follow-up audit complete"',
  140. '# I need to ask\nsaddle block "Which branch should I target?"',
  141. ]
  142. argv = [sys.executable, MOCK, "--port", MOCK_PORT]
  143. for response in responses:
  144. argv += ["-r", response]
  145. argv += ["--on-answer", 'saddle done "Branch confirmed: main"', "--delay", "2"]
  146. return subprocess.Popen(argv, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
  147. def main():
  148. mock = None
  149. if not MOCK_CMD:
  150. mock = start_mock()
  151. time.sleep(0.5)
  152. else:
  153. subprocess.Popen(["bash", "-c", MOCK_CMD])
  154. time.sleep(1.0)
  155. try:
  156. auth = login("admin", "adminpass123")
  157. check(True, "login yields an auth slot")
  158. state = slot_public(auth, "/projects", "projects")
  159. act(auth, state["create"], {
  160. "name": "demo",
  161. "git_url": "https://example.com/demo.git",
  162. "default_branch": "main",
  163. "summary": "Integration test repository",
  164. })
  165. check(any(p["name"] == "demo" for p in slot_public(auth, "/projects", "projects")["projects"]),
  166. "project demo created")
  167. state = slot_public(auth, "/tasks", "tasks")
  168. act(auth, state["create"], {
  169. "project_id": "1",
  170. "title": "Explore the demo repository",
  171. "description": "List the checkout and inspect its git history.",
  172. "priority": "3",
  173. })
  174. act(auth, state["create"], {
  175. "project_id": "1",
  176. "title": "Follow-up audit",
  177. "description": "Runs after the explore task completes.",
  178. "priority": "4",
  179. })
  180. act(auth, state["create"], {
  181. "project_id": "1",
  182. "title": "Ask a blocking question",
  183. "description": "Asks which branch to target, then finishes.",
  184. "priority": "5",
  185. })
  186. titles = [t["title"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]]
  187. check(titles == ["Explore the demo repository", "Follow-up audit", "Ask a blocking question"],
  188. "three tasks queued in priority order")
  189. detail = task_state(auth, 2)
  190. act(auth, detail["dependency"], {"depends_on_task_id": "1"})
  191. check(wait_for(lambda: any(int(d.get("id", 0)) == 1
  192. for d in task_state(auth, 2).get("dependencies", [])) or False,
  193. timeout=5, what="dependency edge of task 2"),
  194. "task 2 depends on task 1")
  195. state = slot_public(auth, "/saddles", "saddles")
  196. act(auth, state["register"], {"name": SADDLE_NAME, "host": SADDLE_HOST})
  197. check(any(s["name"] == SADDLE_NAME and s["host"] == SADDLE_HOST
  198. for s in slot_public(auth, "/saddles", "saddles")["saddles"]),
  199. "saddle registered at %s" % SADDLE_HOST)
  200. print("== explore task ==")
  201. left_queue = wait_for(
  202. lambda: (lambda s: s if s != "queued" else False)(task_state(auth, 1).get("status")) or False,
  203. timeout=15, what="task 1 to leave the queue")
  204. check(left_queue is not None, "dispatcher picks task 1 up within 15s (status %s)" % left_queue)
  205. check(wait_for(lambda: (saddle_status() or {}).get("state") in ("working", "blocked", "done") or False,
  206. timeout=15, what="saddle active") is not None,
  207. "saddle daemon receives the task")
  208. check(wait_task_status(auth, 1, "working", timeout=30) is not None,
  209. "task 1 flips to working from daemon status")
  210. check(wait_task_status(auth, 1, "done", timeout=90) is not None, "task 1 completes")
  211. detail = task_state(auth, 1)
  212. check("Explored the demo repository" in (detail.get("summary") or ""),
  213. "task 1 carries the agent's summary")
  214. check(any(c["author"] == "bot" and "Inspected the checkout" in c["body"]
  215. for c in detail.get("comments", [])),
  216. "agent comment projected into task_comments")
  217. check(detail.get("sessions") and detail["sessions"][0].get("outcome") == "done",
  218. "session closed with outcome done")
  219. check(directives("/tasks/1/terminal", auth) is not None, "terminal page state loads")
  220. print("== dependency gating ==")
  221. check(wait_task_status(auth, 2, "done", timeout=90) is not None,
  222. "task 2 (dependent) dispatches and completes after task 1")
  223. print("== block and answer ==")
  224. check(wait_task_status(auth, 3, "blocked", timeout=90) is not None, "task 3 reaches blocked")
  225. detail = task_state(auth, 3)
  226. open_blocks = [b for b in detail.get("blocks", []) if not b.get("answered")]
  227. check(len(open_blocks) == 1 and "Which branch" in open_blocks[0]["question"],
  228. "block question surfaced in the web UI")
  229. act(auth, open_blocks[0]["answer_action"], {"answer": "main"})
  230. check(any(b.get("answer") == "main" for b in task_state(auth, 3).get("blocks", [])),
  231. "human answer recorded via the web action")
  232. check(wait_task_status(auth, 3, "done", timeout=90) is not None,
  233. "answer delivered to daemon; task 3 completes")
  234. detail = task_state(auth, 3)
  235. check("Branch confirmed: main" in (detail.get("summary") or ""),
  236. "task 3 summary reflects the post-answer completion")
  237. print("== audit trail ==")
  238. db = sqlite3.connect(DB_PATH)
  239. rows = db.execute(
  240. "select command_text, output_text, exit_code from session_commands order by id").fetchall()
  241. commands = {row[0]: row for row in rows}
  242. check(any("ls -la" in c and "hello.py" in (r[1] or "") for c, r in commands.items()),
  243. "ls output captured in session_commands")
  244. check(any("git" in c and "Initial commit" in (r[1] or "") and r[2] == 0
  245. for c, r in commands.items()),
  246. "git log output captured with exit code 0")
  247. check(len([c for c in commands if "echo" in c]) >= 1, "echo output captured")
  248. per_session = db.execute(
  249. "select session_id, count(*) from session_commands group by session_id order by session_id"
  250. ).fetchall()
  251. check(all(count > 0 for _, count in per_session) and len(per_session) == 3,
  252. "each session carries its own commands (%s)" % per_session)
  253. db.close()
  254. print("== quiesce ==")
  255. check(wait_for(
  256. lambda: all(int(s["current_task_id"] or 0) == 0 and s["status"] == "idle"
  257. for s in slot_public(auth, "/saddles", "saddles")["saddles"]) or False,
  258. timeout=30, what="saddle idle with no current task") is not None,
  259. "saddle freed and idle once every task finished")
  260. finally:
  261. if mock is not None:
  262. mock.terminate()
  263. print("%d passed, %d failed" % (passes, failures))
  264. return 1 if failures else 0
  265. if __name__ == "__main__":
  266. sys.exit(main())