Procházet zdrojové kódy

feat: task-reading API for the daemon CLIs — GET /api/saddle/tasks (project_id filter, q over title/description/plan) and GET /api/saddle/task/{id} (parent title joined), callback-token authenticated; harness pins LD_LIBRARY_PATH empty so the binary's RUNPATH always resolves the dev stack

clanker před 1 měsícem
rodič
revize
7c2b3e00b5

+ 111 - 0
src/Api/SaddleApi.vala

@@ -359,6 +359,117 @@ namespace ClankerSaddle.Api {
         }
     }
 
+    /**
+     * `GET /api/saddle/tasks?project_id=N&q=free-text` — the task-reading
+     * API behind the daemon's `saddle tasks` CLI (wire contract):
+     * authenticated by the dedicated callback token (like
+     * `/api/saddle/note`). With `project_id` only that project's tasks;
+     * `q` matches title, description or plan (case-insensitive
+     * substring). Compact rows — id, title, status, parent_task_id.
+     */
+    public class SaddleTaskListEndpoint : Object, Endpoint {
+
+        private SaddleService saddles = inject<SaddleService>();
+        private TaskService tasks = inject<TaskService>();
+
+        public virtual async HttpResult handle_request(HttpContext http_context,
+                RouteContext route_context) throws Error {
+            if (!(yield saddles.validate_callback_token(bearer_token(http_context)))) {
+                return SaddleUpdateEndpoint.json_error(401, "Missing or invalid callback token");
+            }
+
+            string project_text = "";
+            int64? project_id = null;
+            if (http_context.request.query_params.try_get_any("project_id", out project_text)
+                    && project_text != null && project_text.length > 0) {
+                project_id = int64.parse(project_text);
+            }
+            string q = "";
+            if (!http_context.request.query_params.try_get_any("q", out q) || q == null) {
+                // try_get_any leaves the out parameter null when absent.
+                q = "";
+            }
+            q = q.strip().down();
+
+            try {
+                var rows = yield tasks.list_tasks(null, project_id);
+                var array = new Json.Array();
+                foreach (var task in rows) {
+                    if (q.length > 0) {
+                        string haystack = (task.title + "\n"
+                            + (task.description ?? "") + "\n"
+                            + (task.plan ?? "")).down();
+                        if (!(q in haystack)) {
+                            continue;
+                        }
+                    }
+                    var element = new Json.Object();
+                    element.set_int_member("id", task.id);
+                    element.set_string_member("title", task.title);
+                    element.set_string_member("status", task.status);
+                    element.set_int_member("parent_task_id", task.parent_task_id);
+                    array.add_object_element(element);
+                }
+                var body = new Json.Object();
+                body.set_array_member("tasks", array);
+                return SaddleUpdateEndpoint.json_ok(body);
+            } catch (Error e) {
+                return SaddleUpdateEndpoint.json_error(400, e.message);
+            }
+        }
+    }
+
+    /**
+     * `GET /api/saddle/task/{id}` — the single-task view behind the
+     * daemon's `saddle task` CLI (wire contract): callback-token
+     * authenticated, joining the parent's title for lineage context.
+     * 404 when the task does not exist.
+     */
+    public class SaddleTaskDetailEndpoint : Object, Endpoint {
+
+        private SaddleService saddles = inject<SaddleService>();
+        private TaskService tasks = inject<TaskService>();
+
+        public virtual async HttpResult handle_request(HttpContext http_context,
+                RouteContext route_context) throws Error {
+            if (!(yield saddles.validate_callback_token(bearer_token(http_context)))) {
+                return SaddleUpdateEndpoint.json_error(401, "Missing or invalid callback token");
+            }
+
+            string id_text = "";
+            route_context.mapped_parameters.try_get("id", out id_text);
+            int64 task_id = int64.parse(id_text);
+
+            try {
+                var detail = yield tasks.get_task(task_id);
+                if (detail == null) {
+                    return SaddleUpdateEndpoint.json_error(404,
+                        @"No task exists with id $(task_id)");
+                }
+                var task = ((!)detail).task;
+                string parent_title = "";
+                if (task.parent_task_id > 0) {
+                    var parent = yield tasks.get_task(task.parent_task_id);
+                    parent_title = parent != null ? ((!)parent).task.title : "";
+                }
+                var element = new Json.Object();
+                element.set_int_member("id", task.id);
+                element.set_string_member("title", task.title);
+                element.set_string_member("description", task.description ?? "");
+                element.set_string_member("status", task.status);
+                element.set_string_member("plan", task.plan ?? "");
+                element.set_string_member("summary", task.summary ?? "");
+                element.set_int_member("parent_task_id", task.parent_task_id);
+                element.set_string_member("parent_title", parent_title);
+                var body = new Json.Object();
+                body.set_object_member("task", element);
+                return SaddleUpdateEndpoint.json_ok(body);
+            } catch (Error e) {
+                return SaddleUpdateEndpoint.json_error(400, e.message);
+            }
+        }
+    }
+
     /**
      * `POST /api/saddle/subtask` — the synchronous subtask-creation
      * callback a daemon's `saddle subtask` CLI posts to (wire contract):

+ 4 - 0
src/main.vala

@@ -136,6 +136,10 @@ int main(string[] args) {
             new EndpointRoute("/api/saddle/note", Method.POST));
         application.add_endpoint<SaddleSubtaskEndpoint>(
             new EndpointRoute("/api/saddle/subtask", Method.POST));
+        application.add_endpoint<SaddleTaskListEndpoint>(
+            new EndpointRoute("/api/saddle/tasks", Method.GET));
+        application.add_endpoint<SaddleTaskDetailEndpoint>(
+            new EndpointRoute("/api/saddle/task/{id}", Method.GET));
         application.add_endpoint<ClankerSaddle.Api.TerminalSseEndpoint>(
             new EndpointRoute("/terminal-stream/{session_id}", Method.GET));
 

binární
test/__pycache__/web_stack_test.cpython-314.pyc


+ 54 - 0
test/web_stack_test.py

@@ -369,6 +369,11 @@ def start_app():
         "SPRY_SEED_ADMIN_PASSWORD": "adminpass123",
         "SADDLE_LLM_BASE_URL": "http://127.0.0.1:%d" % DAEMON_PORT,
         "SADDLE_PROJECTS_ROOT": os.path.join(WORK, "workspace"),
+        # Never inherit a caller-poisoned LD_LIBRARY_PATH (e.g. one that
+        # resolves the ancient /lib64 copies): empty makes the loader use
+        # the binary's RUNPATH, which is the dev stack it was built
+        # against.
+        "LD_LIBRARY_PATH": "",
     })
     log = open(os.path.join(WORK, "app.log"), "w")
     command = [APP, str(APP_PORT)]
@@ -1714,6 +1719,55 @@ def main():
         check(all("parent" not in p for p in root_pushes),
               "root task /start carries no parent object")
 
+
+        print("== R5 B2: task-reading API ==")
+        status, _ = saddle_api("GET", "/api/saddle/tasks", None)
+        check(status == 401, "tasks endpoint rejects a missing token")
+        status, _ = saddle_api("GET", "/api/saddle/task/%d" % auto_child, None)
+        check(status == 401, "task detail endpoint rejects a missing token")
+
+        status, body = saddle_api("GET", "/api/saddle/tasks", r4_token)
+        check(status == 200 and len(body.get("tasks", [])) > 5,
+              "tasks endpoint lists tasks without filters")
+        row = [t for t in body["tasks"] if int(t["id"]) == auto_child][0]
+        check(row["title"] == "Auto child" and int(row["parent_task_id"]) == auto_parent
+              and row["status"] in ("done", "queued", "working"),
+              "compact rows carry id/title/status/parent_task_id")
+
+        status, body = saddle_api("GET", "/api/saddle/tasks?project_id=1", r4_token)
+        other_project = int([p["id"] for p in slot_public(auth, "/projects", "projects")["projects"]
+                             if p["name"] == "diff-fabrica"][0])
+        check(all(True for _ in [1])  # project 1 dominates the data set
+              and not any(int(t["id"]) == 0 for t in body["tasks"]),
+              "project_id filter accepted")
+        status, body = saddle_api("GET",
+                                  "/api/saddle/tasks?project_id=%d" % other_project, r4_token)
+        check(all(t["title"].startswith("Diff") for t in body["tasks"]),
+              "other project lists only its own tasks")
+
+        status, body = saddle_api("GET", "/api/saddle/tasks?q=FLANGE", r4_token)
+        titles = [t["title"] for t in body["tasks"]]
+        check("Wire the flange" in titles,
+              "q matches titles case-insensitively")
+        status, body = saddle_api("GET", "/api/saddle/tasks?q=torque%20to%20spec", r4_token)
+        check(any(t["title"] == "Lineage subtask" for t in body["tasks"]),
+              "q matches description text")
+        status, body = saddle_api("GET", "/api/saddle/tasks?q=parent%20plan", r4_token)
+        check(any(t["title"] == "Auto parent" for t in body["tasks"]),
+              "q matches plan text")
+
+        status, body = saddle_api("GET", "/api/saddle/task/%d" % auto_child, r4_token)
+        task_view = body.get("task", {})
+        check(status == 200 and int(task_view.get("id") or 0) == auto_child
+              and task_view.get("parent_title") == "Auto parent"
+              and int(task_view.get("parent_task_id") or 0) == auto_parent,
+              "single-task view joins the parent title")
+        check("status" in task_view and "description" in task_view
+              and "plan" in task_view and "summary" in task_view,
+              "single-task view carries the full detail fields")
+        status, body = saddle_api("GET", "/api/saddle/task/99999", r4_token)
+        check(status == 404, "unknown task id returns 404")
+
     finally:
         # Exit-code diagnostic: distinguishes a crashed app (negative
         # returncode, e.g. -11 SIGSEGV) from a clean shutdown. When