integration_test.py 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749
  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 runs a REAL goal against a real git checkout under
  6. WORKSPACE, powered by saddle/test/mock_llm.py with a deterministic
  7. script — both binaries, the true cross-binary contract proof (the
  8. per-repo fake-peer suits live in web_stack_test.py / saddle e2e).
  9. The goal-era arc (plan 12):
  10. 1. goal create -> start: the goal-run assignment reaches the daemon
  11. over the ask/allocate channel with the full plan-12 payload (kind/
  12. mode/creator/goal context/ephemeral branches for workers)
  13. 2. pre-plan question block -> answered on the goal page -> the ONE
  14. answer resolves the goal-run task's single task_blocks row
  15. row, and the parked goal agent resumes with the verdict wording
  16. 3. plan submit -> reject-with-feedback -> revision -> approval (the
  17. agreed verdict words ride the resume)
  18. 4. task fan-out through the real /api/saddle/task/create + enqueue
  19. callbacks; the worker runs in its ephemeral worktree branch cut
  20. from the goal clone, commits, completes — worktree auto-pruned,
  21. branch kept
  22. 5. the task-terminal event coalesces (~SADDLE_GOAL_RESUME_S) into the
  23. goal agent's batched resume; it merges, deletes the task branch,
  24. pushes the goal branch (push-before-review) and submits the
  25. completion report
  26. 6. completion review accept -> goal complete, run task done
  27. 7. the goal agent's terminal sessions (SSE replay), the audit trail
  28. (the derived goal narrative, typed session_commands), the /status
  29. goal_branches cache sampled by the manual saddle refresh
  30. 8. the legacy ingest API surface (401s, typed updates, exactly-once
  31. poll handover) and the stm-markdown renderer
  32. Prerequisites (see README.md "Integration testing"): the web app
  33. running with SPRY_SEED_ADMIN_USERNAME=admin,
  34. SPRY_SEED_ADMIN_PASSWORD=adminpass123, SADDLE_LLM_BASE_URL pointing
  35. at the mock, SADDLE_PROJECTS_ROOT=$WORKSPACE and a FRESH database
  36. (the harness asserts an empty tasks/goals state); a saddle daemon
  37. with SADDLE_WORKSPACE_ROOT=$WORKSPACE (the shared-volume deployment
  38. assumption — the goal clone anchors the worker worktrees); a git
  39. repo with at least two commits at $WORKSPACE/demo whose origin is
  40. pushable (the harness prepares demo + demo-origin.git when WORKSPACE
  41. is empty).
  42. Environment:
  43. WEB web app base URL (default http://localhost:8080)
  44. SADDLE daemon control URL (default http://localhost:7100)
  45. SADDLE_HOST host:port to register (default localhost:7100)
  46. SADDLE_NAME registration name (default saddle-e2e-1)
  47. WORKSPACE demo repo parent dir (default /tmp/clanker-e2e/workspace)
  48. DB_PATH web app SQLite file (for audit assertions)
  49. MOCK mock_llm.py path (default <repo>/../saddle/test/mock_llm.py)
  50. MOCK_PORT local mock port (default 9999)
  51. MOCK_CMD run instead of the local mock (e.g. a podman exec line
  52. that starts the mock inside the saddle container); the
  53. script skips spawning the mock when set
  54. Prints PASS/FAIL lines Statum-style; exits non-zero on any failure.
  55. """
  56. import json
  57. import os
  58. import socket
  59. import sqlite3
  60. import subprocess
  61. import sys
  62. import time
  63. import urllib.error
  64. import urllib.parse
  65. import urllib.request
  66. REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  67. WEB = os.environ.get("WEB", "http://localhost:8080")
  68. SADDLE = os.environ.get("SADDLE", "http://localhost:7100")
  69. SADDLE_HOST = os.environ.get("SADDLE_HOST", "localhost:7100")
  70. SADDLE_NAME = os.environ.get("SADDLE_NAME", "saddle-e2e-1")
  71. WORKSPACE = os.environ.get("WORKSPACE", "/tmp/clanker-e2e/workspace")
  72. DB_PATH = os.environ.get("DB_PATH", os.path.join(WORKSPACE, "..", "web", "clanker-saddle.sqlite"))
  73. MOCK = os.environ.get("MOCK", os.path.join(os.path.dirname(REPO), "saddle", "test", "mock_llm.py"))
  74. MOCK_PORT = os.environ.get("MOCK_PORT", "9999")
  75. MOCK_CMD = os.environ.get("MOCK_CMD", "")
  76. DEMO = os.path.join(WORKSPACE, "demo")
  77. HOSTNAME = socket.gethostname().split(".")[0]
  78. PLAN_APPROVED = "Plan approved. Proceed with implementation."
  79. REVIEW_APPROVED = "Review approved. Continue with the task."
  80. passes = 0
  81. failures = 0
  82. def check(condition, label):
  83. global passes, failures
  84. if condition:
  85. passes += 1
  86. print("PASS %s" % label)
  87. else:
  88. failures += 1
  89. print("FAIL %s" % label)
  90. def http(method, url, data=None, headers=None, timeout=30):
  91. body = None
  92. if data is not None:
  93. body = "&".join("%s=%s" % (k, urllib.parse.quote(str(v))) for k, v in data.items()).encode()
  94. request = urllib.request.Request(WEB + url if url.startswith("/") else url,
  95. data=body, method=method)
  96. if data is not None:
  97. request.add_header("Content-Type", "application/x-www-form-urlencoded")
  98. for key, value in (headers or {}).items():
  99. request.add_header(key, value)
  100. try:
  101. with urllib.request.urlopen(request, timeout=timeout) as response:
  102. return response.status, response.read().decode()
  103. except urllib.error.HTTPError as error:
  104. return error.code, error.read().decode()
  105. def directives(uri, auth=None):
  106. headers = {}
  107. if auth:
  108. headers["X-Statum-Slot"] = "key=%s; as_at=%s" % (auth["key"], auth["as_at"])
  109. status, body = http("GET", "/_statum/entrypoint?uri=" + urllib.parse.quote(uri.split("?")[0]),
  110. headers=headers)
  111. if status != 200:
  112. raise SystemExit("entrypoint %s -> HTTP %d: %s" % (uri, status, body[:300]))
  113. return json.loads(body)
  114. def slot_public(auth, uri, type_name):
  115. for directive in directives(uri, auth):
  116. if directive["type"] == "set":
  117. content = json.loads(directive["snapshot_frame"]["content"])
  118. if content["slot"]["type"] == type_name:
  119. return content["public"]
  120. raise SystemExit("no %s slot in entrypoint state for %s" % (type_name, uri))
  121. def act(auth, action, fields):
  122. headers = {"X-Statum-Slot": "key=%s; as_at=%s" % (auth["key"], auth["as_at"])}
  123. if action.get("private"):
  124. headers["X-Statum-Private"] = action["private"]
  125. status, body = http(action.get("method", "POST"), action["uri"], fields, headers)
  126. if status != 200:
  127. raise SystemExit("action %s -> HTTP %d: %s" % (action["uri"], status, body[:300]))
  128. return json.loads(body)
  129. def login(username, password):
  130. frames = directives("/login")
  131. content = json.loads(frames[0]["snapshot_frame"]["content"])
  132. action = content["public"]["login"]
  133. status, body = http(action.get("method", "POST"), action["uri"],
  134. {"username": username, "password": password})
  135. if status != 200:
  136. raise SystemExit("login -> HTTP %d: %s" % (status, body[:300]))
  137. for directive in json.loads(body):
  138. if directive["type"] == "set":
  139. frame = json.loads(directive["snapshot_frame"]["content"])
  140. if frame["slot"]["type"] == "auth":
  141. return {"key": frame["slot"]["key"], "as_at": frame["as_at"]}
  142. raise SystemExit("login response carried no auth slot: " + body[:300])
  143. def saddle_status():
  144. try:
  145. status, body = http("GET", SADDLE + "/status", timeout=8)
  146. return json.loads(body) if status == 200 else None
  147. except (urllib.error.URLError, OSError):
  148. return None
  149. def wait_for(predicate, timeout=60, what="condition", interval=1.0):
  150. deadline = time.monotonic() + timeout
  151. last = None
  152. while time.monotonic() < deadline:
  153. last = predicate()
  154. if last is not False and last is not None:
  155. return last
  156. time.sleep(interval)
  157. print(" (timeout waiting for %s; last: %s)" % (what, last))
  158. return None
  159. def task_state(auth, task_id):
  160. return slot_public(auth, "/tasks/%d" % task_id, "task")
  161. def wait_task_status(auth, task_id, status, timeout=90):
  162. return wait_for(lambda: task_state(auth, task_id).get("status") == status or False,
  163. timeout=timeout, what="task %d -> %s" % (task_id, status))
  164. def goal_state(auth, goal_id):
  165. return slot_public(auth, "/goals/%d" % goal_id, "goal")
  166. def wait_goal_status(auth, goal_id, status, timeout=90):
  167. return wait_for(lambda: goal_state(auth, goal_id).get("status") == status or False,
  168. timeout=timeout, what="goal %d -> %s" % (goal_id, status))
  169. def saddle_api(method, path, token, payload=None):
  170. """Raw call against the saddle ingest API; returns (status, json)."""
  171. request = urllib.request.Request(WEB + path,
  172. data=json.dumps(payload or {}).encode(), method=method)
  173. request.add_header("Content-Type", "application/json")
  174. if token is not None:
  175. request.add_header("Authorization", "Bearer " + token)
  176. try:
  177. with urllib.request.urlopen(request, timeout=15) as response:
  178. return response.status, json.loads(response.read().decode() or "{}")
  179. except urllib.error.HTTPError as error:
  180. body = error.read().decode()
  181. try:
  182. return error.code, json.loads(body)
  183. except ValueError:
  184. return error.code, {"raw": body}
  185. def db_query(sql, params=()):
  186. db = sqlite3.connect(DB_PATH)
  187. try:
  188. return db.execute(sql, params).fetchall()
  189. finally:
  190. db.close()
  191. def prepare_workspace():
  192. """Ensures $WORKSPACE/demo exists with a pushable origin (the goal
  193. branch push needs a remote that accepts it). Reuses an existing
  194. demo checkout; the DATABASE must still be fresh (checked in main)."""
  195. if os.path.isdir(os.path.join(DEMO, ".git")):
  196. return
  197. os.makedirs(WORKSPACE, exist_ok=True)
  198. origin = os.path.join(WORKSPACE, "demo-origin.git")
  199. subprocess.run(["git", "init", "--bare", "-b", "main", origin], check=True,
  200. capture_output=True)
  201. subprocess.run(["git", "clone", origin, DEMO], check=True, capture_output=True)
  202. subprocess.run(["git", "-C", DEMO, "config", "user.email", "e2e@localhost"], check=True)
  203. subprocess.run(["git", "-C", DEMO, "config", "user.name", "e2e"], check=True)
  204. with open(os.path.join(DEMO, "hello.py"), "w") as handle:
  205. handle.write("print('demo')\n")
  206. subprocess.run(["git", "-C", DEMO, "add", "-A"], check=True, capture_output=True)
  207. subprocess.run(["git", "-C", DEMO, "commit", "-m", "one"], check=True, capture_output=True)
  208. with open(os.path.join(DEMO, "notes.md"), "w") as handle:
  209. handle.write("# notes\n")
  210. subprocess.run(["git", "-C", DEMO, "add", "-A"], check=True, capture_output=True)
  211. subprocess.run(["git", "-C", DEMO, "commit", "-m", "two"], check=True, capture_output=True)
  212. subprocess.run(["git", "-C", DEMO, "push", "origin", "main"], check=True, capture_output=True)
  213. def mock_argv():
  214. heredoc = "cat << 'SADDLE_EOF' | %s\n%%s\nSADDLE_EOF"
  215. fanout = (
  216. "# Fanning the plan out\n"
  217. "out=$(cat << 'SADDLE_EOF' | saddle task create --title 'Write the greeting' --mode implement\n"
  218. "Create greeting.txt containing hello-from-the-goal.\nSADDLE_EOF)\n"
  219. "tid=$(echo \"$out\" | grep -oE '[0-9]+' | head -1)\n"
  220. "saddle task enqueue \"$tid\"\n"
  221. "saddle suspend"
  222. )
  223. responses = [
  224. # R1 (fresh goal agent): investigate, then block on a scope question
  225. "# Investigating before planning\nls -la\ngit log --oneline | head -3\n"
  226. + (heredoc % 'saddle block question "Demo only" "Whole workspace"') % (
  227. "Which scope should the greeting cover? The demo repo is small; confirm the blast radius."),
  228. # R2 (plan revision after feedback): the rejected plan, re-submitted
  229. (heredoc % "saddle plan") % (
  230. "# Plan v2\n\n1. Add greeting.txt to the demo repo.\n2. Export it for reuse."),
  231. # W1 (the worker task): verify the ephemeral branch, commit, done
  232. "# Working the mandate\ngit branch --show-current\n"
  233. "echo hello-from-the-goal > greeting.txt\ngit add -A\n"
  234. "git commit -m 'greeting from the goal'\n"
  235. + (heredoc % "saddle done") % (
  236. "# Completion report\n\n- changed: greeting.txt\n"
  237. "- verified: committed on the ephemeral branch"),
  238. ]
  239. argv = []
  240. for response in responses:
  241. argv += ["-r", response]
  242. argv += [
  243. # the question's verdict ("Human selected: Demo only") -> plan v1
  244. "--on-answer", (heredoc % "saddle plan") % (
  245. "# Plan v1\n\n1. Add greeting.txt to the demo repo."),
  246. # the approval verdict -> task fan-out + suspend
  247. "--on-plan-approved", fanout,
  248. # the batched "Goal update" resume -> merge, prune, push, complete
  249. "--answer-contains", "Goal update",
  250. "--on-answer-seen",
  251. "# Merging the finished task, then completing (the sleep holds the\n"
  252. "# working state open so /status can sample the branch state)\n"
  253. "sleep 4\n"
  254. "git merge --no-edit $(git for-each-ref --format='%(refname:short)' refs/heads/task/*)\n"
  255. "git branch -D $(git for-each-ref --format='%(refname:short)' refs/heads/task/*)\n"
  256. "git push -u origin $(git branch --show-current)\n"
  257. + (heredoc % "saddle done") % (
  258. "# Goal completion report\n\n- achieved: greeting.txt added via one worker task\n"
  259. "- verified: the goal branch is pushed to origin"),
  260. "--on-query", "The hostname is %s." % HOSTNAME,
  261. ]
  262. return argv
  263. def start_mock():
  264. if MOCK_CMD:
  265. # e.g. MOCK_CMD='podman exec -d clanker-saddle python3
  266. # /usr/libexec/saddle/mock_llm.py' — the same scripted argv is
  267. # appended, so the in-container mock behaves identically.
  268. argv = MOCK_CMD.split() + mock_argv()
  269. else:
  270. argv = [sys.executable, MOCK, "--port", MOCK_PORT] + mock_argv()
  271. return subprocess.Popen(argv, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
  272. def markdown_renderer_checks():
  273. """Exercise the stm-markdown renderer shipped inside statum.js via node."""
  274. status, body = http("GET", "/_statum/resource/statum.js", timeout=15)
  275. if status != 200:
  276. check(False, "statum.js served to the browser")
  277. return
  278. check("stm-markdown" in body, "statum.js ships the stm-markdown directive")
  279. def extract(name, source):
  280. start = source.index("function %s(" % name)
  281. depth = 0
  282. index = source.index("{", start)
  283. end = index
  284. while True:
  285. if source[end] == "{":
  286. depth += 1
  287. elif source[end] == "}":
  288. depth -= 1
  289. if depth == 0:
  290. break
  291. end += 1
  292. return source[start:end + 1]
  293. try:
  294. parts = [extract(name, body)
  295. for name in ("safeMarkdownUrl", "mdLinks", "mdInline", "renderMarkdown")]
  296. except ValueError:
  297. check(False, "statum.js carries the markdown renderer functions")
  298. return
  299. harness = (
  300. "%s\n%s\n%s\n%s\n"
  301. "console.log(renderMarkdown('# Title\\n\\n- **bold** item\\n- `code` item\\n"
  302. "\\n> quoted text\\n\\n```text\\nfenced\\n```'));\n"
  303. "console.log('@@' + renderMarkdown('<script>alert(1)</script> [bad](javascript:alert(2))"
  304. " [ok](https://example.com) <img src=x onerror=alert(3)>'));\n" % tuple(parts)
  305. )
  306. try:
  307. result = subprocess.run(["node", "-e", harness], capture_output=True, text=True, timeout=20)
  308. except (OSError, subprocess.SubprocessError):
  309. check(True, "stm-markdown renderer present (node unavailable, deep check skipped)")
  310. return
  311. rendered, _, xss = result.stdout.partition("@@")
  312. check("<h1>" in rendered and "<li>" in rendered and "<code>" in rendered
  313. and "<blockquote>" in rendered and "<strong>" in rendered,
  314. "stm-markdown renders headings, lists, code, quotes, bold")
  315. check("<script" not in xss and "<img" not in xss
  316. and 'href="javascript:' not in xss and 'href="#"' in xss
  317. and '<a href="https://example.com">ok</a>' in xss,
  318. "stm-markdown escapes raw tags, defangs javascript: links, keeps safe ones")
  319. def main():
  320. prepare_workspace()
  321. if db_query("select count(*) from tasks")[0][0] or db_query("select count(*) from goals")[0][0]:
  322. raise SystemExit("the web database is not fresh (tasks/goals exist) — "
  323. "wipe the DB and restart the app+daemon for a clean goal-era run")
  324. mock = start_mock()
  325. time.sleep(2.0)
  326. try:
  327. # A previous suite run (or a crashed orchestrator) can leave the
  328. # daemon parked on a block; /stop releases it so dispatch works.
  329. try:
  330. http("POST", SADDLE + "/stop", timeout=5)
  331. except (urllib.error.URLError, OSError):
  332. pass
  333. auth = login("admin", "adminpass123")
  334. check(True, "login yields an auth slot")
  335. state = slot_public(auth, "/projects", "projects")
  336. act(auth, state["create"], {
  337. "name": "demo",
  338. "git_url": DEMO,
  339. "default_branch": "main",
  340. "summary": "Integration test repository",
  341. })
  342. check(any(p["name"] == "demo" for p in slot_public(auth, "/projects", "projects")["projects"]),
  343. "project demo created (local git_url, pushable origin)")
  344. state = slot_public(auth, "/saddles", "saddles")
  345. act(auth, state["register"], {"name": SADDLE_NAME, "host": SADDLE_HOST})
  346. registered = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  347. if s["name"] == SADDLE_NAME]
  348. check(len(registered) == 1 and registered[0]["token"].startswith("saddle-"),
  349. "saddle registered at %s with an API token" % SADDLE_HOST)
  350. print("== goal create -> start ==")
  351. goals_state = slot_public(auth, "/goals", "goals")
  352. act(auth, goals_state["create"], {
  353. "project_id": "1",
  354. "title": "Greet the demo",
  355. "brief": "Add a greeting file to the demo repo end to end.",
  356. "branch_name": "goal/demo-run",
  357. "priority": "3",
  358. })
  359. goal1 = int([g["id"] for g in slot_public(auth, "/goals", "goals")["goals"]
  360. if g["title"] == "Greet the demo"][0])
  361. check(goal_state(auth, goal1)["status"] == "draft", "the goal lands in draft")
  362. act(auth, goal_state(auth, goal1)["start"], {})
  363. check(wait_goal_status(auth, goal1, "planning", timeout=30) is not None,
  364. "starting the goal moves it to planning (agent dispatched)")
  365. run_task = db_query(
  366. "select id from tasks where goal_id = ? and kind = 'goal_run'", (goal1,))[0][0]
  367. check(wait_for(lambda: task_state(auth, run_task)["status"]
  368. in ("assigned", "working", "blocked", "suspended") or False,
  369. timeout=30, what="goal run dispatched") is not None,
  370. "the goal-run task reaches the daemon (assigned/working/blocked)")
  371. goal_clone = os.path.join(WORKSPACE, "goal-%d" % goal1, "demo")
  372. check(wait_for(lambda: os.path.isdir(os.path.join(goal_clone, ".git")) or False,
  373. timeout=30, what="the goal clone") is not None,
  374. "the goal clone appears under the shared workspace root (goal-<id>)")
  375. branch = subprocess.run(["git", "-C", goal_clone, "branch", "--show-current"],
  376. capture_output=True, text=True).stdout.strip()
  377. check(branch == "goal/demo-run", "the goal clone sits on the user-named goal branch")
  378. print("== pre-plan question -> one answer, one row ==")
  379. check(wait_for(lambda: any(b["block_type"] == "question" and not b.get("answered")
  380. for b in goal_state(auth, goal1).get("blocks", [])) or False,
  381. timeout=90, what="the agent's question block") is not None,
  382. "the pre-plan question surfaces as a goal block")
  383. check(wait_task_status(auth, run_task, "blocked", timeout=30) is not None,
  384. "the goal-run task parks blocked on its own block row")
  385. question = [b for b in goal_state(auth, goal1)["blocks"]
  386. if b["block_type"] == "question" and not b.get("answered")][0]
  387. check(question["options"] == ["Demo only", "Whole workspace"],
  388. "the question carries its options")
  389. check("Which scope should the greeting cover" in question["question"],
  390. "the question title derives from the body's first line")
  391. check("confirm the blast radius" in question["body"], "the question carries the markdown body")
  392. dupes = db_query(
  393. "select count(*) from task_blocks tb join tasks t on t.id = tb.task_id"
  394. " where t.goal_id = ? and t.kind = 'goal_run' and tb.type = 'question'",
  395. (goal1,))[0][0]
  396. check(dupes == 1, "one question row despite the park-unwinding round-end push")
  397. dash = slot_public(auth, "/", "dashboard")
  398. check(any(int(r.get("goal_id") or 0) == goal1 for r in dash["pinned"]),
  399. "the dashboard pile pins the goal block")
  400. act(auth, question["answer_action"], {"option": "Demo only"})
  401. answered_rows = db_query(
  402. "select answer from task_blocks tb join tasks t on t.id = tb.task_id"
  403. " where t.goal_id = ? and t.kind = 'goal_run' and tb.type = 'question'",
  404. (goal1,))
  405. check(answered_rows and answered_rows[0][0] == "Demo only",
  406. "the ONE answer lands on the goal-run task's single row")
  407. print("== plan review: reject -> revise -> approve ==")
  408. check(wait_goal_status(auth, goal1, "plan_review", timeout=90) is not None,
  409. "the plan submission moves the goal to plan_review")
  410. check(wait_for(lambda: goal_state(auth, goal1).get("plan_pending_block") not in
  411. (None, False) or False, timeout=30, what="pending plan block") is not None,
  412. "the pending plan block renders on the goal page")
  413. pending = goal_state(auth, goal1)["plan_pending_block"]
  414. check("Plan v1" in pending["body"], "the plan block carries the submitted markdown")
  415. act(auth, pending["answer_action"], {"answer": "Feedback: also export the greeting"})
  416. check(wait_goal_status(auth, goal1, "planning", timeout=30) is not None,
  417. "rejecting with feedback returns the goal to planning")
  418. check(wait_for(lambda: goal_state(auth, goal1).get("plan_pending_block") not in
  419. (None, False) and "Plan v2" in
  420. goal_state(auth, goal1)["plan_pending_block"].get("body", "") or False,
  421. timeout=90, what="revised plan") is not None,
  422. "the agent revises and re-submits incorporating the feedback")
  423. revised = goal_state(auth, goal1)
  424. check(len([r for r in revised["plan_revisions"] if r.get("answered")]) >= 1,
  425. "the rejected revision stays discoverable behind the expander")
  426. act(auth, revised["plan_pending_block"]["answer_action"], {"answer": PLAN_APPROVED})
  427. check(wait_goal_status(auth, goal1, "active", timeout=30) is not None,
  428. "approving with the agreed wording activates the goal")
  429. print("== task fan-out via the real callbacks ==")
  430. worker_row = wait_for(lambda: db_query(
  431. "select id from tasks where goal_id = ? and kind = 'task'", (goal1,)) or False,
  432. timeout=90, what="the agent-created task")
  433. worker_task = int(worker_row[0][0])
  434. check(bool(worker_row), "the goal agent created its worker task through the callback")
  435. creator = db_query("select creator, mode from tasks where id = ?", (worker_task,))[0]
  436. check(creator == ("goal", "implement"),
  437. "the created task carries creator=goal and the requested mode")
  438. check(wait_task_status(auth, worker_task, "queued", timeout=30) is not None
  439. or task_state(auth, worker_task)["status"] in ("working", "done"),
  440. "the agent enqueued its task (creator-only rule honoured by construction)")
  441. ephemeral = "task/%d-%d" % (goal1, worker_task)
  442. check(wait_for(lambda: task_state(auth, worker_task)["status"]
  443. in ("assigned", "working", "done", "failed") or False,
  444. timeout=60, what="worker dispatched") is not None,
  445. "the dispatcher hands the worker to the freed saddle")
  446. check(wait_for(lambda: db_query(
  447. "select suspended_at is not null from tasks where id = ?", (run_task,))[0][0] or False,
  448. timeout=30, what="the goal agent parks") is not None,
  449. "the goal agent parked suspended after fanning out (suspended_at stamped)")
  450. print("== the worker's ephemeral worktree ==")
  451. branch_rows = wait_for(lambda: db_query(
  452. "select sc.output_text from session_commands sc join sessions s"
  453. " on s.id = sc.session_id where s.task_id = ?"
  454. " and sc.command_text like '%git branch --show-current%'", (worker_task,)) or False,
  455. timeout=90, what="the worker's branch probe")
  456. check(branch_rows and ephemeral in (branch_rows[0][0] or ""),
  457. "the worker runs inside its ephemeral branch %s" % ephemeral)
  458. worktree_dir = os.path.join(WORKSPACE, "task-%d" % worker_task)
  459. check(wait_for(lambda: task_state(auth, worker_task)["status"] == "done" or False,
  460. timeout=120, what="worker done") is not None,
  461. "the worker completes with its report")
  462. detail = task_state(auth, worker_task)
  463. check("greeting.txt" in (detail.get("summary") or ""),
  464. "the worker summary carries its completion report")
  465. check(not os.path.exists(worktree_dir), "the worker's worktree auto-pruned on done")
  466. kept = subprocess.run(["git", "-C", goal_clone, "rev-parse", "--verify", ephemeral],
  467. capture_output=True).returncode == 0
  468. check(kept, "the ephemeral branch survives in the goal clone for the merge")
  469. print("== coalesced resume -> merge -> push -> completion review ==")
  470. check(wait_for(lambda: any(
  471. e["kind"] == "task-done" and int(e["task_id"]) == worker_task
  472. for e in goal_state(goal1).get("events", [])) or False,
  473. timeout=30, what="the task-done narrative row") is not None,
  474. "the worker's terminal transition lands in the derived goal narrative")
  475. # the goal agent's /status reports the branch push state while it
  476. # works; sample the moment the resumed agent is mid-round, then
  477. # cache it through the manual saddle refresh
  478. check(wait_for(lambda: (saddle_status() or {}).get("goal_branches") or False,
  479. timeout=90, what="the resumed agent exposes goal_branches") is not None,
  480. "the daemon's /status exposes goal_branches while the goal agent works")
  481. saddles_state = slot_public(auth, "/saddles", "saddles")
  482. card = [s for s in saddles_state["saddles"] if s["name"] == SADDLE_NAME][0]
  483. act(auth, card["refresh"], {})
  484. check(wait_for(lambda: any("demo" in (b.get("branch") or "")
  485. for b in goal_state(auth, goal1).get("push_branches", []))
  486. or False, timeout=60, what="cached push state") is not None,
  487. "the refresh probe caches the branch push state on the goal")
  488. check(wait_goal_status(auth, goal1, "completion_review", timeout=120) is not None,
  489. "the resumed agent merges, pushes and enters completion review")
  490. state = goal_state(auth, goal1)
  491. check("greeting.txt added via one worker task" in state.get("completion_report", ""),
  492. "the completion report renders on the goal page")
  493. merged = subprocess.run(
  494. ["git", "-C", goal_clone, "show", "goal/demo-run:greeting.txt"],
  495. capture_output=True, text=True).stdout
  496. check("hello-from-the-goal" in merged, "the merged task branch landed on the goal branch")
  497. deleted = subprocess.run(["git", "-C", goal_clone, "rev-parse", "--verify", ephemeral],
  498. capture_output=True).returncode != 0
  499. check(deleted, "the goal agent deleted the merged task branch")
  500. pushed = subprocess.run(["git", "-C", DEMO, "rev-parse", "--verify", "goal/demo-run"],
  501. capture_output=True).returncode == 0
  502. check(pushed, "the goal branch was pushed to origin (push-before-review)")
  503. print("== completion accept ==")
  504. completion = state["completion_pending_block"]
  505. check(completion and completion.get("body", "") == state.get("completion_report", ""),
  506. "the run task's completion block carries the report")
  507. act(auth, completion["answer_action"], {"answer": REVIEW_APPROVED})
  508. check(wait_goal_status(auth, goal1, "complete", timeout=30) is not None,
  509. "accepting with the agreed wording completes the goal")
  510. check(wait_task_status(auth, run_task, "done", timeout=30) is not None,
  511. "the goal-run task closes done with the goal")
  512. print("== audit trail (the derived narrative) ==")
  513. narrative = goal_state(goal1)["events"]
  514. kinds = [e["kind"] for e in narrative]
  515. for expected in ("started", "block-raised", "plan-submitted", "plan-rejected",
  516. "plan-approved", "task-created", "task-done",
  517. "completion-submitted", "goal-approved"):
  518. check(expected in kinds, "the derived narrative records %s" % expected)
  519. check(all(k != "task-enqueued" for k in kinds),
  520. "timestamp-less bookkeeping never returns (task-enqueued is gone)")
  521. check(db_query(
  522. "select count(*) from sqlite_master where type = 'table'"
  523. " and name = 'goal_events'")[0][0] == 0,
  524. "the goal_events table is gone (nothing is written on ingest)")
  525. run_types = {row[0] for row in db_query(
  526. "select distinct sc.type from session_commands sc join sessions s"
  527. " on s.id = sc.session_id where s.task_id = ?", (run_task,))}
  528. check({"bash", "block", "plan"} <= run_types,
  529. "the goal agent's terminal carries typed entries (%s)" % sorted(run_types))
  530. worker_types = {row[0] for row in db_query(
  531. "select distinct sc.type from session_commands sc join sessions s"
  532. " on s.id = sc.session_id where s.task_id = ?", (worker_task,))}
  533. check({"bash", "done"} <= worker_types,
  534. "the worker's terminal carries typed entries (%s)" % sorted(worker_types))
  535. print("== SSE ==")
  536. run_session = db_query(
  537. "select sc.session_id from session_commands sc join sessions s on s.id = sc.session_id"
  538. " where s.task_id = ? and sc.type = 'plan' order by sc.id limit 1",
  539. (run_task,))[0][0]
  540. state = goal_state(auth, goal1)
  541. check(any(r.get("terminal_url", "").endswith("/terminal?session_id=%d" % run_session)
  542. for r in state.get("runs", [])),
  543. "the goal page's run list deep-links each session terminal")
  544. request = urllib.request.Request(WEB + "/terminal-stream/%d" % run_session)
  545. with urllib.request.urlopen(request, timeout=15) as stream:
  546. check(stream.headers.get("Content-Type", "").startswith("text/event-stream"),
  547. "terminal stream is text/event-stream")
  548. entries = []
  549. deadline = time.monotonic() + 10
  550. while time.monotonic() < deadline and len(entries) < 120:
  551. if any(e.get("type") == "done" for e in entries):
  552. break
  553. line = stream.readline().decode().strip()
  554. if not line.startswith("data: ") or line == "data: ping":
  555. continue
  556. entries.append(json.loads(line[6:]))
  557. check(len(entries) >= 4, "terminal stream replays the run's backlog (%d entries)" % len(entries))
  558. # push-era sessions stream their raw PTY log (the typed
  559. # commands live in session_commands) — the plan submission
  560. # shows as its echoed heredoc in the raw replay
  561. check(any("saddle plan" in json.dumps(e) for e in entries),
  562. "terminal stream replay carries the plan submission round")
  563. pulse = directives("/goals/%d" % goal1, auth)
  564. check(any(d.get("type") == "subscribe" for d in pulse),
  565. "goal page subscribes to the pulse channel for live refresh")
  566. print("== legacy ingest API surface ==")
  567. saddles_state = slot_public(auth, "/saddles", "saddles")
  568. act(auth, saddles_state["register"], {"name": "saddle-e2e-api", "host": "localhost:7199"})
  569. api_rows = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  570. if s["name"] == "saddle-e2e-api"]
  571. check(len(api_rows) == 1 and api_rows[0]["token"].startswith("saddle-"),
  572. "registration returns an API token")
  573. api_saddle = int(api_rows[0]["id"])
  574. api_token = api_rows[0]["token"]
  575. tasks_state = slot_public(auth, "/tasks", "tasks")
  576. act(auth, tasks_state["create"], {
  577. "project_id": "1",
  578. "title": "API surface probe",
  579. "description": "Typed updates, exactly-once poll",
  580. "priority": "6",
  581. "task_branch": "task/api-surface",
  582. })
  583. api_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  584. if t["title"] == "API surface probe"][0])
  585. db = sqlite3.connect(DB_PATH)
  586. db.execute("insert into sessions (task_id, saddle_id, started_at)"
  587. " values (?, ?, strftime('%s','now'))", (api_task, api_saddle))
  588. db.execute("update saddles set current_task_id = ?, status = 'idle' where id = ?",
  589. (api_task, api_saddle))
  590. db.execute("update tasks set status = 'working' where id = ?", (api_task,))
  591. db.commit()
  592. api_session = db.execute("select max(id) from sessions").fetchone()[0]
  593. db.close()
  594. status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, None,
  595. {"type": "comment", "session_id": api_session, "data": {"body": "x"}})
  596. check(status == 401, "update without a token is rejected")
  597. status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, "saddle-not-a-token",
  598. {"type": "comment", "session_id": api_session, "data": {"body": "x"}})
  599. check(status == 401, "update with an invalid token is rejected")
  600. status, body = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  601. {"type": "comment", "session_id": api_session,
  602. "data": {"body": "Surveyed the module"}})
  603. check(status == 200 and body.get("ok"), "comment update accepted with token")
  604. status, body = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  605. {"type": "block", "session_id": api_session, "data": {
  606. "question": "Which database?",
  607. "type": "question",
  608. "options": ["SQLite", "Postgres"],
  609. "body": "# Pick one"}})
  610. check(status == 200 and body.get("block_id"), "typed block returns block_id")
  611. status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  612. {"type": "output", "session_id": api_session, "data": {
  613. "command": "cat notes.md | saddle query 'meaning?'",
  614. "output": "42",
  615. "type": "query",
  616. "input": "# notes\nmeaning of life"}})
  617. check(status == 200, "query output update accepted")
  618. status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  619. {"type": "progress", "session_id": api_session,
  620. "data": {"summary": "Migrated the schemas"}})
  621. check(status == 200, "progress update accepted")
  622. status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  623. {"type": "review", "session_id": api_session,
  624. "data": {"summary": "Migration **ready** for review"}})
  625. check(status == 200, "review update accepted")
  626. status, body = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  627. {"type": "plan", "session_id": api_session,
  628. "data": {"plan": "# Plan\n- migrate schemas"}})
  629. check(status == 400 and "gone" in (body.get("error") or ""),
  630. "the task-plan update type fails loudly (plan 12 removal)")
  631. status, body = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  632. {"type": "subtask", "session_id": api_session, "data": {}})
  633. check(status == 400 and "gone" in (body.get("error") or ""),
  634. "the subtask update type fails loudly (plan 12 removal)")
  635. detail = task_state(auth, api_task)
  636. check(detail["status"] == "review_requested", "review flips the task status")
  637. check([b["options"] for b in detail["blocks"]
  638. if b["block_type"] == "question"] == [["SQLite", "Postgres"]],
  639. "question block carries its options")
  640. check(detail["progress"][0]["summary"] == "Migrated the schemas",
  641. "progress history newest-first")
  642. question = [b for b in detail["blocks"] if b["block_type"] == "question"][0]
  643. act(auth, question["answer_action"], {"option": "SQLite"})
  644. check([b["answer"] for b in task_state(auth, api_task)["blocks"]
  645. if b["block_type"] == "question"] == ["SQLite"],
  646. "option answer stored verbatim (the resume notice formats it)")
  647. act(auth, detail["comment"],
  648. {"body": "Check the migration output", "quote": "ERROR: table demos exists"})
  649. status, body = saddle_api("GET", "/api/saddle/%d/poll" % api_saddle, api_token)
  650. check(any(a["answer"] == "SQLite" for a in body.get("answers", [])),
  651. "poll hands over the block answer")
  652. check(any(c["body"] == "Check the migration output" and "ERROR" in c["quote"]
  653. for c in body.get("comments", [])),
  654. "poll hands over the quoted user comment")
  655. status, body = saddle_api("GET", "/api/saddle/%d/poll" % api_saddle, api_token)
  656. check(body.get("answers") == [] and body.get("comments") == [],
  657. "poll delivers answers and comments exactly once")
  658. act(auth, detail["cancel"], {"task_id": str(api_task)})
  659. print("== stm-markdown ==")
  660. markdown_renderer_checks()
  661. print("== quiesce ==")
  662. check(wait_for(
  663. lambda: (lambda rows: all(int(s["current_task_id"] or 0) == 0 and s["status"] == "idle"
  664. for s in rows))(
  665. [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  666. if s["name"] == SADDLE_NAME]) or False,
  667. timeout=30, what="saddle idle with no current task") is not None,
  668. "saddle freed and idle once the goal finished")
  669. finally:
  670. if mock is not None:
  671. mock.terminate()
  672. print("%d passed, %d failed" % (passes, failures))
  673. return 1 if failures else 0
  674. if __name__ == "__main__":
  675. sys.exit(main())