浏览代码

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 月之前
父节点
当前提交
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
      * `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));
         application.add_endpoint<SaddleCapabilitiesEndpoint>(
             new EndpointRoute("/api/saddle/capabilities", Method.GET));
+        application.add_endpoint<SaddleGoalsEndpoint>(
+            new EndpointRoute("/api/saddle/goals", Method.GET));
         application.add_endpoint<ClankerSaddle.Api.TaskEventsEndpoint>(
             new EndpointRoute("/tasks/{task_id}/events", Method.GET));
         application.add_endpoint<ClankerSaddle.Api.EventStreamSseEndpoint>(

+ 13 - 1
src/pages/task_log.html

@@ -98,7 +98,8 @@
             function filterOf(kind) {
                 if (kind === 'tool_call') return 'commands';
                 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';
                 return 'transitions';
             }
@@ -438,6 +439,17 @@
                     cbody.textContent = (p.author ? p.author + ': ' : '') + (p.body || '');
                     quote.appendChild(cbody);
                     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') {
                     var ul = document.createElement('ul');
                     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)
     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) ==")
     # Release the held workers: their completions wake the parked goal
     # 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"],
               "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
     # into the audit trail without failing (or finishing) the task.
     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 ""
 
 
+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):
     """PORT of fmtDuration."""
     if ms < 1000:
@@ -822,6 +842,46 @@ class SearchHaystackTests(unittest.TestCase):
         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):
     """The shipped script itself (no JS engine in this container, so
     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("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__":
     unittest.main(verbosity=2)