Просмотр исходного кода

Saddle goals API + yeehaw event rendering in the task log

Goal 9 part B (Clanker side of the agent-action parity sweep):

GET /api/saddle/goals gives saddle agents read-only goals context,
modeled on SaddleTaskListEndpoint: bearer-auth via resolve_bearer
(401 otherwise). With goal_id the single goal's detail object
(id/title/status/branch/brief/has_plan/has_report); otherwise the
goals whose primary OR linked project (GoalService.involved_project_ids
over goal_links) carries the named project, newest first, as compact
rows with updated_at (completed_at ?? started_at ?? created_at, UTC
ISO-8601). An unknown project name or a missing project param yields
{"goals": []} - context gathering must not break on a typo or an
unbounded dump (human-confirmed strict semantics). Route registered
next to the other /api/saddle GETs. Wire shape fixed by Saddle's
already-merged list_goals tool.

The task-log page renders the daemon's yeehaw ledger events: kind
'yeehaw' lands in the comments filter bucket (beside comment/answer/
verdict) and the render switch draws the message in the bold
.yeehaw-message span - the event-head badge already carries the
.badge-yeehaw palette via kindClass, so no new CSS.

Tests: lineage bundle covers 401, name lookup, linked-project
inclusion, newest-first ordering, both empty-list semantics, the
goal_id detail flags and 404; the pages bundle pushes a yeehaw event
through /api/saddle/chunks and asserts storage, SSE delivery with the
message intact and page render; test_task_log_js.py gains a filterOf
port, yeehaw render ports and shipped-source shape checks.
Full web-stack suite 1123 passed / 0 failed; task-log-js 60/60.
saddle-Clanker-Saddle 1 месяц назад
Родитель
Сommit
c260259da1
6 измененных файлов с 316 добавлено и 1 удалено
  1. 113 0
      src/Api/SaddleApi.vala
  2. 2 0
      src/main.vala
  3. 13 1
      src/pages/task_log.html
  4. 77 0
      test/bundles/lineage.py
  5. 40 0
      test/bundles/pages.py
  6. 71 0
      test/test_task_log_js.py

+ 113 - 0
src/Api/SaddleApi.vala

@@ -235,6 +235,119 @@ namespace ClankerSaddle.Api {
         }
         }
     }
     }
 
 
+    /**
+     * `GET /api/saddle/goals?project=<name>&goal_id=<id>` (plan-17
+     * parity sweep) — the read-only goals context behind the daemon's
+     * `list_goals` tool (wire contract): authenticated by the
+     * per-saddle bearer token (plan 10 — any registered saddle's
+     * credential authorises the read-only view). With `goal_id` the
+     * single goal's detail (brief included — Saddle clamps again);
+     * otherwise the goals whose primary OR linked project carries the
+     * named project, newest first, as compact rows. An unknown
+     * project name yields an empty list, not an error — the agent's
+     * context gathering must not break on a typo.
+     *
+     * ```json
+     * {"ok": true, "goals": [{"id": 7, "title": "…", "status": "running",
+     *                         "branch": "goal/…", "updated_at": "…"}]}
+     * {"ok": true, "goal": {"id": 7, "title": "…", "status": "running",
+     *                       "branch": "goal/…", "brief": "…",
+     *                       "has_plan": true, "has_report": false}}
+     * ```
+     */
+    public class SaddleGoalsEndpoint : Object, Endpoint {
+
+        private SaddleService saddles = inject<SaddleService>();
+        private ProjectService projects = inject<ProjectService>();
+        private GoalService goals = inject<GoalService>();
+
+        public virtual async HttpResult handle_request(HttpContext http_context,
+                RouteContext route_context) throws Error {
+            if ((yield resolve_bearer(http_context, saddles)) == null) {
+                return SaddleWire.json_error(401, "Missing or invalid saddle token");
+            }
+
+            string goal_text = "";
+            if (http_context.request.query_params.try_get_any("goal_id", out goal_text)
+                    && goal_text != null && goal_text.length > 0) {
+                int64 goal_id = int64.parse(goal_text);
+                try {
+                    var goal = goal_id > 0 ? yield goals.get_goal(goal_id) : null;
+                    if (goal == null) {
+                        return SaddleWire.json_error(404,
+                            @"No goal exists with id $(goal_id)");
+                    }
+                    var element = new Json.Object();
+                    element.set_int_member("id", ((!)goal).id);
+                    element.set_string_member("title", ((!)goal).title);
+                    element.set_string_member("status", ((!)goal).status);
+                    element.set_string_member("branch", ((!)goal).branch_name);
+                    element.set_string_member("brief", ((!)goal).brief ?? "");
+                    element.set_boolean_member("has_plan",
+                        (((!)goal).plan ?? "").length > 0);
+                    element.set_boolean_member("has_report",
+                        (((!)goal).completion_report ?? "").length > 0);
+                    var body = new Json.Object();
+                    body.set_object_member("goal", element);
+                    return SaddleWire.json_ok(body);
+                } catch (Error e) {
+                    return SaddleWire.json_error(400, e.message);
+                }
+            }
+
+            string project_name = "";
+            if (!http_context.request.query_params.try_get_any("project", out project_name)
+                    || project_name == null) {
+                project_name = "";
+            }
+            project_name = project_name.strip();
+
+            try {
+                var array = new Json.Array();
+                if (project_name.length > 0) {
+                    var project = yield projects.get_project_by_name(project_name);
+                    if (project != null) {
+                        string project_key = ((!)project).id.to_string();
+                        // list_goals is newest-first (id descending), so
+                        // the filtered rows keep that order.
+                        foreach (var goal in yield goals.list_goals()) {
+                            var involved = yield goals.involved_project_ids(goal);
+                            if (!involved.contains(project_key)) {
+                                continue;
+                            }
+                            array.add_object_element(goal_row(goal));
+                        }
+                    }
+                }
+                var body = new Json.Object();
+                body.set_array_member("goals", array);
+                return SaddleWire.json_ok(body);
+            } catch (Error e) {
+                return SaddleWire.json_error(400, e.message);
+            }
+        }
+
+        /**
+         * One compact list row — id, title, status, branch and the
+         * most recent lifecycle stamp as ISO-8601 (`updated_at`,
+         * read by Saddle's list_goals tool).
+         */
+        private static Json.Object goal_row(Goal goal) {
+            var element = new Json.Object();
+            element.set_int_member("id", goal.id);
+            element.set_string_member("title", goal.title);
+            element.set_string_member("status", goal.status);
+            element.set_string_member("branch", goal.branch_name);
+            DateTime? updated = goal.completed_at ?? goal.started_at ?? goal.created_at;
+            if (updated != null) {
+                element.set_string_member("updated_at",
+                    ((!)updated).to_timezone(new TimeZone.utc())
+                        .format("%Y-%m-%dT%H:%M:%SZ"));
+            }
+            return element;
+        }
+    }
+
     /**
     /**
      * `POST /api/saddle/note` — the callback endpoint a daemon's
      * `POST /api/saddle/note` — the callback endpoint a daemon's
      * `saddle note` CLI posts to (wire contract): authenticated by the
      * `saddle note` CLI posts to (wire contract): authenticated by the

+ 2 - 0
src/main.vala

@@ -221,6 +221,8 @@ int main(string[] args) {
             new EndpointRoute("/api/saddle/task/{id}", Method.GET));
             new EndpointRoute("/api/saddle/task/{id}", Method.GET));
         application.add_endpoint<SaddleCapabilitiesEndpoint>(
         application.add_endpoint<SaddleCapabilitiesEndpoint>(
             new EndpointRoute("/api/saddle/capabilities", Method.GET));
             new EndpointRoute("/api/saddle/capabilities", Method.GET));
+        application.add_endpoint<SaddleGoalsEndpoint>(
+            new EndpointRoute("/api/saddle/goals", Method.GET));
         application.add_endpoint<ClankerSaddle.Api.TaskEventsEndpoint>(
         application.add_endpoint<ClankerSaddle.Api.TaskEventsEndpoint>(
             new EndpointRoute("/tasks/{task_id}/events", Method.GET));
             new EndpointRoute("/tasks/{task_id}/events", Method.GET));
         application.add_endpoint<ClankerSaddle.Api.EventStreamSseEndpoint>(
         application.add_endpoint<ClankerSaddle.Api.EventStreamSseEndpoint>(

+ 13 - 1
src/pages/task_log.html

@@ -98,7 +98,8 @@
             function filterOf(kind) {
             function filterOf(kind) {
                 if (kind === 'tool_call') return 'commands';
                 if (kind === 'tool_call') return 'commands';
                 if (kind === 'tool_result' || kind === 'command_output') return 'results';
                 if (kind === 'tool_result' || kind === 'command_output') return 'results';
-                if (kind === 'comment' || kind === 'answer' || kind === 'verdict') return 'comments';
+                if (kind === 'comment' || kind === 'answer' || kind === 'verdict'
+                        || kind === 'yeehaw') return 'comments';
                 if (kind === 'usage') return 'usage';
                 if (kind === 'usage') return 'usage';
                 return 'transitions';
                 return 'transitions';
             }
             }
@@ -438,6 +439,17 @@
                     cbody.textContent = (p.author ? p.author + ': ' : '') + (p.body || '');
                     cbody.textContent = (p.author ? p.author + ': ' : '') + (p.body || '');
                     quote.appendChild(cbody);
                     quote.appendChild(cbody);
                     body.appendChild(quote);
                     body.appendChild(quote);
+                } else if (evt.kind === 'yeehaw') {
+                    // The agent's celebration: the event-head badge
+                    // already carries the yeehaw palette (kindClass ->
+                    // .badge-yeehaw); here the message renders in the
+                    // bold .yeehaw-message span, never a payload dump.
+                    var whoop = document.createElement('p');
+                    var shout = document.createElement('span');
+                    shout.className = 'yeehaw-message';
+                    shout.textContent = strText(p.message);
+                    whoop.appendChild(shout);
+                    body.appendChild(whoop);
                 } else if (evt.kind === 'usage') {
                 } else if (evt.kind === 'usage') {
                     var ul = document.createElement('ul');
                     var ul = document.createElement('ul');
                     Object.keys(p).forEach(function (key) {
                     Object.keys(p).forEach(function (key) {

+ 77 - 0
test/bundles/lineage.py

@@ -157,6 +157,83 @@ def run(ctx):
     status, body = saddle_api("GET", "/api/saddle/task/99999", r4_token)
     status, body = saddle_api("GET", "/api/saddle/task/99999", r4_token)
     check(status == 404, "unknown task id returns 404")
     check(status == 404, "unknown task id returns 404")
 
 
+    print("== goal 9: the read-only goals API (list_goals wire) ==")
+    # The daemon's list_goals tool reads this endpoint: bearer-only,
+    # project-scoped (primary OR linked), newest first, with a
+    # goal_id detail carrying the plan/report presence flags.
+    status, _ = saddle_api("GET", "/api/saddle/goals", None)
+    check(status == 401, "goals endpoint rejects a missing token")
+    status, _ = saddle_api("GET", "/api/saddle/goals?project_id=1", r4_token)
+    check(status == 200, "goals endpoint ignores stray params (project is by name)")
+
+    # A linked-project goal: the primary's rows plus a goal that only
+    # references the project through goal_links.
+    conn = db()
+    conn.execute("insert into projects (name, git_url, default_branch, created_at)"
+                 " values ('goal-fabrica',"
+                 " 'ssh://fabrica.unitatem.net:222/clanker/clanker-saddle.git',"
+                 " 'main', strftime('%s','now'))")
+    conn.commit()
+    conn.close()
+    state = slot_public(auth, "/projects", "projects")
+    goal_fabrica = int([p["id"] for p in state["projects"]
+                        if p["name"] == "goal-fabrica"][0])
+    goal_via_link = create_goal_row({
+        "project_id": "1", "title": "Linked-context goal",
+        "brief": "Touches goal-fabrica only through its link.",
+        "branch_name": "goal/linked-context"})
+    act(auth, goal_state(goal_via_link)["add_link"],
+        {"linked_project_id": str(goal_fabrica),
+         "relationship": "co-developed"})
+
+    status, body = saddle_api("GET", "/api/saddle/goals?project=demo",
+                              r4_token)
+    check(status == 200 and any(int(g["id"]) == goal for g in body.get("goals", [])),
+          "the primary project's goals list (name lookup)")
+    row = [g for g in body["goals"] if int(g["id"]) == goal_via_link][0]
+    check(row["title"] == "Linked-context goal"
+          and row["status"] in ("draft", "planning", "plan_review", "active", "cancelled", "done")
+          and row["branch"] == "goal/linked-context"
+          and len(row.get("updated_at") or "") > 0,
+          "compact goal rows carry id/title/status/branch/updated_at")
+    ids = [int(g["id"]) for g in body["goals"]]
+    check(ids == sorted(ids, reverse=True),
+          "the goal list reads newest first")
+    check(not any(int(g["id"]) == goal_via_link
+                  for g in body["goals"] if int(g["id"]) != goal_via_link),
+          "no duplicate rows for a goal listed once")
+
+    status, body = saddle_api("GET", "/api/saddle/goals?project=goal-fabrica",
+                              r4_token)
+    check(status == 200
+          and [int(g["id"]) for g in body.get("goals", [])] == [goal_via_link],
+          "a linked-only project lists its linked goals (not the primary's)")
+
+    status, body = saddle_api("GET", "/api/saddle/goals", r4_token)
+    check(status == 200 and body.get("goals") == [],
+          "no project param yields an empty list (strict, human-confirmed)")
+    status, body = saddle_api("GET",
+                              "/api/saddle/goals?project=no-such-project", r4_token)
+    check(status == 200 and body.get("goals") == [],
+          "an unknown project name yields an empty list, not an error")
+
+    status, body = saddle_api("GET",
+                              "/api/saddle/goals?goal_id=%d" % goal, r4_token)
+    detail = body.get("goal") or {}
+    check(status == 200 and int(detail.get("id") or 0) == goal
+          and detail.get("title") == "Lineage goal"
+          and detail.get("branch") == "goal/lineage"
+          and "Carry the goal lineage." in detail.get("brief", ""),
+          "the goal_id detail carries id/title/branch/brief")
+    check(detail.get("has_plan") is True and detail.get("has_report") is False,
+          "the detail's has_plan/has_report reflect the goal's documents")
+    detail_link = saddle_api("GET", "/api/saddle/goals?goal_id=%d" % goal_via_link,
+                             r4_token)[1].get("goal") or {}
+    check(detail_link.get("has_plan") is False,
+          "a goal without an approved plan reports has_plan false")
+    status, body = saddle_api("GET", "/api/saddle/goals?goal_id=99999", r4_token)
+    check(status == 404, "an unknown goal_id returns 404")
+
     print("== settle: released workers resume the parked agent (coalesced) ==")
     print("== settle: released workers resume the parked agent (coalesced) ==")
     # Release the held workers: their completions wake the parked goal
     # Release the held workers: their completions wake the parked goal
     # agent (affinity — the same fake hosts it) with the batched
     # agent (affinity — the same fake hosts it) with the batched

+ 40 - 0
test/bundles/pages.py

@@ -307,6 +307,46 @@ def run(ctx):
               and "\x1b[38;5;196m" in coloured_event["payload"]["text"],
               and "\x1b[38;5;196m" in coloured_event["payload"]["text"],
               "payload bytes stream byte-exact to the client renderer")
               "payload bytes stream byte-exact to the client renderer")
 
 
+    print("== goal 9: the yeehaw celebration rides the ledger + stream ==")
+    # The daemon's yeehaw tool emits a `yeehaw` ledger event (actor
+    # model, payload {message}); it must store, stream and leave the
+    # page wiring intact for the client renderer (the comments bucket
+    # + .yeehaw-message span are pinned by test_task_log_js.py).
+    whoop = "Finally cracked the SSE replay - every byte landed."
+    status, body = saddle_api("POST", "/api/saddle/chunks", fake_token, {
+        "session_id": session_id,
+        "events": [{"seq": 9200, "kind": "yeehaw", "actor": "model",
+                    "payload": {"message": whoop}}]})
+    check(status == 200 and int(body.get("stored") or 0) == 1,
+          "a yeehaw event stores on the session ledger (%s)" % body.get("error"))
+    request = urllib.request.Request(WEB + "/tasks/%d/events/stream" % plan_task)
+    with urllib.request.urlopen(request, timeout=15) as stream:
+        yeehaw_event = None
+        deadline = time.monotonic() + 8
+        while time.monotonic() < deadline and yeehaw_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("kind") == "yeehaw":
+                yeehaw_event = event
+        check(yeehaw_event is not None
+              and yeehaw_event.get("actor") == "model"
+              and yeehaw_event.get("payload", {}).get("message") == whoop,
+              "the yeehaw event streams with its message intact")
+    conn = db()
+    stored_kind = conn.execute(
+        "select kind, actor from events where session_id = ? and seq = 9200",
+        (session_id,)).fetchone()
+    conn.close()
+    check(stored_kind is not None and stored_kind[0] == "yeehaw"
+          and stored_kind[1] == "model",
+          "the ledger row keeps the yeehaw kind + model actor")
+    status, page = http("GET", "/tasks/%d/log" % plan_task, timeout=15)
+    check(status == 200 and "data-log-feed" in page
+          and "yeehaw" not in page.split("<script>", 1)[0],
+          "the log page still renders with the yeehaw renderer shipped in its script")
+
     # Dispatch path: a rejected entry in a round's results push flows
     # Dispatch path: a rejected entry in a round's results push flows
     # into the audit trail without failing (or finishing) the task.
     # into the audit trail without failing (or finishing) the task.
     task_id_tmp = create_queued(auth, {
     task_id_tmp = create_queued(auth, {

+ 71 - 0
test/test_task_log_js.py

@@ -456,6 +456,26 @@ def str_text(value):
     return value if isinstance(value, str) else ""
     return value if isinstance(value, str) else ""
 
 
 
 
+def filter_of(kind):
+    """PORT of filterOf: which filter bucket owns a kind. The yeehaw
+    celebration lands in the comments bucket (goal 9 task 4b)."""
+    if kind == "tool_call":
+        return "commands"
+    if kind in ("tool_result", "command_output"):
+        return "results"
+    if kind in ("comment", "answer", "verdict", "yeehaw"):
+        return "comments"
+    if kind == "usage":
+        return "usage"
+    return "transitions"
+
+
+def yeehaw_render(evt):
+    """PORT of eventNode's yeehaw branch -> (span_class, message)."""
+    payload = evt.get("payload") or {}
+    return "yeehaw-message", str_text(payload.get("message"))
+
+
 def fmt_duration(ms):
 def fmt_duration(ms):
     """PORT of fmtDuration."""
     """PORT of fmtDuration."""
     if ms < 1000:
     if ms < 1000:
@@ -822,6 +842,46 @@ class SearchHaystackTests(unittest.TestCase):
         self.assertIn("cd /workspace && ls", haystack)
         self.assertIn("cd /workspace && ls", haystack)
 
 
 
 
+class YeehawFilterTests(unittest.TestCase):
+    """The celebration must ride the comments filter (goal 9 task 4b):
+    a human scanning the conversation sees the yeehaw, and the
+    comments toggle hides it like any other chatter."""
+
+    def test_yeehaw_maps_to_comments(self):
+        self.assertEqual(filter_of("yeehaw"), "comments")
+
+    def test_comment_kinds_unchanged(self):
+        for kind in ("comment", "answer", "verdict"):
+            self.assertEqual(filter_of(kind), "comments")
+
+    def test_other_kinds_unchanged(self):
+        self.assertEqual(filter_of("tool_call"), "commands")
+        self.assertEqual(filter_of("tool_result"), "results")
+        self.assertEqual(filter_of("command_output"), "results")
+        self.assertEqual(filter_of("usage"), "usage")
+        self.assertEqual(filter_of("lifecycle"), "transitions")
+
+
+class YeehawRenderTests(unittest.TestCase):
+    """The yeehaw branch: the message in the bold .yeehaw-message span
+    (the festive palette), never a JSON payload dump."""
+
+    def test_message_renders_in_the_span(self):
+        span, message = yeehaw_render(
+            {"kind": "yeehaw", "payload": {"message": "Loved every line"}})
+        self.assertEqual(span, "yeehaw-message")
+        self.assertEqual(message, "Loved every line")
+
+    def test_missing_message_is_blank_not_garbage(self):
+        span, message = yeehaw_render({"kind": "yeehaw", "payload": {}})
+        self.assertEqual(message, "")
+
+    def test_non_string_message_is_blank(self):
+        span, message = yeehaw_render(
+            {"kind": "yeehaw", "payload": {"message": 42}})
+        self.assertEqual(message, "")
+
+
 class RendererSourceTests(unittest.TestCase):
 class RendererSourceTests(unittest.TestCase):
     """The shipped script itself (no JS engine in this container, so
     """The shipped script itself (no JS engine in this container, so
     the render decisions are pinned by the ports above plus these
     the render decisions are pinned by the ports above plus these
@@ -849,6 +909,17 @@ class RendererSourceTests(unittest.TestCase):
         self.assertIn("bytes_out", self.body)
         self.assertIn("bytes_out", self.body)
         self.assertIn("duration_ms", self.body)
         self.assertIn("duration_ms", self.body)
 
 
+    def test_yeehaw_lands_in_the_comments_bucket(self):
+        self.assertRegex(
+            self.body,
+            r"kind === 'comment' \|\| kind === 'answer' \|\| kind === 'verdict'[\s\S]*?"
+            r"kind === 'yeehaw'\) return 'comments'")
+
+    def test_yeehaw_renders_the_message_span(self):
+        self.assertIn("evt.kind === 'yeehaw'", self.body)
+        self.assertIn("'yeehaw-message'", self.body)
+        self.assertIn("strText(p.message)", self.body)
+
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":
     unittest.main(verbosity=2)
     unittest.main(verbosity=2)