|
|
@@ -65,9 +65,9 @@ def run(ctx):
|
|
|
"task page carries the quote-comment dialog the popover opens")
|
|
|
check('name="quote"' in page and 'name="return_to"' in page,
|
|
|
"quote dialog posts the quote and return-to fields")
|
|
|
- status, page = http("GET", "/tasks/%d/terminal" % plan_task, timeout=15)
|
|
|
- check(status == 200 and "quote-comment-dialog" in page,
|
|
|
- "terminal page keeps its quote dialog")
|
|
|
+ status, page = http("GET", "/tasks/%d/log" % plan_task, timeout=15)
|
|
|
+ check(status == 200 and "data-log-feed" in page and "data-log-more" in page,
|
|
|
+ "the event log page renders the feed and the load-more control")
|
|
|
|
|
|
print("== B3: review section hides the duplicate body ==")
|
|
|
status, _ = raise_block(session_id, "Review the diff", kind="review",
|
|
|
@@ -252,45 +252,46 @@ def run(ctx):
|
|
|
print("== rejected entries (non-fatal) ==")
|
|
|
# The push wire: a rejected entry rides a round-results push, stores
|
|
|
# on the session and streams through SSE; the task is untouched.
|
|
|
- status, _ = saddle_api("POST", "/api/saddle/results", fake_token, {
|
|
|
- "session_id": session_id, "state": "working",
|
|
|
- "entries": [{
|
|
|
- "id": 9021, "command": "bash -n script.sh",
|
|
|
- "output": "syntax error near line 3 (recoverable)",
|
|
|
- "exit_code": 1, "type": "rejected",
|
|
|
- "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
|
- }]})
|
|
|
- check(status == 200, "a rejected entry rides the results push")
|
|
|
- request = urllib.request.Request(WEB + "/terminal-stream/%d" % session_id)
|
|
|
+ status, body = saddle_api("POST", "/api/saddle/chunks", fake_token, {
|
|
|
+ "session_id": session_id,
|
|
|
+ "events": [{"seq": 9021, "kind": "tool_result", "actor": "harness",
|
|
|
+ "payload": {"command": "bash -n script.sh",
|
|
|
+ "stderr": "syntax error near line 3 (recoverable)",
|
|
|
+ "exit_code": 1}}]})
|
|
|
+ check(status == 200 and int(body.get("stored") or 0) == 1,
|
|
|
+ "a failed tool result rides the events push")
|
|
|
+ request = urllib.request.Request(WEB + "/tasks/%d/events/stream" % plan_task)
|
|
|
with urllib.request.urlopen(request, timeout=15) as stream:
|
|
|
- rejected_event = None
|
|
|
+ result_event = None
|
|
|
deadline = time.monotonic() + 8
|
|
|
- while time.monotonic() < deadline and rejected_event is None:
|
|
|
+ while time.monotonic() < deadline and result_event is None:
|
|
|
line = stream.readline().decode().strip()
|
|
|
if not line.startswith("data: ") or line == "data: ping":
|
|
|
continue
|
|
|
event = json.loads(line[6:])
|
|
|
- if event.get("type") == "rejected":
|
|
|
- rejected_event = event
|
|
|
- check(rejected_event is not None
|
|
|
- and "syntax error near line 3" in (rejected_event.get("output") or ""),
|
|
|
- "terminal stream carries rejected entries with their reason")
|
|
|
-
|
|
|
- # The page template renders them with the distinct non-fatal style.
|
|
|
- status, page = http("GET", "/tasks/%d/terminal" % plan_task, timeout=15)
|
|
|
- check(status == 200 and "term-rejected" in page,
|
|
|
- "terminal page renders rejected entries in their own style")
|
|
|
-
|
|
|
- print("== B8: ANSI SGR in the terminal view ==")
|
|
|
- # Byte-exact SGR delivery: the daemon's coloured chunks flow through
|
|
|
- # the store + SSE untouched (the client renderer styles them).
|
|
|
+ if (event.get("kind") == "tool_result"
|
|
|
+ and "syntax error near line 3" in
|
|
|
+ (event.get("payload", {}).get("stderr") or "")):
|
|
|
+ result_event = event
|
|
|
+ check(result_event is not None,
|
|
|
+ "the event stream carries tool results with their reason")
|
|
|
+
|
|
|
+ # The page template renders the feed and the filters.
|
|
|
+ status, page = http("GET", "/tasks/%d/log" % plan_task, timeout=15)
|
|
|
+ check(status == 200 and "data-log-filter" in page and "data-log-search" in page,
|
|
|
+ "the event log page renders filters and search")
|
|
|
+
|
|
|
+ print("== B8: byte-exact payload delivery over the event stream ==")
|
|
|
+ # Byte-exact delivery: ANSI-coloured turn text flows through the
|
|
|
+ # ledger + SSE untouched (the client renderer styles them).
|
|
|
coloured = "\x1b[1;32mbold green\x1b[0m then \x1b[38;5;196m256-red\x1b[0m\n"
|
|
|
- conn = db()
|
|
|
- conn.execute("insert into session_raw_log (session_id, seq, chunk)"
|
|
|
- " values (?, 900, ?)", (session_id, coloured))
|
|
|
- conn.commit()
|
|
|
- conn.close()
|
|
|
- request = urllib.request.Request(WEB + "/terminal-stream/%d" % session_id)
|
|
|
+ status, body = saddle_api("POST", "/api/saddle/chunks", fake_token, {
|
|
|
+ "session_id": session_id,
|
|
|
+ "events": [{"seq": 9100, "kind": "turn_text",
|
|
|
+ "payload": {"turn": 9, "text": coloured}}]})
|
|
|
+ check(status == 200 and int(body.get("stored") or 0) == 1,
|
|
|
+ "the coloured turn text stores as one event")
|
|
|
+ request = urllib.request.Request(WEB + "/tasks/%d/events/stream" % plan_task)
|
|
|
with urllib.request.urlopen(request, timeout=15) as stream:
|
|
|
coloured_event = None
|
|
|
deadline = time.monotonic() + 8
|
|
|
@@ -299,14 +300,12 @@ def run(ctx):
|
|
|
if not line.startswith("data: ") or line == "data: ping":
|
|
|
continue
|
|
|
event = json.loads(line[6:])
|
|
|
- if event.get("type") == "raw" and "\x1b[1;32m" in (event.get("text") or ""):
|
|
|
+ if (event.get("kind") == "turn_text"
|
|
|
+ and "\x1b[1;32m" in (event.get("payload", {}).get("text") or "")):
|
|
|
coloured_event = event
|
|
|
check(coloured_event is not None
|
|
|
- and "\x1b[38;5;196m" in coloured_event["text"],
|
|
|
- "SGR sequences stream byte-exact to the client renderer")
|
|
|
- status, page = http("GET", "/tasks/%d/terminal" % plan_task, timeout=15)
|
|
|
- check(status == 200 and "makeAnsiRenderer" in page and "colour256" in page,
|
|
|
- "terminal page embeds the ANSI renderer (SGR + 256-colour palette)")
|
|
|
+ and "\x1b[38;5;196m" in coloured_event["payload"]["text"],
|
|
|
+ "payload bytes stream byte-exact to the client renderer")
|
|
|
|
|
|
# Dispatch path: a rejected entry in a round's results push flows
|
|
|
# into the audit trail without failing (or finishing) the task.
|
|
|
@@ -323,13 +322,10 @@ def run(ctx):
|
|
|
"max_tokens exceeded (recoverable)")
|
|
|
check(wait_task_done(auth, rejected_task, timeout=60) is not None,
|
|
|
"task completes normally past rejected entries")
|
|
|
- conn = db()
|
|
|
- stored = conn.execute(
|
|
|
- "select count(*) from session_commands sc join sessions s on s.id = sc.session_id"
|
|
|
- " where s.task_id = ? and sc.type = 'rejected'", (rejected_task,)).fetchone()[0]
|
|
|
- conn.close()
|
|
|
- check(stored >= 1,
|
|
|
- "rejected entries stored in the audit trail (%d rows)" % stored)
|
|
|
+ stored = [row for row in event_rows(rejected_task, ["command_output"])
|
|
|
+ if "max_tokens exceeded" in row[1]]
|
|
|
+ check(len(stored) >= 1,
|
|
|
+ "command_output events stored in the audit trail (%d rows)" % len(stored))
|
|
|
check(task_state(auth, rejected_task)["status"] == "done"
|
|
|
and "fake daemon finished" in (task_state(auth, rejected_task).get("summary") or ""),
|
|
|
"rejected entries never fail the task")
|
|
|
@@ -363,12 +359,13 @@ def run(ctx):
|
|
|
layout_task = conn.execute("select max(id) from tasks").fetchone()[0]
|
|
|
# backdated so the dashboard-timeline fixtures below stay the
|
|
|
# newest events
|
|
|
- conn.execute("insert into task_progress (task_id, summary, created_at)"
|
|
|
- " values (?, 'first update', strftime('%s','now','-90 seconds'))",
|
|
|
- (layout_task,))
|
|
|
- conn.execute("insert into task_progress (task_id, summary, created_at)"
|
|
|
- " values (?, 'second update', strftime('%s','now','-89 seconds'))",
|
|
|
- (layout_task,))
|
|
|
+ for offset, line in ((90, "first update"), (89, "second update")):
|
|
|
+ payload = json.dumps({"text": line})
|
|
|
+ conn.execute(
|
|
|
+ "insert into events (kind, actor, scope_type, scope_id, payload,"
|
|
|
+ " created_at) values ('progress', 'model', 'task', ?, ?,"
|
|
|
+ " strftime('%s','now', ?))",
|
|
|
+ (layout_task, payload, "-%d seconds" % offset))
|
|
|
conn.commit()
|
|
|
conn.close()
|
|
|
status, history_page = http("GET", "/tasks/%d" % layout_task, timeout=15)
|
|
|
@@ -411,6 +408,21 @@ def run(ctx):
|
|
|
" strftime('%s','now'), strftime('%s','now'),"
|
|
|
" strftime('%s','now'))")
|
|
|
report_empty = conn.execute("select max(id) from tasks").fetchone()[0]
|
|
|
+ # The terminal transitions ride the ledger (state_change events
|
|
|
+ # carrying the summary the timeline snippets).
|
|
|
+ for task_id, to, summary in (
|
|
|
+ (report_done, "done", report_md),
|
|
|
+ (report_failed, "failed", "Boom: tests failed\n\nsecond line carries the detail"),
|
|
|
+ (report_empty, "done", "")):
|
|
|
+ payload = {"from": "working", "to": to,
|
|
|
+ "text": "Task completed" if to == "done" else "Task failed"}
|
|
|
+ if summary:
|
|
|
+ payload["summary"] = summary
|
|
|
+ conn.execute(
|
|
|
+ "insert into events (kind, actor, scope_type, scope_id, payload,"
|
|
|
+ " created_at) values ('state_change', 'system', 'task', ?, ?,"
|
|
|
+ " strftime('%s','now'))",
|
|
|
+ (task_id, json.dumps(payload)))
|
|
|
conn.commit()
|
|
|
conn.close()
|
|
|
|
|
|
@@ -544,130 +556,6 @@ def run(ctx):
|
|
|
"the create expander offers the mode + auto-enqueue-after selects"
|
|
|
" (no subtask checkboxes)")
|
|
|
|
|
|
- print("== yeehaw: ingestion, festive card, badge, timeline ==")
|
|
|
- # `saddle yeehaw <message>` arrives as a round-results entry with the
|
|
|
- # message in the output field: it stores like any entry type, earns
|
|
|
- # the task its 🤠 badge (derived, nothing new stored), renders as the
|
|
|
- # task page's festive card (last 3, newest first, "+N more") and
|
|
|
- # surfaces on the dashboard timeline as its own event kind.
|
|
|
- conn = db()
|
|
|
- conn.execute("insert into tasks (project_id, title, status, priority, created_at)"
|
|
|
- " values (1, 'Yeehaw task', 'working', 5, strftime('%s','now'))")
|
|
|
- yeehaw_task = conn.execute("select max(id) from tasks").fetchone()[0]
|
|
|
- conn.execute("insert into sessions (task_id, saddle_id, started_at)"
|
|
|
- " values (?, ?, strftime('%s','now'))", (yeehaw_task, fake_id))
|
|
|
- yeehaw_session = conn.execute("select max(id) from sessions").fetchone()[0]
|
|
|
- conn.commit()
|
|
|
- conn.close()
|
|
|
- now_epoch = int(time.time())
|
|
|
-
|
|
|
- def push_yeehaw(entry_id, message, offset=0):
|
|
|
- """One celebration riding a round-results push, timestamped
|
|
|
- `offset` seconds from now (distinct epochs keep the timeline's
|
|
|
- newest-first ordering observable)."""
|
|
|
- return saddle_api("POST", "/api/saddle/results", fake_token, {
|
|
|
- "session_id": yeehaw_session, "state": "working",
|
|
|
- "entries": [{
|
|
|
- "id": entry_id, "command": "saddle yeehaw", "output": message,
|
|
|
- "exit_code": 0, "type": "yeehaw",
|
|
|
- "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ",
|
|
|
- time.gmtime(now_epoch + offset)),
|
|
|
- }]})[0]
|
|
|
-
|
|
|
- def yeehaw_rows():
|
|
|
- conn = db()
|
|
|
- try:
|
|
|
- return conn.execute(
|
|
|
- "select count(*) from session_commands where session_id = ?"
|
|
|
- " and type = 'yeehaw'", (yeehaw_session,)).fetchone()[0]
|
|
|
- finally:
|
|
|
- conn.close()
|
|
|
-
|
|
|
- check(push_yeehaw(9101, "Shipped the flange") == 200,
|
|
|
- "yeehaw entries ingest through the results push like any entry type")
|
|
|
- check(yeehaw_rows() == 1,
|
|
|
- "the yeehaw entry stores as a session_commands row (message in output)")
|
|
|
- check(push_yeehaw(9101, "Shipped the flange") == 200 and yeehaw_rows() == 1,
|
|
|
- "a redelivered yeehaw push is idempotent on the daemon entry id")
|
|
|
- check(task_state(auth, yeehaw_task)["status"] == "working",
|
|
|
- "a yeehaw entry never derails the task state")
|
|
|
-
|
|
|
- push_yeehaw(9102, "Second hoedown", 1)
|
|
|
- push_yeehaw(9103, "Third hoedown\nsecond line carries the detail", 2)
|
|
|
- push_yeehaw(9104, "Fourth hoedown", 3)
|
|
|
- push_yeehaw(9105, "Fifth hoedown", 4)
|
|
|
- detail = task_state(auth, yeehaw_task)
|
|
|
- check(detail.get("yeehaw") is True and detail.get("yeehaw_total") == 5,
|
|
|
- "the detail state flags the badge presence and the uncapped total")
|
|
|
- check([y["message"] for y in detail.get("yeehaws", [])]
|
|
|
- == ["Fifth hoedown", "Fourth hoedown",
|
|
|
- "Third hoedown\nsecond line carries the detail"],
|
|
|
- "the festive card keeps the last 3 yeehaws, newest first (full messages)")
|
|
|
- check(detail.get("yeehaw_more") == 2,
|
|
|
- "the '+N more' hint counts the capped tail")
|
|
|
- status, page = http("GET", "/tasks/%d" % yeehaw_task, timeout=15)
|
|
|
- # The template compiler HTML-escapes the emoji (🤠 = 🤠).
|
|
|
- check(status == 200 and "yeehaw-card" in page and "🤠 Yeehaw!" in page,
|
|
|
- "the task page renders the festive yeehaw card")
|
|
|
- check(page.index("<h3>Progress</h3>") < page.index("yeehaw-card")
|
|
|
- < page.index("status-head"),
|
|
|
- "the card sits between the progress feed and the status grid")
|
|
|
- check('stm-text="task.yeehaw_more"' in page and "more yeehaw" in page,
|
|
|
- "the card carries the +N more hint")
|
|
|
- check("badge-yeehaw" in page and "status-corner" in page,
|
|
|
- "the status card corner carries the earned 🤠 badge")
|
|
|
- plain_detail = task_state(auth, layout_task)
|
|
|
- check(plain_detail.get("yeehaw") is False and plain_detail.get("yeehaw_total") == 0
|
|
|
- and plain_detail.get("yeehaws", []) == [],
|
|
|
- "tasks without yeehaw entries carry no card state")
|
|
|
- status, plain_page = http("GET", "/tasks/%d" % layout_task, timeout=15)
|
|
|
- check(status == 200 and 'section class="card yeehaw-card" stm-if="task.yeehaw"' in plain_page,
|
|
|
- "the card template only renders under state gating (stm-if task.yeehaw)")
|
|
|
-
|
|
|
- queue_rows = slot_public(auth, "/tasks", "tasks")["tasks"]
|
|
|
- check([t for t in queue_rows if int(t["id"]) == yeehaw_task][0].get("has_yeehaw") is True,
|
|
|
- "queue rows badge yeehaw tasks")
|
|
|
- check([t for t in queue_rows if int(t["id"]) == layout_task][0].get("has_yeehaw") is False,
|
|
|
- "queue rows leave yeehaw-less tasks bare")
|
|
|
- status, queue_page = http("GET", "/tasks", timeout=15)
|
|
|
- check(status == 200 and 'stm-if="task.has_yeehaw"' in queue_page,
|
|
|
- "the queue template renders the row badge")
|
|
|
-
|
|
|
- dash = slot_public(auth, "/", "dashboard")
|
|
|
- yeehaw_events = [e for e in dash.get("activity", []) if e["kind"] == "yeehaw"]
|
|
|
- check(len(yeehaw_events) == 5
|
|
|
- and all(int(e["task_id"]) == yeehaw_task for e in yeehaw_events),
|
|
|
- "every yeehaw entry surfaces as its own timeline event kind")
|
|
|
- check(yeehaw_events[0]["text"] == "🤖 yeehaw'd: Fifth hoedown",
|
|
|
- "the newest yeehaw event leads the timeline rows")
|
|
|
- check(all("second line" not in e["text"] for e in yeehaw_events),
|
|
|
- "multiline yeehaw messages snippet to their first line")
|
|
|
- referenced = [e for e in dash.get("activity", []) if int(e["task_id"]) == yeehaw_task]
|
|
|
- check(len(referenced) > 0 and all(e.get("task_yeehaw") is True for e in referenced),
|
|
|
- "dashboard timeline task references carry the badge flag")
|
|
|
- plain_refs = [e for e in dash.get("activity", []) if int(e["task_id"]) == layout_task]
|
|
|
- check(len(plain_refs) > 0 and all(e.get("task_yeehaw") is False for e in plain_refs),
|
|
|
- "references to yeehaw-less tasks stay bare")
|
|
|
- status, home_page = http("GET", "/", timeout=15)
|
|
|
- check(status == 200 and 'stm-if="item.task_yeehaw"' in home_page
|
|
|
- and "badge-yeehaw" in home_page,
|
|
|
- "the dashboard renders the badge on timeline task references")
|
|
|
-
|
|
|
- # Close the yeehaw fixture's round (a plain done push under its
|
|
|
- # session): under the derived view (item 23 — busy iff an open
|
|
|
- # session on an assigned/working task) the fixture round would
|
|
|
- # hold the fake's saddle busy forever; the goal section below
|
|
|
- # needs it free.
|
|
|
- status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
|
|
|
- {"session_id": yeehaw_session, "state": "done",
|
|
|
- "summary": "yeehaw fixture round closed"})
|
|
|
- check(wait_task_done(auth, yeehaw_task, timeout=30) is not None,
|
|
|
- "the yeehaw fixture round closes (the derived busy view frees the fake)")
|
|
|
-
|
|
|
- # (The old R10 scenario — branch_advanced riding /poll verdicts —
|
|
|
- # died with the poll channel; the answers bundle pins the hint on
|
|
|
- # its surviving surface, the resumed assignment payload.)
|
|
|
-
|
|
|
print("== plan 12: the web surfaces — pile, timeline, queue, task page ==")
|
|
|
# A goal parked in plan review feeds the unified dashboard pile;
|
|
|
# goal events feed the timeline.
|
|
|
@@ -749,19 +637,15 @@ def run(ctx):
|
|
|
task_page_html = http_get_page(auth, "/tasks/%d" % dash_run)
|
|
|
check("Mandate" in task_page_html and 'stm-markdown="task.description"' in task_page_html,
|
|
|
"the task page frames the description as the mandate")
|
|
|
- check(re.search(r"'/terminal\?session_id=' \+ session\.id\"\s+target=\"_blank\"",
|
|
|
+ check(re.search(r"'/tasks/' \+ task\.id \+ '/log'\"\s+target=\"_blank\"",
|
|
|
task_page_html) is not None,
|
|
|
- "the task page's session terminal links open in a new tab")
|
|
|
- terminal_state = slot_public(auth, "/tasks/%d/terminal" % dash_run, "terminal")
|
|
|
- session_rows = terminal_state["sessions"]
|
|
|
- check(len(session_rows) >= 2
|
|
|
- and all(("raw" in s) for s in session_rows),
|
|
|
- "the terminal state lists every goal-run session with per-session raw flags")
|
|
|
- check(int(terminal_state["session_id"]) == int(session_rows[0]["id"]),
|
|
|
- "the terminal default selects the LATEST session (rows are newest-first)")
|
|
|
- terminal_html = http_get_page(auth, "/tasks/%d/terminal" % dash_run)
|
|
|
- check("requestedSessionId" in terminal_html and "data-session-id" in terminal_html,
|
|
|
- "the terminal client owns the ?session_id= selection (query params never reach entrypoints)")
|
|
|
+ "the task page's event log links open in a new tab")
|
|
|
+ log_state = slot_public(auth, "/tasks/%d/log" % dash_run, "task_log")
|
|
|
+ check(log_state.get("exists") is True and int(log_state["task_id"]) == dash_run,
|
|
|
+ "the event log page state names the run task")
|
|
|
+ log_html = http_get_page(auth, "/tasks/%d/log" % dash_run)
|
|
|
+ check("EventSource" in log_html and "events/stream" in log_html,
|
|
|
+ "the log page subscribes to the task's SSE stream")
|
|
|
run_detail = task_state(auth, dash_run)
|
|
|
check(run_detail.get("is_goal_run") is True and run_detail.get("mode") == "orchestrate",
|
|
|
"the goal agent's own run task identifies itself on its page")
|
|
|
@@ -831,7 +715,6 @@ def run(ctx):
|
|
|
and queue_page.index("New task") < queue_page.index("<table"),
|
|
|
"the New task expander sits above the tasks table")
|
|
|
# Comments/blocks threads: newest first since the sweep. The
|
|
|
- # yeehaw task above carries session comments; assert the detail's
|
|
|
# comment list leads with the most recently added one.
|
|
|
conn = db()
|
|
|
conn.execute(
|