integration_test.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  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. A fourth section exercises the part 2 surface directly against the
  10. saddle ingest API: bearer-token auth, typed blocks with options, plan,
  11. progress and review updates, quoted comments on the poll endpoint, and
  12. the terminal SSE stream.
  13. Prerequisites (see README.md "Integration testing"):
  14. - the web app running with SPRY_SEED_ADMIN_USERNAME=admin,
  15. SPRY_SEED_ADMIN_PASSWORD=adminpass123, SADDLE_LLM_BASE_URL pointing at
  16. the mock (below), SADDLE_PROJECTS_ROOT=$WORKSPACE
  17. - a saddle daemon reachable at SADDLE
  18. - a git repo with at least two commits at $WORKSPACE/demo
  19. Environment:
  20. WEB web app base URL (default http://localhost:8080)
  21. SADDLE daemon control URL (default http://localhost:7100)
  22. SADDLE_HOST host:port to register (default localhost:7100)
  23. SADDLE_NAME registration name (default saddle-e2e-1)
  24. WORKSPACE demo repo parent dir (default /tmp/clanker-e2e/workspace)
  25. DB_PATH web app SQLite file (for audit assertions)
  26. MOCK mock_llm.py path (default <repo>/../saddle/test/mock_llm.py)
  27. MOCK_PORT local mock port (default 9999)
  28. MOCK_CMD run instead of the local mock (e.g. a podman exec line
  29. that starts the mock inside the saddle container); the
  30. script skips spawning the mock when set
  31. Prints PASS/FAIL lines Statum-style; exits non-zero on any failure.
  32. """
  33. import json
  34. import os
  35. import sqlite3
  36. import subprocess
  37. import sys
  38. import time
  39. import urllib.error
  40. import urllib.parse
  41. import urllib.request
  42. REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  43. WEB = os.environ.get("WEB", "http://localhost:8080")
  44. SADDLE = os.environ.get("SADDLE", "http://localhost:7100")
  45. SADDLE_HOST = os.environ.get("SADDLE_HOST", "localhost:7100")
  46. SADDLE_NAME = os.environ.get("SADDLE_NAME", "saddle-e2e-1")
  47. WORKSPACE = os.environ.get("WORKSPACE", "/tmp/clanker-e2e/workspace")
  48. DB_PATH = os.environ.get("DB_PATH", os.path.join(WORKSPACE, "..", "web", "clanker-saddle.sqlite"))
  49. MOCK = os.environ.get("MOCK", os.path.join(os.path.dirname(REPO), "saddle", "test", "mock_llm.py"))
  50. MOCK_PORT = os.environ.get("MOCK_PORT", "9999")
  51. MOCK_CMD = os.environ.get("MOCK_CMD", "")
  52. DEMO = os.path.join(WORKSPACE, "demo")
  53. passes = 0
  54. failures = 0
  55. def check(condition, label):
  56. global passes, failures
  57. if condition:
  58. passes += 1
  59. print("PASS %s" % label)
  60. else:
  61. failures += 1
  62. print("FAIL %s" % label)
  63. def http(method, url, data=None, headers=None, timeout=30):
  64. body = None
  65. if data is not None:
  66. body = "&".join("%s=%s" % (k, urllib.parse.quote(str(v))) for k, v in data.items()).encode()
  67. request = urllib.request.Request(WEB + url if url.startswith("/") else url,
  68. data=body, method=method)
  69. if data is not None:
  70. request.add_header("Content-Type", "application/x-www-form-urlencoded")
  71. for key, value in (headers or {}).items():
  72. request.add_header(key, value)
  73. try:
  74. with urllib.request.urlopen(request, timeout=timeout) as response:
  75. return response.status, response.read().decode()
  76. except urllib.error.HTTPError as error:
  77. return error.code, error.read().decode()
  78. def directives(uri, auth=None):
  79. headers = {}
  80. if auth:
  81. headers["X-Statum-Slot"] = "key=%s; as_at=%s" % (auth["key"], auth["as_at"])
  82. status, body = http("GET", "/_statum/entrypoint?uri=" + urllib.parse.quote(uri.split("?")[0]),
  83. headers=headers)
  84. if status != 200:
  85. raise SystemExit("entrypoint %s -> HTTP %d: %s" % (uri, status, body[:300]))
  86. return json.loads(body)
  87. def slot_public(auth, uri, type_name):
  88. for directive in directives(uri, auth):
  89. if directive["type"] == "set":
  90. content = json.loads(directive["snapshot_frame"]["content"])
  91. if content["slot"]["type"] == type_name:
  92. return content["public"]
  93. raise SystemExit("no %s slot in entrypoint state for %s" % (type_name, uri))
  94. def act(auth, action, fields):
  95. headers = {"X-Statum-Slot": "key=%s; as_at=%s" % (auth["key"], auth["as_at"])}
  96. if action.get("private"):
  97. headers["X-Statum-Private"] = action["private"]
  98. status, body = http(action.get("method", "POST"), action["uri"], fields, headers)
  99. if status != 200:
  100. raise SystemExit("action %s -> HTTP %d: %s" % (action["uri"], status, body[:300]))
  101. return json.loads(body)
  102. def login(username, password):
  103. frames = directives("/login")
  104. content = json.loads(frames[0]["snapshot_frame"]["content"])
  105. action = content["public"]["login"]
  106. status, body = http(action.get("method", "POST"), action["uri"],
  107. {"username": username, "password": password})
  108. if status != 200:
  109. raise SystemExit("login -> HTTP %d: %s" % (status, body[:300]))
  110. for directive in json.loads(body):
  111. if directive["type"] == "set":
  112. frame = json.loads(directive["snapshot_frame"]["content"])
  113. if frame["slot"]["type"] == "auth":
  114. return {"key": frame["slot"]["key"], "as_at": frame["as_at"]}
  115. raise SystemExit("login response carried no auth slot: " + body[:300])
  116. def saddle_status():
  117. try:
  118. status, body = http("GET", SADDLE + "/status", timeout=8)
  119. return json.loads(body) if status == 200 else None
  120. except (urllib.error.URLError, OSError):
  121. return None
  122. def wait_for(predicate, timeout=60, what="condition", interval=1.0):
  123. deadline = time.monotonic() + timeout
  124. last = None
  125. while time.monotonic() < deadline:
  126. last = predicate()
  127. if last is not False and last is not None:
  128. return last
  129. time.sleep(interval)
  130. print(" (timeout waiting for %s; last: %s)" % (what, last))
  131. return None
  132. def task_state(auth, task_id):
  133. return slot_public(auth, "/tasks/%d" % task_id, "task")
  134. def wait_task_status(auth, task_id, status, timeout=90):
  135. return wait_for(lambda: task_state(auth, task_id).get("status") == status or False,
  136. timeout=timeout, what="task %d -> %s" % (task_id, status))
  137. def saddle_api(method, path, token, payload=None):
  138. """Raw call against the saddle ingest API; returns (status, json)."""
  139. request = urllib.request.Request(WEB + path,
  140. data=json.dumps(payload or {}).encode(), method=method)
  141. request.add_header("Content-Type", "application/json")
  142. if token is not None:
  143. request.add_header("Authorization", "Bearer " + token)
  144. try:
  145. with urllib.request.urlopen(request, timeout=15) as response:
  146. return response.status, json.loads(response.read().decode() or "{}")
  147. except urllib.error.HTTPError as error:
  148. body = error.read().decode()
  149. try:
  150. return error.code, json.loads(body)
  151. except ValueError:
  152. return error.code, {"raw": body}
  153. def start_mock():
  154. responses = [
  155. "# Inspecting the checkout\nls -la %s" % DEMO,
  156. "# Checking history\ngit -C %s log --oneline" % DEMO,
  157. 'saddle comment "Inspected the checkout"\nsaddle done "Explored the demo repository"',
  158. "# Starting the follow-up\necho task-two-running",
  159. 'saddle done "Follow-up audit complete"',
  160. '# I need to ask\nsaddle block "Which branch should I target?"',
  161. ]
  162. argv = [sys.executable, MOCK, "--port", MOCK_PORT]
  163. for response in responses:
  164. argv += ["-r", response]
  165. argv += ["--on-answer", 'saddle done "Branch confirmed: main"', "--delay", "2"]
  166. return subprocess.Popen(argv, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
  167. def main():
  168. mock = None
  169. if not MOCK_CMD:
  170. mock = start_mock()
  171. time.sleep(0.5)
  172. else:
  173. subprocess.Popen(["bash", "-c", MOCK_CMD])
  174. time.sleep(1.0)
  175. try:
  176. auth = login("admin", "adminpass123")
  177. check(True, "login yields an auth slot")
  178. state = slot_public(auth, "/projects", "projects")
  179. act(auth, state["create"], {
  180. "name": "demo",
  181. "git_url": "https://example.com/demo.git",
  182. "default_branch": "main",
  183. "summary": "Integration test repository",
  184. })
  185. check(any(p["name"] == "demo" for p in slot_public(auth, "/projects", "projects")["projects"]),
  186. "project demo created")
  187. state = slot_public(auth, "/tasks", "tasks")
  188. act(auth, state["create"], {
  189. "project_id": "1",
  190. "title": "Explore the demo repository",
  191. "description": "List the checkout and inspect its git history.",
  192. "priority": "3",
  193. })
  194. act(auth, state["create"], {
  195. "project_id": "1",
  196. "title": "Follow-up audit",
  197. "description": "Runs after the explore task completes.",
  198. "priority": "4",
  199. })
  200. act(auth, state["create"], {
  201. "project_id": "1",
  202. "title": "Ask a blocking question",
  203. "description": "Asks which branch to target, then finishes.",
  204. "priority": "5",
  205. })
  206. titles = [t["title"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]]
  207. check(titles == ["Explore the demo repository", "Follow-up audit", "Ask a blocking question"],
  208. "three tasks queued in priority order")
  209. detail = task_state(auth, 2)
  210. act(auth, detail["dependency"], {"depends_on_task_id": "1"})
  211. check(wait_for(lambda: any(int(d.get("id", 0)) == 1
  212. for d in task_state(auth, 2).get("dependencies", [])) or False,
  213. timeout=5, what="dependency edge of task 2"),
  214. "task 2 depends on task 1")
  215. state = slot_public(auth, "/saddles", "saddles")
  216. act(auth, state["register"], {"name": SADDLE_NAME, "host": SADDLE_HOST})
  217. check(any(s["name"] == SADDLE_NAME and s["host"] == SADDLE_HOST
  218. for s in slot_public(auth, "/saddles", "saddles")["saddles"]),
  219. "saddle registered at %s" % SADDLE_HOST)
  220. print("== explore task ==")
  221. left_queue = wait_for(
  222. lambda: (lambda s: s if s != "queued" else False)(task_state(auth, 1).get("status")) or False,
  223. timeout=15, what="task 1 to leave the queue")
  224. check(left_queue is not None, "dispatcher picks task 1 up within 15s (status %s)" % left_queue)
  225. check(wait_for(lambda: (saddle_status() or {}).get("state") in ("working", "blocked", "done") or False,
  226. timeout=15, what="saddle active") is not None,
  227. "saddle daemon receives the task")
  228. check(wait_task_status(auth, 1, "working", timeout=30) is not None,
  229. "task 1 flips to working from daemon status")
  230. check(wait_task_status(auth, 1, "done", timeout=90) is not None, "task 1 completes")
  231. detail = task_state(auth, 1)
  232. check("Explored the demo repository" in (detail.get("summary") or ""),
  233. "task 1 carries the agent's summary")
  234. check(any(c["author"] == "bot" and "Inspected the checkout" in c["body"]
  235. for c in detail.get("comments", [])),
  236. "agent comment projected into task_comments")
  237. check(detail.get("sessions") and detail["sessions"][0].get("outcome") == "done",
  238. "session closed with outcome done")
  239. check(directives("/tasks/1/terminal", auth) is not None, "terminal page state loads")
  240. print("== dependency gating ==")
  241. check(wait_task_status(auth, 2, "done", timeout=90) is not None,
  242. "task 2 (dependent) dispatches and completes after task 1")
  243. print("== block and answer ==")
  244. check(wait_task_status(auth, 3, "blocked", timeout=90) is not None, "task 3 reaches blocked")
  245. detail = task_state(auth, 3)
  246. open_blocks = [b for b in detail.get("blocks", []) if not b.get("answered")]
  247. check(len(open_blocks) == 1 and "Which branch" in open_blocks[0]["question"],
  248. "block question surfaced in the web UI")
  249. act(auth, open_blocks[0]["answer_action"], {"answer": "main"})
  250. check(any(b.get("answer") == "main" for b in task_state(auth, 3).get("blocks", [])),
  251. "human answer recorded via the web action")
  252. check(wait_task_status(auth, 3, "done", timeout=90) is not None,
  253. "answer delivered to daemon; task 3 completes")
  254. detail = task_state(auth, 3)
  255. check("Branch confirmed: main" in (detail.get("summary") or ""),
  256. "task 3 summary reflects the post-answer completion")
  257. print("== audit trail ==")
  258. db = sqlite3.connect(DB_PATH)
  259. rows = db.execute(
  260. "select command_text, output_text, exit_code from session_commands order by id").fetchall()
  261. commands = {row[0]: row for row in rows}
  262. check(any("ls -la" in c and "hello.py" in (r[1] or "") for c, r in commands.items()),
  263. "ls output captured in session_commands")
  264. check(any("git" in c and "Initial commit" in (r[1] or "") and r[2] == 0
  265. for c, r in commands.items()),
  266. "git log output captured with exit code 0")
  267. check(len([c for c in commands if "echo" in c]) >= 1, "echo output captured")
  268. per_session = db.execute(
  269. "select session_id, count(*) from session_commands group by session_id order by session_id"
  270. ).fetchall()
  271. check(all(count > 0 for _, count in per_session) and len(per_session) == 3,
  272. "each session carries its own commands (%s)" % per_session)
  273. db.close()
  274. print("== part 2 API surface ==")
  275. db = sqlite3.connect(DB_PATH)
  276. saddles_state = slot_public(auth, "/saddles", "saddles")
  277. act(auth, saddles_state["register"], {"name": "saddle-e2e-api", "host": "localhost:7199"})
  278. api_rows = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  279. if s["name"] == "saddle-e2e-api"]
  280. check(len(api_rows) == 1 and api_rows[0]["token"].startswith("saddle-"),
  281. "registration returns an API token")
  282. api_saddle = int(api_rows[0]["id"])
  283. api_token = api_rows[0]["token"]
  284. tasks_state = slot_public(auth, "/tasks", "tasks")
  285. act(auth, tasks_state["create"], {
  286. "project_id": "1",
  287. "title": "Part 2 API surface",
  288. "description": "Typed blocks, plan, progress, review, quoted comments, SSE",
  289. "plan": "# Initial plan",
  290. "priority": "6",
  291. "task_branch": "task/9-api-surface",
  292. })
  293. api_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  294. if t["title"] == "Part 2 API surface"][0])
  295. detail = task_state(auth, api_task)
  296. check(detail["branch_from"] == "main",
  297. "branch_from prefilled from the project default branch")
  298. check(detail["task_branch"] == "task/9-api-surface", "task_branch stored")
  299. # Dispatch bookkeeping without a daemon: the ingest API is under
  300. # test, so the session and saddle/task state are staged directly.
  301. db.execute("insert into sessions (task_id, saddle_id, started_at)"
  302. " values (?, ?, strftime('%s','now'))", (api_task, api_saddle))
  303. db.execute("update saddles set current_task_id = ?, status = 'idle' where id = ?",
  304. (api_task, api_saddle))
  305. db.execute("update tasks set status = 'working' where id = ?", (api_task,))
  306. db.commit()
  307. api_session = db.execute("select max(id) from sessions").fetchone()[0]
  308. status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, None,
  309. {"type": "comment", "session_id": api_session, "data": {"body": "x"}})
  310. check(status == 401, "update without a token is rejected")
  311. status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, "saddle-not-a-token",
  312. {"type": "comment", "session_id": api_session, "data": {"body": "x"}})
  313. check(status == 401, "update with an invalid token is rejected")
  314. status, _ = saddle_api("GET", "/api/saddle/%d/poll" % api_saddle, None)
  315. check(status == 401, "poll without a token is rejected")
  316. status, body = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  317. {"type": "comment", "session_id": api_session,
  318. "data": {"body": "Surveyed the module"}})
  319. check(status == 200 and body.get("ok"), "comment update accepted with token")
  320. status, body = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  321. {"type": "block", "session_id": api_session, "data": {
  322. "question": "Which database?",
  323. "type": "question",
  324. "options": ["SQLite", "Postgres"],
  325. "body": "# Pick one"}})
  326. check(status == 200 and body.get("block_id"), "typed block returns block_id")
  327. status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  328. {"type": "plan", "session_id": api_session,
  329. "data": {"plan": "# Plan\n- migrate schemas"}})
  330. check(status == 200, "plan update accepted")
  331. status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  332. {"type": "progress", "session_id": api_session,
  333. "data": {"summary": "Migrated the schemas"}})
  334. check(status == 200, "progress update accepted")
  335. status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  336. {"type": "review", "session_id": api_session,
  337. "data": {"summary": "Migration **ready** for review"}})
  338. check(status == 200, "review update accepted")
  339. detail = task_state(auth, api_task)
  340. check(detail["status"] == "review_requested", "review flips the task status")
  341. check([b["options"] for b in detail["blocks"]
  342. if b["block_type"] == "question"] == [["SQLite", "Postgres"]],
  343. "question block carries its options")
  344. check("- migrate schemas" in detail["plan"], "plan visible on the task page")
  345. check(detail["progress"][0]["summary"] == "Migrated the schemas",
  346. "progress history newest-first")
  347. check(detail["review_pending"] and "ready" in detail["review_summary"],
  348. "review banner data on the task page")
  349. check(detail["review_diff_url"].endswith(
  350. "demo.git/compare/main...task/9-api-surface"), "review diff link constructed")
  351. dash = slot_public(auth, "/", "dashboard")
  352. check(any(int(r["task_id"]) == api_task for r in dash["reviews"]),
  353. "dashboard lists the pending review")
  354. check(any(b["question"] == "Which database?" for b in dash["open_blocks"]),
  355. "dashboard lists blocks needing answers")
  356. question = [b for b in detail["blocks"] if b["block_type"] == "question"][0]
  357. act(auth, question["answer_action"], {"option": "SQLite"})
  358. check([b["answer"] for b in task_state(auth, api_task)["blocks"]
  359. if b["block_type"] == "question"] == ["Human selected: SQLite"],
  360. "option answer formatted for the agent")
  361. act(auth, detail["comment"],
  362. {"body": "Check the migration output", "quote": "ERROR: table demos exists"})
  363. status, body = saddle_api("GET", "/api/saddle/%d/poll" % api_saddle, api_token)
  364. check(any(a["answer"] == "Human selected: SQLite" for a in body.get("answers", [])),
  365. "poll hands over the block answer")
  366. check(any(c["body"] == "Check the migration output" and "ERROR" in c["quote"]
  367. for c in body.get("comments", [])),
  368. "poll hands over the quoted user comment")
  369. status, body = saddle_api("GET", "/api/saddle/%d/poll" % api_saddle, api_token)
  370. check(body.get("answers") == [] and body.get("comments") == [],
  371. "poll delivers answers and comments exactly once")
  372. entry_types = {row[0] for row in db.execute(
  373. "select type from session_commands where session_id = ?", (api_session,))}
  374. check({"comment", "block", "plan", "progress", "review", "user-comment"} <= entry_types,
  375. "conversation events logged as typed terminal entries")
  376. request = urllib.request.Request(WEB + "/terminal-stream/%d" % api_session)
  377. with urllib.request.urlopen(request, timeout=10) as stream:
  378. check(stream.headers.get("Content-Type", "").startswith("text/event-stream"),
  379. "terminal stream is text/event-stream")
  380. lines = []
  381. deadline = time.monotonic() + 5
  382. while time.monotonic() < deadline and len(lines) < 40:
  383. line = stream.readline().decode().strip()
  384. if line:
  385. lines.append(line)
  386. deadline = min(deadline, time.monotonic() + 2)
  387. entries = [json.loads(line[6:]) for line in lines
  388. if line.startswith("data: ") and line != "data: ping"]
  389. check(len(entries) >= 7, "terminal stream replays the backlog")
  390. db.execute("insert into session_commands (session_id, command_text, output_text,"
  391. " type, started_at) values (?, 'sse-live-check', 'ok', 'command',"
  392. " strftime('%s','now'))", (api_session,))
  393. db.commit()
  394. got_live = False
  395. deadline = time.monotonic() + 6
  396. while time.monotonic() < deadline and not got_live:
  397. line = stream.readline().decode().strip()
  398. if line.startswith("data: ") and "sse-live-check" in line:
  399. got_live = True
  400. check(got_live, "terminal stream pushes new entries within seconds")
  401. act(auth, detail["cancel"], {"task_id": str(api_task)})
  402. db.close()
  403. print("== quiesce ==")
  404. check(wait_for(
  405. lambda: all(int(s["current_task_id"] or 0) == 0 and s["status"] == "idle"
  406. for s in slot_public(auth, "/saddles", "saddles")["saddles"]) or False,
  407. timeout=30, what="saddle idle with no current task") is not None,
  408. "saddle freed and idle once every task finished")
  409. finally:
  410. if mock is not None:
  411. mock.terminate()
  412. print("%d passed, %d failed" % (passes, failures))
  413. return 1 if failures else 0
  414. if __name__ == "__main__":
  415. sys.exit(main())