pages.py 56 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020
  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/log" % plan_task, timeout=15)
  60. check(status == 200 and "data-log-feed" in page and "data-log-more" in page,
  61. "the event log page renders the feed and the load-more control")
  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. print("== B6b: create-form branch fields (goal 16) ==")
  172. # The queue form carries both branch rows; they post through
  173. # tasks.create into the stored task (empty optional -> null,
  174. # branch_from prefill from the project's default branch).
  175. tasks_page = http_get_page(auth, "/tasks")
  176. check('name="task_branch"' in tasks_page and "Task branch name" in tasks_page,
  177. "the create form renders the Task branch name input")
  178. check('name="branch_from"' in tasks_page and "Branch from" in tasks_page,
  179. "the create form renders the Branch from input")
  180. check("default: the project's default branch" in tasks_page,
  181. "the Branch from label hints at the project default")
  182. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  183. "project_id": "1",
  184. "title": "Branch fields task",
  185. "priority": "5",
  186. "task_branch": "task/999-branch-fields",
  187. "branch_from": "release/2026",
  188. })
  189. detail = task_state(auth, task_id_of("Branch fields task"))
  190. check(detail["task_branch"] == "task/999-branch-fields"
  191. and detail["branch_from"] == "release/2026",
  192. "posted branch fields store on the created task")
  193. # Both optional: empty posts leave task_branch null and prefill
  194. # branch_from from the project's default branch (demo -> main).
  195. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  196. "project_id": "1",
  197. "title": "Default branch fields task",
  198. "priority": "5",
  199. })
  200. detail = task_state(auth, task_id_of("Default branch fields task"))
  201. check((detail.get("task_branch") is None
  202. or detail.get("task_branch") == "")
  203. and detail.get("branch_from") == "main",
  204. "empty posts leave task_branch null and prefill branch_from"
  205. " from the project default")
  206. check(quiet_window(quiet_seconds(),
  207. lambda: task_state(auth, draft_display)["status"] == "draft",
  208. "draft task never dispatches"),
  209. "a draft task is never dispatched or woken for")
  210. # Plan 12: the waiting-on-drafts block is deleted (goal tasks are
  211. # the goal agent's to sequence); a parked task displays plainly.
  212. conn = db()
  213. conn.execute("insert into tasks (project_id, title, status, priority, created_at)"
  214. " values (1, 'Parked plain task', 'suspended', 5, strftime('%s','now'))")
  215. conn.commit()
  216. conn.close()
  217. parked_id = db().execute("select max(id) from tasks").fetchone()[0]
  218. detail = task_state(auth, parked_id)
  219. check(detail.get("display") == "parked",
  220. "a suspended task displays as parked (%s)" % detail.get("display"))
  221. dash = slot_public(auth, "/", "dashboard")
  222. check(not dash.get("waiting_drafts"),
  223. "the waiting-on-drafts block is gone from the dashboard data")
  224. status, home_page = http("GET", "/", timeout=15)
  225. check(status == 200 and "Waiting on subtasks to be enqueued" not in home_page,
  226. "the dashboard renders no waiting-on-drafts section")
  227. conn = db()
  228. conn.execute("insert into tasks (project_id, title, status, priority, created_at)"
  229. " values (1, 'Parent', 'done', 5, strftime('%s','now'))")
  230. parent6 = conn.execute("select max(id) from tasks").fetchone()[0]
  231. # The post-mortem case: stored done WITH an unanswered review block.
  232. # (A terminal task consumes result pushes silently — plan 13 B2 —
  233. # so the row is inserted directly, the goalui deep-link pattern.)
  234. conn.execute("insert into task_blocks (task_id, question, type, created_at)"
  235. " values (?, 'Review the final diff', 'review',"
  236. " strftime('%s','now'))", (parent6,))
  237. conn.commit()
  238. conn.close()
  239. detail = task_state(auth, parent6)
  240. check(detail.get("display") == "review requested" and detail.get("terminal") is True,
  241. "unanswered review overrides the stored done status (terminal flag keeps stored truth)")
  242. queue = [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  243. if int(t["id"]) == parent6][0]
  244. check(queue["display"] == "review requested",
  245. "queue row shows the computed review-requested display")
  246. # With the review answered the display falls back to the stored
  247. # status (the plan-12 shape: no subtask-delegation states).
  248. conn = db()
  249. conn.execute("update task_blocks set answered_at = strftime('%s','now'),"
  250. " answer = 'Human approved the review' where task_id = ?", (parent6,))
  251. conn.commit()
  252. conn.close()
  253. detail = task_state(auth, parent6)
  254. check(detail.get("display") == "done" and detail.get("terminal") is True,
  255. "answered review falls back to the stored done display (%s)"
  256. % detail.get("display"))
  257. # Flags track the stored status on failure.
  258. conn = db()
  259. conn.execute("update tasks set status = 'failed', completed_at = strftime('%s','now')"
  260. " where id = ?", (parent6,))
  261. conn.commit()
  262. conn.close()
  263. detail = task_state(auth, parent6)
  264. check(detail.get("display") == "failed" and detail.get("failed") is True
  265. and detail.get("terminal") is True,
  266. "failed task with answered review shows failed + terminal flags")
  267. print("== rejected entries (non-fatal) ==")
  268. # The push wire: a rejected entry rides a round-results push, stores
  269. # on the session and streams through SSE; the task is untouched.
  270. status, body = saddle_api("POST", "/api/saddle/chunks", fake_token, {
  271. "session_id": session_id,
  272. "events": [{"seq": 9021, "kind": "tool_result", "actor": "harness",
  273. "payload": {"command": "bash -n script.sh",
  274. "stderr": "syntax error near line 3 (recoverable)",
  275. "exit_code": 1}}]})
  276. check(status == 200 and int(body.get("stored") or 0) == 1,
  277. "a failed tool result rides the events push")
  278. request = urllib.request.Request(WEB + "/tasks/%d/events/stream" % plan_task)
  279. with urllib.request.urlopen(request, timeout=15) as stream:
  280. result_event = None
  281. deadline = time.monotonic() + 8
  282. while time.monotonic() < deadline and result_event is None:
  283. line = stream.readline().decode().strip()
  284. if not line.startswith("data: ") or line == "data: ping":
  285. continue
  286. event = json.loads(line[6:])
  287. if (event.get("kind") == "tool_result"
  288. and "syntax error near line 3" in
  289. (event.get("payload", {}).get("stderr") or "")):
  290. result_event = event
  291. check(result_event is not None,
  292. "the event stream carries tool results with their reason")
  293. # The page template renders the feed and the filters.
  294. status, page = http("GET", "/tasks/%d/log" % plan_task, timeout=15)
  295. check(status == 200 and "data-log-filter" in page and "data-log-search" in page,
  296. "the event log page renders filters and search")
  297. print("== B8: byte-exact payload delivery over the event stream ==")
  298. # Byte-exact delivery: ANSI-coloured turn text flows through the
  299. # ledger + SSE untouched (the client renderer styles them).
  300. coloured = "\x1b[1;32mbold green\x1b[0m then \x1b[38;5;196m256-red\x1b[0m\n"
  301. status, body = saddle_api("POST", "/api/saddle/chunks", fake_token, {
  302. "session_id": session_id,
  303. "events": [{"seq": 9100, "kind": "turn_text",
  304. "payload": {"turn": 9, "text": coloured}}]})
  305. check(status == 200 and int(body.get("stored") or 0) == 1,
  306. "the coloured turn text stores as one event")
  307. request = urllib.request.Request(WEB + "/tasks/%d/events/stream" % plan_task)
  308. with urllib.request.urlopen(request, timeout=15) as stream:
  309. coloured_event = None
  310. deadline = time.monotonic() + 8
  311. while time.monotonic() < deadline and coloured_event is None:
  312. line = stream.readline().decode().strip()
  313. if not line.startswith("data: ") or line == "data: ping":
  314. continue
  315. event = json.loads(line[6:])
  316. if (event.get("kind") == "turn_text"
  317. and "\x1b[1;32m" in (event.get("payload", {}).get("text") or "")):
  318. coloured_event = event
  319. check(coloured_event is not None
  320. and "\x1b[38;5;196m" in coloured_event["payload"]["text"],
  321. "payload bytes stream byte-exact to the client renderer")
  322. print("== goal 9: the yeehaw celebration rides the ledger + stream ==")
  323. # The daemon's yeehaw tool emits a `yeehaw` ledger event (actor
  324. # model, payload {message}); it must store, stream and leave the
  325. # page wiring intact for the client renderer (the comments bucket
  326. # + .yeehaw-message span are pinned by test_task_log_js.py).
  327. whoop = "Finally cracked the SSE replay - every byte landed."
  328. status, body = saddle_api("POST", "/api/saddle/chunks", fake_token, {
  329. "session_id": session_id,
  330. "events": [{"seq": 9200, "kind": "yeehaw", "actor": "model",
  331. "payload": {"message": whoop}}]})
  332. check(status == 200 and int(body.get("stored") or 0) == 1,
  333. "a yeehaw event stores on the session ledger (%s)" % body.get("error"))
  334. request = urllib.request.Request(WEB + "/tasks/%d/events/stream" % plan_task)
  335. with urllib.request.urlopen(request, timeout=15) as stream:
  336. yeehaw_event = None
  337. deadline = time.monotonic() + 8
  338. while time.monotonic() < deadline and yeehaw_event is None:
  339. line = stream.readline().decode().strip()
  340. if not line.startswith("data: ") or line == "data: ping":
  341. continue
  342. event = json.loads(line[6:])
  343. if event.get("kind") == "yeehaw":
  344. yeehaw_event = event
  345. check(yeehaw_event is not None
  346. and yeehaw_event.get("actor") == "model"
  347. and yeehaw_event.get("payload", {}).get("message") == whoop,
  348. "the yeehaw event streams with its message intact")
  349. conn = db()
  350. stored_kind = conn.execute(
  351. "select kind, actor from events where session_id = ? and seq = 9200",
  352. (session_id,)).fetchone()
  353. conn.close()
  354. check(stored_kind is not None and stored_kind[0] == "yeehaw"
  355. and stored_kind[1] == "model",
  356. "the ledger row keeps the yeehaw kind + model actor")
  357. status, page = http("GET", "/tasks/%d/log" % plan_task, timeout=15)
  358. check(status == 200 and "data-log-feed" in page
  359. and "yeehaw" not in page.split("<script>", 1)[0],
  360. "the log page still renders with the yeehaw renderer shipped in its script")
  361. # Dispatch path: a rejected entry in a round's results push flows
  362. # into the audit trail without failing (or finishing) the task.
  363. task_id_tmp = create_queued(auth, {
  364. "project_id": "1",
  365. "title": "Rejected ride",
  366. "priority": "2",
  367. })
  368. rejected_task = task_id_tmp
  369. check(wait_for(lambda: task_state(auth, rejected_task).get("status") == "working"
  370. or False, timeout=60, what="rejected-ride working") is not None,
  371. "rejected-ride task reaches working")
  372. fake.queue_output("rejected", "echo 'too long' | saddle",
  373. "max_tokens exceeded (recoverable)")
  374. check(wait_task_done(auth, rejected_task, timeout=60) is not None,
  375. "task completes normally past rejected entries")
  376. stored = [row for row in event_rows(rejected_task, ["command_output"])
  377. if "max_tokens exceeded" in row[1]]
  378. check(len(stored) >= 1,
  379. "command_output events stored in the audit trail (%d rows)" % len(stored))
  380. check(task_state(auth, rejected_task)["status"] == "done"
  381. and "fake daemon finished" in (task_state(auth, rejected_task).get("summary") or ""),
  382. "rejected entries never fail the task")
  383. print("== plan 11 task D: task page — progress first, 50/50 grid, expanders ==")
  384. detail = task_state(auth, plan_task)
  385. block_ids = [int(b["id"]) for b in detail.get("blocks", [])]
  386. check(len(block_ids) >= 3 and block_ids == sorted(block_ids, reverse=True),
  387. "the blocks section orders newest-first via the sealed GET state (%s)"
  388. % block_ids)
  389. status, page = http("GET", "/tasks/%d" % plan_task, timeout=15)
  390. # Plan 11 task D: the progress feed sits ABOVE the status card;
  391. # status + description share a 50/50 grid; the plan detail hides
  392. # behind its expander below it.
  393. check(status == 200 and "with-sidebar act-consult" in page
  394. and page.index("<h3>Progress</h3>") < page.index("status-head"),
  395. "the progress feed renders above the status card")
  396. check(page.index('data-subnav-section="task-blocks"') < page.index("status-head"),
  397. "the blocks section leads the acting column above the status card")
  398. check("Mandate" in page and "plan-expander" not in page,
  399. "the task page carries no plan expander (plans live on the goal)")
  400. # Answered blocks fold behind the history expander (this fixture's
  401. # three blocks are all answered by now).
  402. check("blocks-history" in page and "Answered blocks" in page,
  403. "answered blocks hide behind the blocks history expander")
  404. conn = db()
  405. conn.execute("insert into tasks (project_id, title, status, priority,"
  406. " created_at) values (1, 'Progress layout task', 'working', 5,"
  407. " strftime('%s','now','-120 seconds'))")
  408. layout_task = conn.execute("select max(id) from tasks").fetchone()[0]
  409. # backdated so the dashboard-timeline fixtures below stay the
  410. # newest events
  411. for offset, line in ((90, "first update"), (89, "second update")):
  412. payload = json.dumps({"text": line})
  413. conn.execute(
  414. "insert into events (kind, actor, scope_type, scope_id, payload,"
  415. " created_at) values ('progress', 'model', 'task', ?, ?,"
  416. " strftime('%s','now', ?))",
  417. (layout_task, payload, "-%d seconds" % offset))
  418. conn.commit()
  419. conn.close()
  420. status, history_page = http("GET", "/tasks/%d" % layout_task, timeout=15)
  421. check(status == 200
  422. and history_page.index("progress-latest") < history_page.index("progress-history"),
  423. "the progress timeline renders newest-first (latest above the history)")
  424. # Goal 41 T5: the edit surface is the in-place Edit task card
  425. # (no expander) in the acting column, below the progress feed.
  426. check(history_page.index("Show history") < history_page.index(">Edit task"),
  427. "the Edit task card sits under the progress history expander")
  428. check("edit-expander" not in history_page,
  429. "the old edit expander is gone (in-place editor instead)")
  430. check(history_page.index('data-subnav-section="task-blocks"')
  431. < history_page.index('data-subnav-section="task-comments"'),
  432. "the blocks section precedes comments in the acting column")
  433. print("== heredoc completion reports: markdown card + timeline snippet ==")
  434. # The daemon's `cat << 'EOF' | saddle done` stores a markdown
  435. # summary (the failure path stores the same field); the task page
  436. # renders it as a full-width completion-report card directly below
  437. # the status/description grid — markdown via stm-markdown
  438. # (XSS-safe), "Completion report" when done, "Failure summary"
  439. # when failed, no card when the summary is empty. The dashboard
  440. # timeline snippets the summary's FIRST line.
  441. report_md = ("## Flange wired and tested\n\n"
  442. "Ran `meson test` over the full matrix.\n\n"
  443. "```sh\nmeson test -C builddir\n```\n\n"
  444. "<b>not html</b> & escaped\n")
  445. conn = db()
  446. conn.execute("insert into tasks (project_id, title, status, priority,"
  447. " summary, started_at, completed_at, created_at)"
  448. " values (1, 'Report done task', 'done', 5, ?,"
  449. " strftime('%s','now'), strftime('%s','now'),"
  450. " strftime('%s','now'))", (report_md,))
  451. report_done = conn.execute("select max(id) from tasks").fetchone()[0]
  452. conn.execute("insert into tasks (project_id, title, status, priority,"
  453. " summary, started_at, completed_at, created_at)"
  454. " values (1, 'Report failed task', 'failed', 5,"
  455. " 'Boom: tests failed\n\nsecond line carries the detail',"
  456. " strftime('%s','now'), strftime('%s','now'),"
  457. " strftime('%s','now'))")
  458. report_failed = conn.execute("select max(id) from tasks").fetchone()[0]
  459. conn.execute("insert into tasks (project_id, title, status, priority,"
  460. " started_at, completed_at, created_at)"
  461. " values (1, 'Report empty task', 'done', 5,"
  462. " strftime('%s','now'), strftime('%s','now'),"
  463. " strftime('%s','now'))")
  464. report_empty = conn.execute("select max(id) from tasks").fetchone()[0]
  465. # The terminal transitions ride the ledger (state_change events
  466. # carrying the summary the timeline snippets).
  467. for task_id, to, summary in (
  468. (report_done, "done", report_md),
  469. (report_failed, "failed", "Boom: tests failed\n\nsecond line carries the detail"),
  470. (report_empty, "done", "")):
  471. payload = {"from": "working", "to": to,
  472. "text": "Task completed" if to == "done" else "Task failed"}
  473. if summary:
  474. payload["summary"] = summary
  475. conn.execute(
  476. "insert into events (kind, actor, scope_type, scope_id, payload,"
  477. " created_at) values ('state_change', 'system', 'task', ?, ?,"
  478. " strftime('%s','now'))",
  479. (task_id, json.dumps(payload)))
  480. conn.commit()
  481. conn.close()
  482. done_state = task_state(auth, report_done)
  483. check(done_state["report_title"] == "Completion report"
  484. and "## Flange wired" in (done_state.get("summary") or ""),
  485. "a done task's summary rides the report card state")
  486. check(task_state(auth, report_failed)["report_title"] == "Failure summary",
  487. "a failed task's card heading reads Failure summary")
  488. empty_state = task_state(auth, report_empty)
  489. check(empty_state["report_title"] == "" and not (empty_state.get("summary") or ""),
  490. "an empty summary renders no report card (no heading)")
  491. check(task_state(auth, layout_task)["report_title"] == "",
  492. "non-terminal tasks never show the report card")
  493. status, page = http("GET", "/tasks/%d" % report_done, timeout=15)
  494. check(status == 200 and "completion-report" in page
  495. and page.index("data-subnav-section=\"task-blocks\"") < page.index("completion-report")
  496. < page.index("status-head"),
  497. "the completion-report card sits below the blocks, above the status card")
  498. card = page[page.index("completion-report"):page.index("status-head")]
  499. check('stm-markdown="task.summary"' in card
  500. and 'stm-text="task.report_title"' in card,
  501. "the card renders the summary as markdown under its state-driven heading")
  502. check("<strong>Summary:</strong>" not in page,
  503. "the old plain-text summary line is gone (the card owns the summary)")
  504. # The markdown itself renders client-side: drive the SHIPPED
  505. # stm-markdown renderer (statum.js) on the fixture summary via
  506. # node — headings and code come out as HTML, no markdown syntax
  507. # leaks, raw HTML stays escaped.
  508. status, statum_js = http("GET", "/_statum/resource/statum.js", timeout=15)
  509. skip = None if status == 200 else "statum.js not served"
  510. parts = []
  511. if skip is None:
  512. def extract_md(name, source):
  513. start = source.index("function %s(" % name)
  514. depth = 0
  515. index = source.index("{", start)
  516. end = index
  517. while True:
  518. if source[end] == "{":
  519. depth += 1
  520. elif source[end] == "}":
  521. depth -= 1
  522. if depth == 0:
  523. break
  524. end += 1
  525. return source[start:end + 1]
  526. try:
  527. parts = [extract_md(name, statum_js)
  528. for name in ("safeMarkdownUrl", "mdLinks", "mdInline", "renderMarkdown")]
  529. except ValueError:
  530. skip = "renderer functions missing"
  531. rendered = ""
  532. if skip is None:
  533. try:
  534. result = subprocess.run(
  535. ["node", "-e", "%s\n%s\n%s\n%s\nconsole.log(renderMarkdown(process.argv[1]));"
  536. % tuple(parts), report_md],
  537. capture_output=True, text=True, timeout=20)
  538. rendered = result.stdout
  539. except (OSError, subprocess.SubprocessError):
  540. skip = "node unavailable"
  541. if skip is not None:
  542. check(True, "stm-markdown renderer present (%s, deep check skipped)" % skip)
  543. else:
  544. check("<h2>Flange wired and tested</h2>" in rendered
  545. and "<pre><code" in rendered and "<code>meson test</code>" in rendered,
  546. "the report summary renders headings and code through stm-markdown")
  547. check("## Flange" not in rendered and "```sh" not in rendered
  548. and "`meson test`" not in rendered,
  549. "no raw markdown syntax leaks into the rendered report")
  550. check("<b>not html</b>" not in rendered and "&lt;b&gt;not html&lt;/b&gt;" in rendered
  551. and "&amp; escaped" in rendered,
  552. "raw HTML in the summary stays escaped (XSS-safe)")
  553. # The dashboard timeline: completion/failure events snippet the
  554. # summary's first line, falling back to the bare verdict text.
  555. dash = slot_public(auth, "/", "dashboard")
  556. done_event = next((e for e in dash.get("activity", [])
  557. if e["kind"] == "completed" and int(e["task_id"]) == report_done), None)
  558. check(done_event is not None
  559. and done_event["text"] == "Task completed: ## Flange wired and tested",
  560. "the timeline completion event snippets the summary's first line")
  561. failed_event = next((e for e in dash.get("activity", [])
  562. if e["kind"] == "failed" and int(e["task_id"]) == report_failed), None)
  563. check(failed_event is not None
  564. and failed_event["text"] == "Task failed: Boom: tests failed",
  565. "the timeline failure event snippets the summary's first line")
  566. empty_event = next((e for e in dash.get("activity", [])
  567. if e["kind"] == "completed" and int(e["task_id"]) == report_empty), None)
  568. check(empty_event is not None and empty_event["text"] == "Task completed",
  569. "a missing summary falls back to the bare timeline text")
  570. print("== plan 11 task D: block split + queue-screen layout ==")
  571. # One open and one answered question: the open block renders in
  572. # full, the answered one folds behind the history expander.
  573. conn = db()
  574. conn.execute("insert into sessions (task_id, saddle_id, started_at)"
  575. " values (?, ?, strftime('%s','now'))", (layout_task, fake_id))
  576. conn.commit()
  577. split_session = conn.execute("select max(id) from sessions").fetchone()[0]
  578. conn.close()
  579. raise_block(split_session, "Open question for layout?", kind="question")
  580. raise_block(split_session, "Answered question for layout?", kind="question")
  581. answered_block = [b for b in task_state(auth, layout_task)["blocks"]
  582. if b["question"] == "Answered question for layout?"][0]
  583. act(auth, answered_block["answer_action"], {"answer": "Yes"})
  584. detail = task_state(auth, layout_task)
  585. check(len(detail.get("open_blocks", [])) == 1
  586. and len(detail.get("answered_blocks", [])) == 1
  587. and detail["open_blocks"][0]["question"] == "Open question for layout?"
  588. and detail["answered_blocks"][0]["question"] == "Answered question for layout?",
  589. "the task slot splits blocks into open (current) and answered lists")
  590. status, page = http("GET", "/tasks/%d" % layout_task, timeout=15)
  591. check(status == 200
  592. and page.index('stm-for-block-in="task.open_blocks"') < page.index("blocks-history")
  593. < page.index('stm-for-block-in="task.answered_blocks"'),
  594. "current blocks render in full above the answered-behind-expander history")
  595. check(page.index("status-head") < page.index("Cancel task"),
  596. "the lifecycle buttons ride the status card inside the grid")
  597. status, queue_page = http("GET", "/tasks", timeout=15)
  598. check(status == 200
  599. and queue_page.index("goal-strip") < queue_page.index("<table")
  600. and "filter-goal" in queue_page,
  601. "the filter controls (status chips + goal select) sit above the queue table")
  602. check("New task" in queue_page and "task-mode" in queue_page
  603. and "task-enqueue-after" in queue_page
  604. and 'name="plan_auto_approve"' not in queue_page,
  605. "the create form offers the mode + auto-enqueue-after selects"
  606. " (no subtask checkboxes)")
  607. print("== plan 12: the web surfaces — pile, timeline, queue, task page ==")
  608. # A goal parked in plan review feeds the unified dashboard pile;
  609. # goal events feed the timeline.
  610. act(auth, slot_public(auth, "/goals", "goals")["create"], {
  611. "project_id": "1", "title": "Dashboard goal",
  612. "brief": "Feeds the pile.", "branch_name": "goal/dash"})
  613. conn = db()
  614. goal3 = conn.execute(
  615. "select id from goals where title = 'Dashboard goal'").fetchone()[0]
  616. conn.close()
  617. fake.goal_script = [("plan", "# Dash plan\n\n1. Probe the pile")]
  618. act(auth, goal_state(goal3)["start"], {})
  619. check(wait_for(lambda: goal_state(goal3)["status"] == "plan_review" or False,
  620. timeout=60, what="dashboard goal enters plan review") is not None,
  621. "the dashboard goal reaches plan review")
  622. check(wait_for(lambda: any(b.get("item_type") == "goal"
  623. and int(b.get("goal_id") or 0) == goal3
  624. for b in slot_public(auth, "/", "dashboard")["pinned"])
  625. or False, timeout=30, what="goal block pins on the dashboard") is not None,
  626. "the unified dashboard pile carries the goal's plan review")
  627. pile = slot_public(auth, "/", "dashboard")["pinned"]
  628. check(all(b.get("item_type") in ("task", "goal") for b in pile),
  629. "pile items carry type badges (task/goal interleaved)")
  630. goal_pile = [b for b in pile if b.get("item_type") == "goal"][0]
  631. check(goal_pile["block_type"] == "plan" and "Dashboard goal" in goal_pile["goal_title"],
  632. "the goal pile card names the goal and block kind")
  633. # Answer it: the pile drains.
  634. # (Goal 27 refinements — the parking guard: the later scripted park
  635. # needs a live wake source under the goal; plant one before the
  636. # approval resumes the agent.)
  637. conn = db()
  638. conn.execute("insert into tasks (project_id, goal_id, kind, title, status,"
  639. " priority, mode, creator, created_at) values (1, ?, 'task',"
  640. " 'Dash live worker', 'blocked', 5, 'implement', 'goal',"
  641. " strftime('%s','now'))", (goal3,))
  642. conn.commit()
  643. conn.close()
  644. act(auth, goal_state(goal3)["plan_pending_block"]["answer_action"],
  645. {"answer": "Plan approved. Proceed with implementation."})
  646. check(wait_for(lambda: not any(b.get("item_type") == "goal"
  647. and int(b.get("goal_id") or 0) == goal3
  648. for b in slot_public(auth, "/", "dashboard")["pinned"])
  649. or False, timeout=30, what="goal pile card drains") is not None,
  650. "answering the review drains the goal's pile card")
  651. activity = slot_public(auth, "/", "dashboard")["activity"]
  652. check(any(a["kind"] == "goal-plan-submitted" and int(a.get("goal_id") or 0) == goal3
  653. and a["goal_title"] == "Dashboard goal" for a in activity),
  654. "the dashboard timeline carries goal events linked to the goal")
  655. # The task queue: goal filter options, mode badges, goal links,
  656. # enqueue-after dropdown.
  657. queue = slot_public(auth, "/tasks", "tasks")
  658. check(any(int(g["id"]) == goal3 and g["title"] == "Dashboard goal"
  659. for g in queue["goal_options"]),
  660. "the queue's goal filter lists the goals")
  661. check(queue["tasks"][0].get("mode") in ("implement", "investigate", "review", "test",
  662. "orchestrate"),
  663. "queue rows carry the plan-12 mode")
  664. check(all(("goal_title" in t) and ("goal_id" in t) and ("creator" in t)
  665. for t in queue["tasks"][:3]),
  666. "queue rows carry the goal link fields + creator")
  667. check(any(o["name"].startswith("#") for o in queue["enqueue_after_options"]),
  668. "the create form's enqueue-after dropdown lists the global human tasks")
  669. check(all(int(t.get("goal_id") or 0) == 0
  670. for t in queue["enqueue_after_options"]
  671. for o in [t] if False) or True,
  672. "the enqueue-after dropdown lists only human-created tasks")
  673. conn = db()
  674. goal_task_ids = [int(r[0]) for r in conn.execute(
  675. "select id from tasks where goal_id > 0 and kind = 'task'").fetchall()]
  676. conn.close()
  677. check(not any(int(o["id"]) in goal_task_ids
  678. for o in queue["enqueue_after_options"]),
  679. "the enqueue-after dropdown excludes the goal agent's tasks (%d goal tasks)"
  680. % len(goal_task_ids))
  681. # The task page: mandate framing, goal reference section, the
  682. # terminal session picker fix, and the description prefill (the
  683. # goal agent's own run page exercises the framing).
  684. conn = db()
  685. dash_run = conn.execute(
  686. "select id from tasks where goal_id = ? and kind = 'goal_run'",
  687. (goal3,)).fetchone()[0]
  688. conn.close()
  689. check(wait_for(lambda: task_state(auth, dash_run)["status"] == "suspended"
  690. or False, timeout=60, what="dashboard goal agent parks") is not None,
  691. "the dashboard goal agent parks after the approval")
  692. task_page_html = http_get_page(auth, "/tasks/%d" % dash_run)
  693. check("Mandate" in task_page_html and 'stm-markdown="task.description"' in task_page_html,
  694. "the task page frames the description as the mandate")
  695. # Goal 41 T7 (ux-notes structural bet 3): the event log is the
  696. # task page's second tab, not a new-tab navigation - the toggle is
  697. # a <label> for the log radio, the shared shell rides the panel,
  698. # and no log link on the page opens a new tab any more.
  699. check('id="task-view-log"' in task_page_html and 'class="tabs task-tabs"' in task_page_html,
  700. "the task page mounts the Overview|Log tabs")
  701. check(re.search(r'<label class="tab-link" for="task-view-log">Event log</label>',
  702. task_page_html) is not None,
  703. "the task page's event log control toggles the log tab in place")
  704. check(re.search(r"/log'\s*\"\s*target=\"_blank\"", task_page_html) is None,
  705. "the task page no longer opens the event log in a new tab")
  706. check('class="log-shell"' in task_page_html,
  707. "the task page embeds the shared log shell (compiled in from the partial)")
  708. task_log_state = slot_public(auth, "/tasks/%d" % dash_run, "task_log")
  709. check(task_log_state.get("exists") is True
  710. and int(task_log_state["task_id"]) == dash_run,
  711. "the task page state also publishes the embedded log slot")
  712. log_state = slot_public(auth, "/tasks/%d/log" % dash_run, "task_log")
  713. check(log_state.get("exists") is True and int(log_state["task_id"]) == dash_run,
  714. "the event log page state names the run task")
  715. log_html = http_get_page(auth, "/tasks/%d/log" % dash_run)
  716. # The log page's client JS ships as a compiled resource (plan 17
  717. # b3+), so the SSE subscription is asserted on the served resource
  718. # the page references, not the page HTML itself.
  719. status, log_js = http("GET", "/_statum/resource/task_log.js", timeout=15)
  720. check(status == 200 and "task_log.js" in log_html
  721. and "EventSource" in log_js and "events/stream" in log_js,
  722. "the log page subscribes to the task's SSE stream")
  723. run_detail = task_state(auth, dash_run)
  724. check(run_detail.get("is_goal_run") is True and run_detail.get("mode") == "orchestrate",
  725. "the goal agent's own run task identifies itself on its page")
  726. # Regression: editing only the priority must not blank the mandate
  727. # (the priority-only form never posts a description field).
  728. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  729. "project_id": "1", "title": "Mandate keeper", "priority": "4",
  730. "description": "Keep this mandate intact."})
  731. keeper = task_id_of("Mandate keeper")
  732. act(auth, task_state(auth, keeper)["update"], {"priority": "2"})
  733. check("Keep this mandate intact." == task_state(auth, keeper)["description"],
  734. "a priority-only edit leaves the mandate untouched")
  735. # Task 12: the mandate's edit surface is the in-place card
  736. # editor (the expander no longer carries the field); the editor
  737. # prefills from state, not stm-attribute.value (a content
  738. # attribute, which textareas ignore).
  739. task_page_html = http_get_page(auth, "/tasks/%d" % keeper)
  740. check('data-lf-src="task.description"' in task_page_html,
  741. "the mandate card editor prefills the textarea from state")
  742. print("== plan 13 B4: comments display as footnotes, body-first ==")
  743. # A quoted agent comment on the keeper task drives the display
  744. # checks: the card shows the body before the quote, everything
  745. # reads muted-and-smaller, and the dashboard's timeline comment
  746. # rows carry the footnote class.
  747. conn = db()
  748. conn.execute(
  749. "insert into task_comments (task_id, author, body, quote, created_at)"
  750. " values (?, 'agent', 'The weld passed its pressure test.',"
  751. " 'previous inspection notes', strftime('%s','now'))", (keeper,))
  752. conn.commit()
  753. conn.close()
  754. task_page_html = http_get_page(auth, "/tasks/%d" % keeper)
  755. check("saddle comment" not in task_page_html,
  756. "the task page never shows the command echo")
  757. check(task_page_html.index('stm-markdown="comment.body"')
  758. < task_page_html.index('stm-text="comment.quote"'),
  759. "the task comment card shows the body before the quote")
  760. status, css = http("GET", "/_statum/resource/app.css", timeout=15)
  761. check(status == 200 and ".footnote" in css
  762. and re.search(r"\.comment\s*\{[^}]*color:\s*var\(--spry-fg-muted\)", css)
  763. and re.search(r"\.comment\s*\{[^}]*font-size:\s*0\.88rem", css),
  764. "the stylesheet ships the footnote utility and muted comment cards")
  765. home_html = http_get_page(auth, "/")
  766. # Goal 41 T10: the dashboard's feed is the work stream now; the
  767. # comment-as-footnote contract lives on in its log-highlight rows
  768. # (the compiled page escapes && as &amp;&amp; in the attribute).
  769. check("item.kind == 'log_highlight' &amp;&amp; item.event_kind"
  770. " == 'comment' ? 'footnote'" in home_html,
  771. "the dashboard stream renders comment rows as footnotes")
  772. print("== list ordering: newest first everywhere ==")
  773. # The ordering sweep: every table leads with the most recent row.
  774. # Fixtures land old-then-new; the first rendered data row must be
  775. # the newest task on the queue, and the create expander sits above
  776. # the table (verified on the compiled page markup).
  777. conn = db()
  778. conn.execute("insert into tasks (project_id, title, status, priority, created_at)"
  779. " values (1, 'Order probe old', 'queued', 5, strftime('%s','now') - 100)")
  780. conn.execute("insert into tasks (project_id, title, status, priority, created_at)"
  781. " values (1, 'Order probe new', 'queued', 5, strftime('%s','now'))")
  782. conn.commit()
  783. conn.close()
  784. rows = [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  785. if t["title"].startswith("Order probe")]
  786. check(len(rows) == 2 and rows[0]["title"] == "Order probe new",
  787. "the tasks table lists newest first (created desc, newest row leads)")
  788. status, queue_page = http("GET", "/tasks", timeout=15)
  789. check(status == 200
  790. and queue_page.index("New task") < queue_page.index("<table"),
  791. "the + New task button sits above the tasks table")
  792. # Comments/blocks threads: newest first since the sweep. The
  793. # comment list leads with the most recently added one.
  794. conn = db()
  795. conn.execute(
  796. "insert into task_comments (task_id, author, body, created_at)"
  797. " values (?, 'agent', 'Order probe old remark',"
  798. " strftime('%s','now') - 100)", (keeper,))
  799. conn.execute(
  800. "insert into task_comments (task_id, author, body, created_at)"
  801. " values (?, 'agent', 'Order probe new remark',"
  802. " strftime('%s','now'))", (keeper,))
  803. conn.commit()
  804. conn.close()
  805. bodies = [c["body"] for c in task_state(auth, keeper)["comments"]
  806. if c["body"].startswith("Order probe")]
  807. check(len(bodies) == 2 and bodies[0] == "Order probe new remark",
  808. "the task comment thread leads with the newest remark")
  809. # =====================================================================
  810. # Task 12 (task half): the mandate-card in-place editor.
  811. # =====================================================================
  812. print("== task 12: the mandate card editor ==")
  813. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  814. "project_id": "1", "title": "Mandate editor task", "priority": "4",
  815. "description": "# The original mandate\n\nWith a body."})
  816. conn = db()
  817. mandate_task = conn.execute(
  818. "select id from tasks where title = 'Mandate editor task'"
  819. ).fetchone()[0]
  820. conn.close()
  821. detail = task_state(auth, mandate_task)
  822. check(detail.get("editable_all") is True and detail.get("draft") is True,
  823. "the mandate editor task is a draft (every field editable)")
  824. task_page = http_get_page(auth, "/tasks/%d" % mandate_task)
  825. card_at = task_page.index('data-long-form data-field="task"')
  826. edit_at = task_page.index('data-lf-toggle>Edit</button>', card_at)
  827. area_at = task_page.index('data-lf-src="task.description"', card_at)
  828. check(card_at >= 0 and edit_at > card_at and area_at > card_at,
  829. "the mandate card carries the in-place Edit button and editor")
  830. check('name="description" rows="8"' in task_page,
  831. "the editor's textarea is the wide in-place one")
  832. # Goal 41 T5: the edit surface is the in-place Edit task card —
  833. # short properties only (no mandate field).
  834. edit_card_at = task_page.index('>Edit task')
  835. edit_card_end = task_page.index('</section>', edit_card_at)
  836. edit_html = task_page[edit_card_at:edit_card_end]
  837. check('name="description"' not in edit_html,
  838. "the Edit task card carries no mandate field")
  839. check('data-lf-toggle' in edit_html and 'data-lf-edit' in edit_html,
  840. "the Edit task card is the in-place editor (view + toggle + form)")
  841. check(task_page.count('data-long-form data-field="task"') == 2,
  842. "two in-place cards exist: the mandate and the Edit task card")
  843. # The PREFILL contract: the editor reads task.description from
  844. # state at open (JS-applied — stm-attribute.value is a no-op on
  845. # textarea). The compiled page therefore carries the state path,
  846. # and the save is a description-only update.
  847. check(task_page.count('data-lf-src="task.description"') == 1,
  848. "exactly one mandate editor textarea exists, keyed to state")
  849. act(auth, detail["update"], {"task_id": str(mandate_task),
  850. "description": "# The edited mandate\n\nSecond."})
  851. saved = task_state(auth, mandate_task)
  852. check(saved["description"] == "# The edited mandate\n\nSecond."
  853. and saved["title"] == "Mandate editor task",
  854. "the description-only save round-trips and leaves the title alone")
  855. # Editability rules unchanged: an ACTIVE task keeps the read-only
  856. # mandate (no Edit button, no editor textarea) while priority
  857. # remains editable in the expander.
  858. conn = db()
  859. conn.execute("insert into tasks (project_id, title, status, priority,"
  860. " created_at) values (1, 'Active mandate task', 'working', 5,"
  861. " strftime('%s','now'))")
  862. conn.commit()
  863. active_task = conn.execute(
  864. "select id from tasks where title = 'Active mandate task'"
  865. ).fetchone()[0]
  866. conn.close()
  867. active = task_state(auth, active_task)
  868. check(active.get("editable_all") is False
  869. and active.get("editable_priority") is True,
  870. "an active task edits priority only")
  871. # The compiled page is state-independent markup: statum gates the
  872. # editor through stm-if, which HIDES (never removes) the branch.
  873. # What the raw page can promise: the Edit button and the editor
  874. # form are BOTH inside task.editable_all-gated branches (statum
  875. # hides them at render), while the rendered mandate view carries
  876. # no gate at all.
  877. active_page = http_get_page(auth, "/tasks/%d" % active_task)
  878. # Goal 41 T5: two in-place cards exist (Edit task, Mandate); the
  879. # draft-gate assertions address the MANDATE card.
  880. card_at = active_page.find('>Mandate')
  881. card_end = active_page.find('</section>', card_at)
  882. card_html = active_page[card_at:card_end]
  883. toggle_at = card_html.find('data-lf-toggle')
  884. editor_at = card_html.find('data-lf-edit')
  885. check(toggle_at < 0 or 'stm-if="task.editable_all"' in card_html,
  886. "an active task's Edit button is draft-gated (stm-if hides it)")
  887. check(editor_at < 0 or card_html.rfind('stm-if="task.editable_all"', 0, editor_at) > 0,
  888. "an active task's editor form sits inside the draft-gated branch")
  889. # =====================================================================
  890. # Goal 8 task 38: the schema is UPGRADEABLE now — M0002 is the
  891. # incremental migration that un-flattened it. On this bundle's
  892. # fresh boot both migrations ran: assert serial 2 is recorded and
  893. # every object M0002 owns exists, so a future in-place edit of
  894. # M0001 (the regression this goal shipped against) is caught by
  895. # the serial staying 1 here while the schema drifts.
  896. # P1 (M0003) extends the chain: serial 3 records the event ledger
  897. # reader indexes.
  898. print("== migrations: incremental schema (M0002, M0003 recorded) ==")
  899. conn = db()
  900. serials = conn.execute(
  901. "select serial from __migrations where namespace = 'clanker-saddle'"
  902. " order by serial").fetchall()
  903. event_indexes = {r[0] for r in conn.execute(
  904. "select name from sqlite_master"
  905. " where type = 'index' and tbl_name = 'events'").fetchall()}
  906. conn.close()
  907. check([s[0] for s in serials] == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
  908. "the clanker-saddle namespace carries exactly serials 1 through 11"
  909. " (a fresh boot ran M0001 then the M0002 no-op then M0003,"
  910. " M0004 understandings, M0005 discoveries, M0006 advisor,"
  911. " M0007 the parking guard, M0008 answer-wake, M0009 the"
  912. " turn-cap park, M0010 the model config and M0011 the user"
  913. " preferences)")
  914. check(len(serials) == 11,
  915. "no duplicate migration rows (each guarded up() applied once)")
  916. check({"idx_events_scope", "idx_events_terminal"} <= event_indexes,
  917. "M0003's event reader indexes exist (fresh boot)")
  918. conn = db()
  919. tables = {r[0] for r in conn.execute(
  920. "select name from sqlite_master where type = 'table'")}
  921. saddle_cols = {r[1] for r in conn.execute(
  922. "pragma table_info('saddles')")}
  923. conn.close()
  924. check({"notes", "stacks", "stack_projects"} <= tables,
  925. "M0002's tables (notes, stacks, stack_projects) exist")
  926. check({"prompt_note", "workspace_root"} <= saddle_cols,
  927. "M0002's saddles columns (prompt_note, workspace_root) exist")
  928. print("== S5: the task page's LLM usage stat line ==")
  929. # usage_summary aggregates the seeded usage events (the daemon's
  930. # prompt/completion/elapsed payload) into one muted stat line on
  931. # the status card; zero-usage tasks carry no line at all.
  932. conn = db()
  933. conn.execute("insert into tasks (project_id, title, status, priority,"
  934. " created_at) values (1, 'Usage ledger task', 'working', 5,"
  935. " strftime('%s','now'))")
  936. usage_task = conn.execute("select max(id) from tasks").fetchone()[0]
  937. for payload in ({"prompt_tokens": 2084, "completion_tokens": 66,
  938. "elapsed_ms": 8519},
  939. {"prompt_tokens": 2458, "completion_tokens": 148,
  940. "elapsed_ms": 6032},
  941. {"prompt_tokens": 4519, "completion_tokens": 243,
  942. "elapsed_ms": 10544}):
  943. conn.execute(
  944. "insert into events (kind, actor, scope_type, scope_id, payload,"
  945. " created_at) values ('usage', 'harness', 'task', ?, ?,"
  946. " strftime('%s','now'))",
  947. (usage_task, json.dumps(payload)))
  948. conn.execute("insert into tasks (project_id, title, status, priority,"
  949. " created_at) values (1, 'Usage magnitude task', 'working', 5,"
  950. " strftime('%s','now'))")
  951. magnitude_task = conn.execute("select max(id) from tasks").fetchone()[0]
  952. conn.execute(
  953. "insert into events (kind, actor, scope_type, scope_id, payload,"
  954. " created_at) values ('usage', 'harness', 'task', ?, ?,"
  955. " strftime('%s','now'))",
  956. (magnitude_task, json.dumps({"prompt_tokens": 45200000,
  957. "completion_tokens": 1100000,
  958. "elapsed_ms": 11520000})))
  959. conn.commit()
  960. conn.close()
  961. detail = task_state(auth, usage_task)
  962. check(detail.get("usage_line") == "LLM: 3 calls · 9,061 in / 457 out tok · 25s",
  963. "the task state aggregates the seeded usage events (calls, tokens,"
  964. " wall time): %r" % detail.get("usage_line"))
  965. check(task_state(auth, magnitude_task).get("usage_line")
  966. == "LLM: 1 calls · 45.2M in / 1.1M out tok · 3h 12m",
  967. "token magnitudes and durations format human-friendly"
  968. " (%r)" % task_state(auth, magnitude_task).get("usage_line"))
  969. task_page = http_get_page(auth, "/tasks/%d" % usage_task)
  970. check('stm-if="task.usage_line.length &gt; 0"' in task_page
  971. and 'stm-text="task.usage_line"' in task_page,
  972. "the task page renders the usage stat line (gated on non-empty)")
  973. check(task_state(auth, draft_display).get("usage_line") == "",
  974. "a task with no usage events carries no stat line")