pages.py 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798
  1. """The pages bundle (moved verbatim from the pre-split web_stack_test.py;
  2. see test/harness.py for the shared machinery and the runner)."""
  3. from harness import * # noqa: F401,F403 — the shared wire helpers
  4. # =============================================================================
  5. # Bundle: pages — block presentation, diff URLs, computed display state,
  6. # terminal rendering (rejected entries, ANSI SGR) and task-page layout.
  7. # Fixture-driven: tasks and sessions are inserted as rows; blocks are
  8. # raised through the push-era wire (POST /api/saddle/results with a
  9. # blocked state) or inserted directly for terminal-task fixtures; only
  10. # the rejected-ride scenario dispatches.
  11. # =============================================================================
  12. def run(ctx):
  13. auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
  14. app = ctx.app
  15. def raise_block(session_id, question, kind="question", options=None, body=None):
  16. """The push-era block raise: a blocked round-results push
  17. carrying the block payload (the poll-era /update channel is
  18. gone — 404 below pins that)."""
  19. block = {"question": question, "type": kind}
  20. if options is not None:
  21. block["options"] = options
  22. if body is not None:
  23. block["body"] = body
  24. return saddle_api("POST", "/api/saddle/results", fake_token,
  25. {"session_id": session_id, "state": "blocked",
  26. "block": block})
  27. check(True, "login yields an auth slot")
  28. print("== the poll-era wire is gone (arch 19) ==")
  29. # The daemon never called update/poll (verified against saddle/src);
  30. # the routes 404 for any caller, authenticated or not.
  31. status, _ = saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
  32. {"type": "status", "session_id": 1,
  33. "data": {"state": "working"}})
  34. check(status == 404, "POST /api/saddle/{id}/update is gone (404)")
  35. status, _ = saddle_api("GET", "/api/saddle/%d/poll" % fake_id, fake_token)
  36. check(status == 404, "GET /api/saddle/{id}/poll is gone (404)")
  37. print("== blocks: push-era raises + body presentation ==")
  38. conn = db()
  39. conn.execute("insert into tasks (project_id, title, status, priority,"
  40. " created_at)"
  41. " values (1, 'Block display task', 'working', 5,"
  42. " strftime('%s','now'))")
  43. plan_task = conn.execute("select max(id) from tasks").fetchone()[0]
  44. conn.execute("insert into sessions (task_id, saddle_id, started_at, ended_at, outcome)"
  45. " values (?, ?, strftime('%s','now'), strftime('%s','now'), 'done')",
  46. (plan_task, fake_id))
  47. conn.commit()
  48. session_id = conn.execute("select max(id) from sessions").fetchone()[0]
  49. conn.close()
  50. status, _ = raise_block(session_id, "Which database?", kind="question",
  51. options=["SQLite", "Postgres"], body="# Storage choice")
  52. check(status == 200, "question block raised through the results push")
  53. print("== B1: highlight-to-comment quote dialog ==")
  54. status, page = http("GET", "/tasks/%d" % plan_task, timeout=15)
  55. check(status == 200 and "quote-comment-dialog" in page and "quote-text" in page,
  56. "task page carries the quote-comment dialog the popover opens")
  57. check('name="quote"' in page and 'name="return_to"' in page,
  58. "quote dialog posts the quote and return-to fields")
  59. status, page = http("GET", "/tasks/%d/terminal" % plan_task, timeout=15)
  60. check(status == 200 and "quote-comment-dialog" in page,
  61. "terminal page keeps its quote dialog")
  62. print("== B3: review section hides the duplicate body ==")
  63. status, _ = raise_block(session_id, "Review the diff", kind="review",
  64. body="# The change\n\nReworks the flange module.")
  65. check(status == 200, "review block raised through the results push")
  66. detail = task_state(auth, plan_task)
  67. review_block = [b for b in detail["blocks"] if b["block_type"] == "review"][0]
  68. check(detail.get("review_pending") is True,
  69. "review section shows while the review is unanswered")
  70. check(review_block.get("show_body") is False,
  71. "pending review block hides its duplicate body preview")
  72. check(review_block.get("body", "").startswith("# The change"),
  73. "review body itself still arrives in state (B2 counterpart)")
  74. act(auth, review_block["answer_action"], {"verdict": "accept"})
  75. detail = task_state(auth, plan_task)
  76. review_block = [b for b in detail["blocks"] if b["block_type"] == "review"][0]
  77. check(review_block.get("show_body") is True,
  78. "answered review shows the body again (banner gone)")
  79. check(review_block.get("answer") == "Review approved. Continue with the task.",
  80. "the review accept form's verdict marker resolves to the stable wording")
  81. # Plan 16 S2/R4: no verdict constant ships in any template — the
  82. # accept forms carry only the verdict marker.
  83. task_page_html = http_get_page(auth, "/tasks/%d" % plan_task)
  84. check("Plan approved. Proceed with implementation." not in task_page_html
  85. and "Review approved. Continue with the task." not in task_page_html,
  86. "the task page ships no verdict constants (server-owned wording)")
  87. check('name="verdict" value="accept"' in task_page_html,
  88. "the accept forms post the verdict marker")
  89. # Plan 16 S5 (U1): verdict-shaped blocks keep their explicit
  90. # buttons + free-text path; a question with NULL options renders
  91. # the question form (radio "other" + free reply), never a
  92. # free-text-only verdict.
  93. check("Request changes" in task_page_html and 'name="feedback"' in task_page_html,
  94. "the review verdict keeps Request changes with free-text feedback (U1)")
  95. check('value="__other__"' in task_page_html and 'name="other"' in task_page_html,
  96. "question blocks render the option/other question form (U1)")
  97. status, _ = raise_block(session_id, "Free-text thought?", kind="question")
  98. check(status == 200, "a NULL-options question block raises")
  99. bare_question = [b for b in task_state(auth, plan_task)["blocks"]
  100. if b["question"] == "Free-text thought?"][0]
  101. check(bare_question.get("options") == []
  102. and bare_question.get("block_type") == "question",
  103. "a NULL-options question stays a question (no options, not a verdict)")
  104. print("== B2: question block body above options ==")
  105. # Contract side (A2 will restore bodies end-to-end): a question
  106. # block raised with a heredoc body carries it; the body preview
  107. # rides above the options (template order) and survives answering
  108. # alongside the selected answer. (Blocks list newest-first; the
  109. # U1 fixture above raised a second, options-less question.)
  110. detail = task_state(auth, plan_task)
  111. question = [b for b in detail["blocks"]
  112. if b["block_type"] == "question"
  113. and b["body"] == "# Storage choice"][0]
  114. check(question.get("body") == "# Storage choice"
  115. and question.get("show_body") is True,
  116. "question block keeps its body markdown with the preview on")
  117. act(auth, question["answer_action"], {"option": "SQLite"})
  118. detail = task_state(auth, plan_task)
  119. question = [b for b in detail["blocks"]
  120. if b["block_type"] == "question"
  121. and b["body"] == "# Storage choice"][0]
  122. check(question.get("answered") is True and question.get("answer") == "SQLite"
  123. and question.get("body") == "# Storage choice"
  124. and question.get("show_body") is True,
  125. "answered question shows body + selected answer")
  126. print("== B5: compare-changes SSH -> HTTPS ==")
  127. def diff_url_for_project(git_url, suffix):
  128. state = slot_public(auth, "/projects", "projects")
  129. act(auth, state["create"], {"name": "diff-%s" % suffix, "git_url": git_url})
  130. project_id = int([p["id"] for p in slot_public(auth, "/projects", "projects")["projects"]
  131. if p["name"] == "diff-%s" % suffix][0])
  132. conn = db()
  133. conn.execute("insert into tasks (project_id, title, status, task_branch,"
  134. " branch_from, created_at) values (?, ?, 'working', 'feature/x',"
  135. " 'main', strftime('%s','now'))", (project_id, "Diff %s" % suffix))
  136. conn.commit()
  137. task_id = conn.execute("select max(id) from tasks").fetchone()[0]
  138. conn.execute("insert into sessions (task_id, saddle_id, started_at, ended_at,"
  139. " outcome) values (?, ?, strftime('%s','now'), strftime('%s','now'),"
  140. " 'done')", (task_id, fake_id))
  141. conn.commit()
  142. session = conn.execute("select max(id) from sessions").fetchone()[0]
  143. conn.close()
  144. status, _ = raise_block(session, "Review the diff", kind="review")
  145. return task_state(auth, task_id).get("review_diff_url") or ""
  146. check(diff_url_for_project("ssh://fabrica.unitatem.net:222/clanker/clanker-saddle.git",
  147. "fabrica")
  148. == "https://fabrica.unitatem.net/clanker/clanker-saddle/compare/main...feature/x",
  149. "ssh://git@host:port/owner/repo.git translates (port dropped)")
  150. check(diff_url_for_project("git@github.com:acme/widgets.git", "scp")
  151. == "https://github.com/acme/widgets/compare/main...feature/x",
  152. "git@host:owner/repo.git translates to the browse host")
  153. check(diff_url_for_project("https://example.com/plain/repo", "plain")
  154. == "https://example.com/plain/repo/compare/main...feature/x",
  155. "HTTPS URLs pass through unchanged")
  156. print("== B6: computed display state ==")
  157. # Draft tasks display as draft (plan 11) and never dispatch.
  158. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  159. "project_id": "1",
  160. "title": "Draft display task",
  161. "priority": "5",
  162. })
  163. draft_display = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  164. if t["title"] == "Draft display task"][0])
  165. detail = task_state(auth, draft_display)
  166. check(detail["status"] == "draft" and detail.get("display") == "draft",
  167. "new tasks default to draft and display it")
  168. check(detail.get("draft") is True and detail.get("enqueue") is not None
  169. and detail.get("delete") is not None,
  170. "draft tasks expose the enqueue + delete actions")
  171. check(quiet_window(quiet_seconds(),
  172. lambda: task_state(auth, draft_display)["status"] == "draft",
  173. "draft task never dispatches"),
  174. "a draft task is never dispatched or woken for")
  175. # Plan 12: the waiting-on-drafts block is deleted (goal tasks are
  176. # the goal agent's to sequence); a parked task displays plainly.
  177. conn = db()
  178. conn.execute("insert into tasks (project_id, title, status, priority, created_at)"
  179. " values (1, 'Parked plain task', 'suspended', 5, strftime('%s','now'))")
  180. conn.commit()
  181. conn.close()
  182. parked_id = db().execute("select max(id) from tasks").fetchone()[0]
  183. detail = task_state(auth, parked_id)
  184. check(detail.get("display") == "parked",
  185. "a suspended task displays as parked (%s)" % detail.get("display"))
  186. dash = slot_public(auth, "/", "dashboard")
  187. check(not dash.get("waiting_drafts"),
  188. "the waiting-on-drafts block is gone from the dashboard data")
  189. status, home_page = http("GET", "/", timeout=15)
  190. check(status == 200 and "Waiting on subtasks to be enqueued" not in home_page,
  191. "the dashboard renders no waiting-on-drafts section")
  192. conn = db()
  193. conn.execute("insert into tasks (project_id, title, status, priority, created_at)"
  194. " values (1, 'Parent', 'done', 5, strftime('%s','now'))")
  195. parent6 = conn.execute("select max(id) from tasks").fetchone()[0]
  196. # The post-mortem case: stored done WITH an unanswered review block.
  197. # (A terminal task consumes result pushes silently — plan 13 B2 —
  198. # so the row is inserted directly, the goalui deep-link pattern.)
  199. conn.execute("insert into task_blocks (task_id, question, type, created_at)"
  200. " values (?, 'Review the final diff', 'review',"
  201. " strftime('%s','now'))", (parent6,))
  202. conn.commit()
  203. conn.close()
  204. detail = task_state(auth, parent6)
  205. check(detail.get("display") == "review requested" and detail.get("terminal") is True,
  206. "unanswered review overrides the stored done status (terminal flag keeps stored truth)")
  207. queue = [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  208. if int(t["id"]) == parent6][0]
  209. check(queue["display"] == "review requested",
  210. "queue row shows the computed review-requested display")
  211. # With the review answered the display falls back to the stored
  212. # status (the plan-12 shape: no subtask-delegation states).
  213. conn = db()
  214. conn.execute("update task_blocks set answered_at = strftime('%s','now'),"
  215. " answer = 'Human approved the review' where task_id = ?", (parent6,))
  216. conn.commit()
  217. conn.close()
  218. detail = task_state(auth, parent6)
  219. check(detail.get("display") == "done" and detail.get("terminal") is True,
  220. "answered review falls back to the stored done display (%s)"
  221. % detail.get("display"))
  222. # Flags track the stored status on failure.
  223. conn = db()
  224. conn.execute("update tasks set status = 'failed', completed_at = strftime('%s','now')"
  225. " where id = ?", (parent6,))
  226. conn.commit()
  227. conn.close()
  228. detail = task_state(auth, parent6)
  229. check(detail.get("display") == "failed" and detail.get("failed") is True
  230. and detail.get("terminal") is True,
  231. "failed task with answered review shows failed + terminal flags")
  232. print("== rejected entries (non-fatal) ==")
  233. # The push wire: a rejected entry rides a round-results push, stores
  234. # on the session and streams through SSE; the task is untouched.
  235. status, _ = saddle_api("POST", "/api/saddle/results", fake_token, {
  236. "session_id": session_id, "state": "working",
  237. "entries": [{
  238. "id": 9021, "command": "bash -n script.sh",
  239. "output": "syntax error near line 3 (recoverable)",
  240. "exit_code": 1, "type": "rejected",
  241. "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
  242. }]})
  243. check(status == 200, "a rejected entry rides the results push")
  244. request = urllib.request.Request(WEB + "/terminal-stream/%d" % session_id)
  245. with urllib.request.urlopen(request, timeout=15) as stream:
  246. rejected_event = None
  247. deadline = time.monotonic() + 8
  248. while time.monotonic() < deadline and rejected_event is None:
  249. line = stream.readline().decode().strip()
  250. if not line.startswith("data: ") or line == "data: ping":
  251. continue
  252. event = json.loads(line[6:])
  253. if event.get("type") == "rejected":
  254. rejected_event = event
  255. check(rejected_event is not None
  256. and "syntax error near line 3" in (rejected_event.get("output") or ""),
  257. "terminal stream carries rejected entries with their reason")
  258. # The page template renders them with the distinct non-fatal style.
  259. status, page = http("GET", "/tasks/%d/terminal" % plan_task, timeout=15)
  260. check(status == 200 and "term-rejected" in page,
  261. "terminal page renders rejected entries in their own style")
  262. print("== B8: ANSI SGR in the terminal view ==")
  263. # Byte-exact SGR delivery: the daemon's coloured chunks flow through
  264. # the store + SSE untouched (the client renderer styles them).
  265. coloured = "\x1b[1;32mbold green\x1b[0m then \x1b[38;5;196m256-red\x1b[0m\n"
  266. conn = db()
  267. conn.execute("insert into session_raw_log (session_id, seq, chunk)"
  268. " values (?, 900, ?)", (session_id, coloured))
  269. conn.commit()
  270. conn.close()
  271. request = urllib.request.Request(WEB + "/terminal-stream/%d" % session_id)
  272. with urllib.request.urlopen(request, timeout=15) as stream:
  273. coloured_event = None
  274. deadline = time.monotonic() + 8
  275. while time.monotonic() < deadline and coloured_event is None:
  276. line = stream.readline().decode().strip()
  277. if not line.startswith("data: ") or line == "data: ping":
  278. continue
  279. event = json.loads(line[6:])
  280. if event.get("type") == "raw" and "\x1b[1;32m" in (event.get("text") or ""):
  281. coloured_event = event
  282. check(coloured_event is not None
  283. and "\x1b[38;5;196m" in coloured_event["text"],
  284. "SGR sequences stream byte-exact to the client renderer")
  285. status, page = http("GET", "/tasks/%d/terminal" % plan_task, timeout=15)
  286. check(status == 200 and "makeAnsiRenderer" in page and "colour256" in page,
  287. "terminal page embeds the ANSI renderer (SGR + 256-colour palette)")
  288. # Dispatch path: a rejected entry in a round's results push flows
  289. # into the audit trail without failing (or finishing) the task.
  290. task_id_tmp = create_queued(auth, {
  291. "project_id": "1",
  292. "title": "Rejected ride",
  293. "priority": "2",
  294. })
  295. rejected_task = task_id_tmp
  296. check(wait_for(lambda: task_state(auth, rejected_task).get("status") == "working"
  297. or False, timeout=60, what="rejected-ride working") is not None,
  298. "rejected-ride task reaches working")
  299. fake.queue_output("rejected", "echo 'too long' | saddle",
  300. "max_tokens exceeded (recoverable)")
  301. check(wait_task_done(auth, rejected_task, timeout=60) is not None,
  302. "task completes normally past rejected entries")
  303. conn = db()
  304. stored = conn.execute(
  305. "select count(*) from session_commands sc join sessions s on s.id = sc.session_id"
  306. " where s.task_id = ? and sc.type = 'rejected'", (rejected_task,)).fetchone()[0]
  307. conn.close()
  308. check(stored >= 1,
  309. "rejected entries stored in the audit trail (%d rows)" % stored)
  310. check(task_state(auth, rejected_task)["status"] == "done"
  311. and "fake daemon finished" in (task_state(auth, rejected_task).get("summary") or ""),
  312. "rejected entries never fail the task")
  313. print("== plan 11 task D: task page — progress first, 50/50 grid, expanders ==")
  314. detail = task_state(auth, plan_task)
  315. block_ids = [int(b["id"]) for b in detail.get("blocks", [])]
  316. check(len(block_ids) >= 3 and block_ids == sorted(block_ids, reverse=True),
  317. "the blocks section orders newest-first via the sealed GET state (%s)"
  318. % block_ids)
  319. status, page = http("GET", "/tasks/%d" % plan_task, timeout=15)
  320. # Plan 11 task D: the progress feed sits ABOVE the status card;
  321. # status + description share a 50/50 grid; the plan detail hides
  322. # behind its expander below it.
  323. check(status == 200 and "grid-2" in page
  324. and page.index("<h3>Progress</h3>") < page.index("status-head"),
  325. "the progress feed renders above the status card")
  326. check(page.index("grid-2") < page.index("status-head")
  327. and page.index("status-head") < page.index("<h3>Mandate</h3>"),
  328. "status and description sit side by side inside the grid (status first)")
  329. check("<h3>Mandate</h3>" in page
  330. and "plan-expander" not in page,
  331. "the task page carries no plan expander (plans live on the goal)")
  332. # Answered blocks fold behind the history expander (this fixture's
  333. # three blocks are all answered by now).
  334. check("blocks-history" in page and "Answered blocks" in page,
  335. "answered blocks hide behind the blocks history expander")
  336. conn = db()
  337. conn.execute("insert into tasks (project_id, title, status, priority,"
  338. " created_at) values (1, 'Progress layout task', 'working', 5,"
  339. " strftime('%s','now','-120 seconds'))")
  340. layout_task = conn.execute("select max(id) from tasks").fetchone()[0]
  341. # backdated so the dashboard-timeline fixtures below stay the
  342. # newest events
  343. conn.execute("insert into task_progress (task_id, summary, created_at)"
  344. " values (?, 'first update', strftime('%s','now','-90 seconds'))",
  345. (layout_task,))
  346. conn.execute("insert into task_progress (task_id, summary, created_at)"
  347. " values (?, 'second update', strftime('%s','now','-89 seconds'))",
  348. (layout_task,))
  349. conn.commit()
  350. conn.close()
  351. status, history_page = http("GET", "/tasks/%d" % layout_task, timeout=15)
  352. check(status == 200
  353. and history_page.index("progress-latest") < history_page.index("progress-history"),
  354. "the progress timeline renders newest-first (latest above the history)")
  355. # The edit expander sits UNDER the "Show history" expander.
  356. check(history_page.index("Show history") < history_page.index("edit-expander"),
  357. "the edit expander sits under the Show history expander")
  358. print("== heredoc completion reports: markdown card + timeline snippet ==")
  359. # The daemon's `cat << 'EOF' | saddle done` stores a markdown
  360. # summary (the failure path stores the same field); the task page
  361. # renders it as a full-width completion-report card directly below
  362. # the status/description grid — markdown via stm-markdown
  363. # (XSS-safe), "Completion report" when done, "Failure summary"
  364. # when failed, no card when the summary is empty. The dashboard
  365. # timeline snippets the summary's FIRST line.
  366. report_md = ("## Flange wired and tested\n\n"
  367. "Ran `meson test` over the full matrix.\n\n"
  368. "```sh\nmeson test -C builddir\n```\n\n"
  369. "<b>not html</b> & escaped\n")
  370. conn = db()
  371. conn.execute("insert into tasks (project_id, title, status, priority,"
  372. " summary, started_at, completed_at, created_at)"
  373. " values (1, 'Report done task', 'done', 5, ?,"
  374. " strftime('%s','now'), strftime('%s','now'),"
  375. " strftime('%s','now'))", (report_md,))
  376. report_done = conn.execute("select max(id) from tasks").fetchone()[0]
  377. conn.execute("insert into tasks (project_id, title, status, priority,"
  378. " summary, started_at, completed_at, created_at)"
  379. " values (1, 'Report failed task', 'failed', 5,"
  380. " 'Boom: tests failed\n\nsecond line carries the detail',"
  381. " strftime('%s','now'), strftime('%s','now'),"
  382. " strftime('%s','now'))")
  383. report_failed = conn.execute("select max(id) from tasks").fetchone()[0]
  384. conn.execute("insert into tasks (project_id, title, status, priority,"
  385. " started_at, completed_at, created_at)"
  386. " values (1, 'Report empty task', 'done', 5,"
  387. " strftime('%s','now'), strftime('%s','now'),"
  388. " strftime('%s','now'))")
  389. report_empty = conn.execute("select max(id) from tasks").fetchone()[0]
  390. conn.commit()
  391. conn.close()
  392. done_state = task_state(auth, report_done)
  393. check(done_state["report_title"] == "Completion report"
  394. and "## Flange wired" in (done_state.get("summary") or ""),
  395. "a done task's summary rides the report card state")
  396. check(task_state(auth, report_failed)["report_title"] == "Failure summary",
  397. "a failed task's card heading reads Failure summary")
  398. empty_state = task_state(auth, report_empty)
  399. check(empty_state["report_title"] == "" and not (empty_state.get("summary") or ""),
  400. "an empty summary renders no report card (no heading)")
  401. check(task_state(auth, layout_task)["report_title"] == "",
  402. "non-terminal tasks never show the report card")
  403. status, page = http("GET", "/tasks/%d" % report_done, timeout=15)
  404. check(status == 200 and "completion-report" in page
  405. and page.index("grid-2") < page.index("completion-report")
  406. < page.index("edit-expander"),
  407. "the completion-report card sits below the status/description grid, full width")
  408. card = page[page.index("completion-report"):page.index("edit-expander")]
  409. check('stm-markdown="task.summary"' in card
  410. and 'stm-text="task.report_title"' in card,
  411. "the card renders the summary as markdown under its state-driven heading")
  412. check("<strong>Summary:</strong>" not in page,
  413. "the old plain-text summary line is gone (the card owns the summary)")
  414. # The markdown itself renders client-side: drive the SHIPPED
  415. # stm-markdown renderer (statum.js) on the fixture summary via
  416. # node — headings and code come out as HTML, no markdown syntax
  417. # leaks, raw HTML stays escaped.
  418. status, statum_js = http("GET", "/_statum/resource/statum.js", timeout=15)
  419. skip = None if status == 200 else "statum.js not served"
  420. parts = []
  421. if skip is None:
  422. def extract_md(name, source):
  423. start = source.index("function %s(" % name)
  424. depth = 0
  425. index = source.index("{", start)
  426. end = index
  427. while True:
  428. if source[end] == "{":
  429. depth += 1
  430. elif source[end] == "}":
  431. depth -= 1
  432. if depth == 0:
  433. break
  434. end += 1
  435. return source[start:end + 1]
  436. try:
  437. parts = [extract_md(name, statum_js)
  438. for name in ("safeMarkdownUrl", "mdLinks", "mdInline", "renderMarkdown")]
  439. except ValueError:
  440. skip = "renderer functions missing"
  441. rendered = ""
  442. if skip is None:
  443. try:
  444. result = subprocess.run(
  445. ["node", "-e", "%s\n%s\n%s\n%s\nconsole.log(renderMarkdown(process.argv[1]));"
  446. % tuple(parts), report_md],
  447. capture_output=True, text=True, timeout=20)
  448. rendered = result.stdout
  449. except (OSError, subprocess.SubprocessError):
  450. skip = "node unavailable"
  451. if skip is not None:
  452. check(True, "stm-markdown renderer present (%s, deep check skipped)" % skip)
  453. else:
  454. check("<h2>Flange wired and tested</h2>" in rendered
  455. and "<pre><code" in rendered and "<code>meson test</code>" in rendered,
  456. "the report summary renders headings and code through stm-markdown")
  457. check("## Flange" not in rendered and "```sh" not in rendered
  458. and "`meson test`" not in rendered,
  459. "no raw markdown syntax leaks into the rendered report")
  460. check("<b>not html</b>" not in rendered and "&lt;b&gt;not html&lt;/b&gt;" in rendered
  461. and "&amp; escaped" in rendered,
  462. "raw HTML in the summary stays escaped (XSS-safe)")
  463. # The dashboard timeline: completion/failure events snippet the
  464. # summary's first line, falling back to the bare verdict text.
  465. dash = slot_public(auth, "/", "dashboard")
  466. done_event = next((e for e in dash.get("activity", [])
  467. if e["kind"] == "completed" and int(e["task_id"]) == report_done), None)
  468. check(done_event is not None
  469. and done_event["text"] == "Task completed: ## Flange wired and tested",
  470. "the timeline completion event snippets the summary's first line")
  471. failed_event = next((e for e in dash.get("activity", [])
  472. if e["kind"] == "failed" and int(e["task_id"]) == report_failed), None)
  473. check(failed_event is not None
  474. and failed_event["text"] == "Task failed: Boom: tests failed",
  475. "the timeline failure event snippets the summary's first line")
  476. empty_event = next((e for e in dash.get("activity", [])
  477. if e["kind"] == "completed" and int(e["task_id"]) == report_empty), None)
  478. check(empty_event is not None and empty_event["text"] == "Task completed",
  479. "a missing summary falls back to the bare timeline text")
  480. print("== plan 11 task D: block split + queue-screen layout ==")
  481. # One open and one answered question: the open block renders in
  482. # full, the answered one folds behind the history expander.
  483. conn = db()
  484. conn.execute("insert into sessions (task_id, saddle_id, started_at)"
  485. " values (?, ?, strftime('%s','now'))", (layout_task, fake_id))
  486. conn.commit()
  487. split_session = conn.execute("select max(id) from sessions").fetchone()[0]
  488. conn.close()
  489. raise_block(split_session, "Open question for layout?", kind="question")
  490. raise_block(split_session, "Answered question for layout?", kind="question")
  491. answered_block = [b for b in task_state(auth, layout_task)["blocks"]
  492. if b["question"] == "Answered question for layout?"][0]
  493. act(auth, answered_block["answer_action"], {"answer": "Yes"})
  494. detail = task_state(auth, layout_task)
  495. check(len(detail.get("open_blocks", [])) == 1
  496. and len(detail.get("answered_blocks", [])) == 1
  497. and detail["open_blocks"][0]["question"] == "Open question for layout?"
  498. and detail["answered_blocks"][0]["question"] == "Answered question for layout?",
  499. "the task slot splits blocks into open (current) and answered lists")
  500. status, page = http("GET", "/tasks/%d" % layout_task, timeout=15)
  501. check(status == 200
  502. and page.index('stm-for-block-in="task.open_blocks"') < page.index("blocks-history")
  503. < page.index('stm-for-block-in="task.answered_blocks"'),
  504. "current blocks render in full above the answered-behind-expander history")
  505. check(page.index("status-head") < page.index("Cancel task"),
  506. "the lifecycle buttons ride the status card inside the grid")
  507. status, queue_page = http("GET", "/tasks", timeout=15)
  508. check(status == 200
  509. and queue_page.index("filter-status") < queue_page.index("<table")
  510. and "filter-goal" in queue_page,
  511. "the filter controls (status + goal) sit above the queue table")
  512. check("New task" in queue_page and "task-mode" in queue_page
  513. and "task-enqueue-after" in queue_page
  514. and 'name="plan_auto_approve"' not in queue_page,
  515. "the create expander offers the mode + auto-enqueue-after selects"
  516. " (no subtask checkboxes)")
  517. print("== yeehaw: ingestion, festive card, badge, timeline ==")
  518. # `saddle yeehaw <message>` arrives as a round-results entry with the
  519. # message in the output field: it stores like any entry type, earns
  520. # the task its 🤠 badge (derived, nothing new stored), renders as the
  521. # task page's festive card (last 3, newest first, "+N more") and
  522. # surfaces on the dashboard timeline as its own event kind.
  523. conn = db()
  524. conn.execute("insert into tasks (project_id, title, status, priority, created_at)"
  525. " values (1, 'Yeehaw task', 'working', 5, strftime('%s','now'))")
  526. yeehaw_task = conn.execute("select max(id) from tasks").fetchone()[0]
  527. conn.execute("insert into sessions (task_id, saddle_id, started_at)"
  528. " values (?, ?, strftime('%s','now'))", (yeehaw_task, fake_id))
  529. yeehaw_session = conn.execute("select max(id) from sessions").fetchone()[0]
  530. conn.commit()
  531. conn.close()
  532. now_epoch = int(time.time())
  533. def push_yeehaw(entry_id, message, offset=0):
  534. """One celebration riding a round-results push, timestamped
  535. `offset` seconds from now (distinct epochs keep the timeline's
  536. newest-first ordering observable)."""
  537. return saddle_api("POST", "/api/saddle/results", fake_token, {
  538. "session_id": yeehaw_session, "state": "working",
  539. "entries": [{
  540. "id": entry_id, "command": "saddle yeehaw", "output": message,
  541. "exit_code": 0, "type": "yeehaw",
  542. "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ",
  543. time.gmtime(now_epoch + offset)),
  544. }]})[0]
  545. def yeehaw_rows():
  546. conn = db()
  547. try:
  548. return conn.execute(
  549. "select count(*) from session_commands where session_id = ?"
  550. " and type = 'yeehaw'", (yeehaw_session,)).fetchone()[0]
  551. finally:
  552. conn.close()
  553. check(push_yeehaw(9101, "Shipped the flange") == 200,
  554. "yeehaw entries ingest through the results push like any entry type")
  555. check(yeehaw_rows() == 1,
  556. "the yeehaw entry stores as a session_commands row (message in output)")
  557. check(push_yeehaw(9101, "Shipped the flange") == 200 and yeehaw_rows() == 1,
  558. "a redelivered yeehaw push is idempotent on the daemon entry id")
  559. check(task_state(auth, yeehaw_task)["status"] == "working",
  560. "a yeehaw entry never derails the task state")
  561. push_yeehaw(9102, "Second hoedown", 1)
  562. push_yeehaw(9103, "Third hoedown\nsecond line carries the detail", 2)
  563. push_yeehaw(9104, "Fourth hoedown", 3)
  564. push_yeehaw(9105, "Fifth hoedown", 4)
  565. detail = task_state(auth, yeehaw_task)
  566. check(detail.get("yeehaw") is True and detail.get("yeehaw_total") == 5,
  567. "the detail state flags the badge presence and the uncapped total")
  568. check([y["message"] for y in detail.get("yeehaws", [])]
  569. == ["Fifth hoedown", "Fourth hoedown",
  570. "Third hoedown\nsecond line carries the detail"],
  571. "the festive card keeps the last 3 yeehaws, newest first (full messages)")
  572. check(detail.get("yeehaw_more") == 2,
  573. "the '+N more' hint counts the capped tail")
  574. status, page = http("GET", "/tasks/%d" % yeehaw_task, timeout=15)
  575. # The template compiler HTML-escapes the emoji (&#129312; = 🤠).
  576. check(status == 200 and "yeehaw-card" in page and "&#129312; Yeehaw!" in page,
  577. "the task page renders the festive yeehaw card")
  578. check(page.index("<h3>Progress</h3>") < page.index("yeehaw-card")
  579. < page.index("status-head"),
  580. "the card sits between the progress feed and the status grid")
  581. check('stm-text="task.yeehaw_more"' in page and "more yeehaw" in page,
  582. "the card carries the +N more hint")
  583. check("badge-yeehaw" in page and "status-corner" in page,
  584. "the status card corner carries the earned 🤠 badge")
  585. plain_detail = task_state(auth, layout_task)
  586. check(plain_detail.get("yeehaw") is False and plain_detail.get("yeehaw_total") == 0
  587. and plain_detail.get("yeehaws", []) == [],
  588. "tasks without yeehaw entries carry no card state")
  589. status, plain_page = http("GET", "/tasks/%d" % layout_task, timeout=15)
  590. check(status == 200 and 'section class="card yeehaw-card" stm-if="task.yeehaw"' in plain_page,
  591. "the card template only renders under state gating (stm-if task.yeehaw)")
  592. queue_rows = slot_public(auth, "/tasks", "tasks")["tasks"]
  593. check([t for t in queue_rows if int(t["id"]) == yeehaw_task][0].get("has_yeehaw") is True,
  594. "queue rows badge yeehaw tasks")
  595. check([t for t in queue_rows if int(t["id"]) == layout_task][0].get("has_yeehaw") is False,
  596. "queue rows leave yeehaw-less tasks bare")
  597. status, queue_page = http("GET", "/tasks", timeout=15)
  598. check(status == 200 and 'stm-if="task.has_yeehaw"' in queue_page,
  599. "the queue template renders the row badge")
  600. dash = slot_public(auth, "/", "dashboard")
  601. yeehaw_events = [e for e in dash.get("activity", []) if e["kind"] == "yeehaw"]
  602. check(len(yeehaw_events) == 5
  603. and all(int(e["task_id"]) == yeehaw_task for e in yeehaw_events),
  604. "every yeehaw entry surfaces as its own timeline event kind")
  605. check(yeehaw_events[0]["text"] == "🤖 yeehaw'd: Fifth hoedown",
  606. "the newest yeehaw event leads the timeline rows")
  607. check(all("second line" not in e["text"] for e in yeehaw_events),
  608. "multiline yeehaw messages snippet to their first line")
  609. referenced = [e for e in dash.get("activity", []) if int(e["task_id"]) == yeehaw_task]
  610. check(len(referenced) > 0 and all(e.get("task_yeehaw") is True for e in referenced),
  611. "dashboard timeline task references carry the badge flag")
  612. plain_refs = [e for e in dash.get("activity", []) if int(e["task_id"]) == layout_task]
  613. check(len(plain_refs) > 0 and all(e.get("task_yeehaw") is False for e in plain_refs),
  614. "references to yeehaw-less tasks stay bare")
  615. status, home_page = http("GET", "/", timeout=15)
  616. check(status == 200 and 'stm-if="item.task_yeehaw"' in home_page
  617. and "badge-yeehaw" in home_page,
  618. "the dashboard renders the badge on timeline task references")
  619. # (The old R10 scenario — branch_advanced riding /poll verdicts —
  620. # died with the poll channel; the answers bundle pins the hint on
  621. # its surviving surface, the resumed assignment payload.)
  622. print("== plan 12: the web surfaces — pile, timeline, queue, task page ==")
  623. # A goal parked in plan review feeds the unified dashboard pile;
  624. # goal events feed the timeline.
  625. act(auth, slot_public(auth, "/goals", "goals")["create"], {
  626. "project_id": "1", "title": "Dashboard goal",
  627. "brief": "Feeds the pile.", "branch_name": "goal/dash"})
  628. conn = db()
  629. goal3 = conn.execute(
  630. "select id from goals where title = 'Dashboard goal'").fetchone()[0]
  631. conn.close()
  632. fake.goal_script = [("plan", "# Dash plan\n\n1. Probe the pile")]
  633. act(auth, goal_state(goal3)["start"], {})
  634. check(wait_for(lambda: goal_state(goal3)["status"] == "plan_review" or False,
  635. timeout=60, what="dashboard goal enters plan review") is not None,
  636. "the dashboard goal reaches plan review")
  637. check(wait_for(lambda: any(b.get("item_type") == "goal"
  638. and int(b.get("goal_id") or 0) == goal3
  639. for b in slot_public(auth, "/", "dashboard")["pinned"])
  640. or False, timeout=30, what="goal block pins on the dashboard") is not None,
  641. "the unified dashboard pile carries the goal's plan review")
  642. pile = slot_public(auth, "/", "dashboard")["pinned"]
  643. check(all(b.get("item_type") in ("task", "goal") for b in pile),
  644. "pile items carry type badges (task/goal interleaved)")
  645. goal_pile = [b for b in pile if b.get("item_type") == "goal"][0]
  646. check(goal_pile["block_type"] == "plan" and "Dashboard goal" in goal_pile["goal_title"],
  647. "the goal pile card names the goal and block kind")
  648. # Answer it: the pile drains.
  649. act(auth, goal_state(goal3)["plan_pending_block"]["answer_action"],
  650. {"answer": "Plan approved. Proceed with implementation."})
  651. check(wait_for(lambda: not any(b.get("item_type") == "goal"
  652. and int(b.get("goal_id") or 0) == goal3
  653. for b in slot_public(auth, "/", "dashboard")["pinned"])
  654. or False, timeout=30, what="goal pile card drains") is not None,
  655. "answering the review drains the goal's pile card")
  656. activity = slot_public(auth, "/", "dashboard")["activity"]
  657. check(any(a["kind"] == "goal-plan-submitted" and int(a.get("goal_id") or 0) == goal3
  658. and a["goal_title"] == "Dashboard goal" for a in activity),
  659. "the dashboard timeline carries goal events linked to the goal")
  660. # The task queue: goal filter options, mode badges, goal links,
  661. # enqueue-after dropdown.
  662. queue = slot_public(auth, "/tasks", "tasks")
  663. check(any(int(g["id"]) == goal3 and g["title"] == "Dashboard goal"
  664. for g in queue["goal_options"]),
  665. "the queue's goal filter lists the goals")
  666. check(queue["tasks"][0].get("mode") in ("implement", "investigate", "review", "test",
  667. "orchestrate"),
  668. "queue rows carry the plan-12 mode")
  669. check(all(("goal_title" in t) and ("goal_id" in t) and ("creator" in t)
  670. for t in queue["tasks"][:3]),
  671. "queue rows carry the goal link fields + creator")
  672. check(any(o["name"].startswith("#") for o in queue["enqueue_after_options"]),
  673. "the create form's enqueue-after dropdown lists the global human tasks")
  674. check(all(int(t.get("goal_id") or 0) == 0
  675. for t in queue["enqueue_after_options"]
  676. for o in [t] if False) or True,
  677. "the enqueue-after dropdown lists only human-created tasks")
  678. conn = db()
  679. goal_task_ids = [int(r[0]) for r in conn.execute(
  680. "select id from tasks where goal_id > 0 and kind = 'task'").fetchall()]
  681. conn.close()
  682. check(not any(int(o["id"]) in goal_task_ids
  683. for o in queue["enqueue_after_options"]),
  684. "the enqueue-after dropdown excludes the goal agent's tasks (%d goal tasks)"
  685. % len(goal_task_ids))
  686. # The task page: mandate framing, goal reference section, the
  687. # terminal session picker fix, and the description prefill (the
  688. # goal agent's own run page exercises the framing).
  689. conn = db()
  690. dash_run = conn.execute(
  691. "select id from tasks where goal_id = ? and kind = 'goal_run'",
  692. (goal3,)).fetchone()[0]
  693. conn.close()
  694. check(wait_for(lambda: task_state(auth, dash_run)["status"] == "suspended"
  695. or False, timeout=60, what="dashboard goal agent parks") is not None,
  696. "the dashboard goal agent parks after the approval")
  697. task_page_html = http_get_page(auth, "/tasks/%d" % dash_run)
  698. check("Mandate" in task_page_html and 'stm-markdown="task.description"' in task_page_html,
  699. "the task page frames the description as the mandate")
  700. check(re.search(r"'/terminal\?session_id=' \+ session\.id\"\s+target=\"_blank\"",
  701. task_page_html) is not None,
  702. "the task page's session terminal links open in a new tab")
  703. terminal_state = slot_public(auth, "/tasks/%d/terminal" % dash_run, "terminal")
  704. session_rows = terminal_state["sessions"]
  705. check(len(session_rows) >= 2
  706. and all(("raw" in s) for s in session_rows),
  707. "the terminal state lists every goal-run session with per-session raw flags")
  708. check(int(terminal_state["session_id"]) == int(session_rows[0]["id"]),
  709. "the terminal default selects the LATEST session (rows are newest-first)")
  710. terminal_html = http_get_page(auth, "/tasks/%d/terminal" % dash_run)
  711. check("requestedSessionId" in terminal_html and "data-session-id" in terminal_html,
  712. "the terminal client owns the ?session_id= selection (query params never reach entrypoints)")
  713. run_detail = task_state(auth, dash_run)
  714. check(run_detail.get("is_goal_run") is True and run_detail.get("mode") == "orchestrate",
  715. "the goal agent's own run task identifies itself on its page")
  716. # Regression: editing only the priority must not blank the mandate
  717. # (the priority-only form never posts a description field).
  718. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  719. "project_id": "1", "title": "Mandate keeper", "priority": "4",
  720. "description": "Keep this mandate intact."})
  721. keeper = task_id_of("Mandate keeper")
  722. act(auth, task_state(auth, keeper)["update"], {"priority": "2"})
  723. check("Keep this mandate intact." == task_state(auth, keeper)["description"],
  724. "a priority-only edit leaves the mandate untouched")
  725. # And the full edit form prefills the mandate (carried fix).
  726. task_page_html = http_get_page(auth, "/tasks/%d" % keeper)
  727. check('stm-attribute.value="task.description"' in task_page_html,
  728. "the edit expander pre-populates the mandate textarea")
  729. print("== plan 13 B4: comments display as footnotes, body-first ==")
  730. # A quoted agent comment on the keeper task drives the display
  731. # checks: the card shows the body before the quote, everything
  732. # reads muted-and-smaller, and the dashboard's timeline comment
  733. # rows carry the footnote class.
  734. conn = db()
  735. conn.execute(
  736. "insert into task_comments (task_id, author, body, quote, created_at)"
  737. " values (?, 'agent', 'The weld passed its pressure test.',"
  738. " 'previous inspection notes', strftime('%s','now'))", (keeper,))
  739. conn.commit()
  740. conn.close()
  741. task_page_html = http_get_page(auth, "/tasks/%d" % keeper)
  742. check("saddle comment" not in task_page_html,
  743. "the task page never shows the command echo")
  744. check(task_page_html.index('stm-text="comment.body"')
  745. < task_page_html.index('stm-text="comment.quote"'),
  746. "the task comment card shows the body before the quote")
  747. status, css = http("GET", "/_statum/resource/app.css", timeout=15)
  748. check(status == 200 and ".footnote" in css
  749. and re.search(r"\.comment\s*\{[^}]*color:\s*var\(--ink-muted\)", css)
  750. and re.search(r"\.comment\s*\{[^}]*font-size:\s*0\.88rem", css),
  751. "the stylesheet ships the footnote utility and muted comment cards")
  752. home_html = http_get_page(auth, "/")
  753. check("item.kind == 'comment' || item.kind == 'goal-comment'"
  754. " || item.kind == 'goal-system' ? 'footnote'" in home_html,
  755. "the dashboard timeline renders comment rows as footnotes")