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

feat: POST /api/saddle/subtask callback — synchronous subtask creation (parent's project/branch, task_dependencies with exists+ancestor-cycle validation, timeline entry, idempotent on parent+title); entry-poll creation kept as dedup fallback

clanker 1 месяц назад
Родитель
Сommit
7625609d95
4 измененных файлов с 267 добавлено и 4 удалено
  1. 104 3
      src/Api/SaddleApi.vala
  2. 66 1
      src/Services/TaskService.vala
  3. 2 0
      src/main.vala
  4. 95 0
      test/web_stack_test.py

+ 104 - 3
src/Api/SaddleApi.vala

@@ -101,9 +101,16 @@ namespace ClankerSaddle.Api {
                     return block_response;
 
                 case "subtask":
-                    yield tasks.create_subtask(task_id,
-                        get_string_or(data, "title", "Untitled subtask"),
-                        get_string_or(data, "description", null));
+                    // Dedup against the synchronous callback: a subtask
+                    // row already created for this parent/title (the
+                    // callback path) is not duplicated — the entry log
+                    // is the fallback, not a second source of truth.
+                    string subtask_title = get_string_or(data, "title", "Untitled subtask");
+                    var existing_subtask = yield tasks.find_subtask_by_title(task_id, subtask_title);
+                    if (existing_subtask == null) {
+                        yield tasks.create_subtask(task_id, subtask_title,
+                            get_string_or(data, "description", null));
+                    }
                     break;
 
                 case "plan":
@@ -351,6 +358,100 @@ namespace ClankerSaddle.Api {
         }
     }
 
+    /**
+     * `POST /api/saddle/subtask` — the synchronous subtask-creation
+     * callback a daemon's `saddle subtask` CLI posts to (wire contract):
+     * authenticated by the dedicated saddle-callback bearer token (like
+     * `/api/saddle/note`), creating the task row immediately — same
+     * project, branch and priority as the parent — with
+     * `task_dependencies` rows for every `depends_on` id and the
+     * timeline entry on the parent's session. Idempotent on
+     * (parent, title): a retried callback or the later entry-poll
+     * fallback returns the existing task's id instead of duplicating.
+     *
+     * ```json
+     * {"parent_task_id": 1, "title": "…", "description": "…",
+     *  "depends_on": [3, 4], "session_id": 2}
+     * ```
+     *
+     * → `{"ok": true, "task_id": 7}`.
+     */
+    public class SaddleSubtaskEndpoint : Object, Endpoint {
+
+        private SaddleService saddles = inject<SaddleService>();
+        private ProjectService projects = inject<ProjectService>();
+        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");
+            }
+
+            Json.Object body;
+            try {
+                body = yield SaddleUpdateEndpoint.read_json_body(http_context);
+            } catch (Error e) {
+                return SaddleUpdateEndpoint.json_error(400, e.message);
+            }
+
+            int64 parent_task_id = body.has_member("parent_task_id")
+                && body.get_member("parent_task_id").get_value_type() == typeof(int64)
+                ? body.get_int_member("parent_task_id") : 0;
+            string title = SaddleUpdateEndpoint.get_string_or(body, "title", "");
+            if (parent_task_id <= 0 || title.length == 0) {
+                return SaddleUpdateEndpoint.json_error(400,
+                    "parent_task_id and title are required");
+            }
+            var parent = yield tasks.get_task(parent_task_id);
+            if (parent == null) {
+                return SaddleUpdateEndpoint.json_error(400,
+                    @"No task exists with id $(parent_task_id)");
+            }
+
+            int64[] depends_on = {};
+            if (body.has_member("depends_on")
+                    && body.get_member("depends_on").get_node_type() == Json.NodeType.ARRAY) {
+                foreach (var element in body.get_array_member("depends_on").get_elements()) {
+                    if (element.get_node_type() == Json.NodeType.VALUE) {
+                        int64 parsed = 0;
+                        if (element.get_value_type() == typeof(int64)) {
+                            parsed = element.get_int();
+                        } else {
+                            parsed = int64.parse(element.get_string());
+                        }
+                        if (parsed > 0) {
+                            depends_on += parsed;
+                        }
+                    }
+                }
+            }
+
+            try {
+                var existing = yield tasks.find_subtask_by_title(parent_task_id, title);
+                TaskEntity task;
+                bool created = false;
+                if (existing != null) {
+                    task = (!)existing;
+                } else {
+                    task = yield tasks.create_subtask_with_dependencies(parent_task_id,
+                        title, SaddleUpdateEndpoint.get_string_or(body, "description", null),
+                        depends_on);
+                    created = true;
+                }
+                if (created) {
+                    yield tasks.log_session_entry(parent_task_id, "subtask",
+                        @"saddle subtask: $(title)", @"created task #$(task.id)");
+                }
+                var response = new Json.Object();
+                response.set_int_member("task_id", task.id);
+                return SaddleUpdateEndpoint.json_ok(response);
+            } catch (Error e) {
+                return SaddleUpdateEndpoint.json_error(400, e.message);
+            }
+        }
+    }
+
     /**
      * `POST /api/saddle/note` — the callback endpoint a daemon's
      * `saddle note` CLI posts to (wire contract): authenticated by the

+ 66 - 1
src/Services/TaskService.vala

@@ -644,7 +644,72 @@ namespace ClankerSaddle.Services {
                     @"No task exists with id $(parent_task_id)");
             }
             return yield create_task(((!)parent).project_id, title, description,
-                ((!)parent).priority, false, parent_task_id);
+                ((!)parent).priority, false, parent_task_id,
+                ((!)parent).task_branch, ((!)parent).branch_from);
+        }
+
+        /**
+         * The existing subtask of `parent_task_id` with exactly `title`,
+         * or null — the dedup key behind the synchronous subtask
+         * callback (retried callbacks and the entry-poll fallback must
+         * not create duplicates).
+         */
+        public async TaskEntity? find_subtask_by_title(int64 parent_task_id,
+                string title) throws Error {
+            var rows = yield db.query<TaskEntity>()
+                .where(expr("parent_task_id == $0 && title == $1",
+                    elem<int64?>(parent_task_id), elem(title)))
+                .order_by(expr("id"))
+                .materialise_async();
+            TaskEntity? first = null;
+            foreach (var row in rows) {
+                first = row;
+                break;
+            }
+            return first;
+        }
+
+        /**
+         * Creates a subtask synchronously (the daemon's
+         * `POST /api/saddle/subtask` path): same project, branch and
+         * priority as the parent, plus `task_dependencies` rows for each
+         * `depends_on` id. Validation: every id must exist, and none may
+         * be an ancestor of the new subtask — depending on your own
+         * ancestor would deadlock the parent/child wait (the parent
+         * waits for its subtasks while the subtask waits for the
+         * parent's chain).
+         */
+        public async TaskEntity create_subtask_with_dependencies(int64 parent_task_id,
+                string title, string? description, int64[] depends_on) throws Error {
+            foreach (int64 dependency in depends_on) {
+                var dep = yield db.query<TaskEntity>()
+                    .where(expr("id == $0", elem<int64?>(dependency)))
+                    .first_async();
+                if (dep == null) {
+                    throw new Error(Quark.from_string("clanker-saddle"), 43,
+                        @"depends_on references unknown task $(dependency)");
+                }
+                int64 ancestor = parent_task_id;
+                while (ancestor > 0) {
+                    if (ancestor == dependency) {
+                        throw new Error(Quark.from_string("clanker-saddle"), 44,
+                            @"depends_on $(dependency) is an ancestor of the subtask — that would wait forever (cycle)");
+                    }
+                    var ancestor_row = yield db.query<TaskEntity>()
+                        .where(expr("id == $0", elem<int64?>(ancestor)))
+                        .first_async();
+                    if (ancestor_row == null) {
+                        break;
+                    }
+                    ancestor = ((!)ancestor_row).parent_task_id;
+                }
+            }
+
+            var task = yield create_subtask(parent_task_id, title, description);
+            foreach (int64 dependency in depends_on) {
+                yield add_dependency(task.id, dependency);
+            }
+            return task;
         }
 
         /**

+ 2 - 0
src/main.vala

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

+ 95 - 0
test/web_stack_test.py

@@ -1393,6 +1393,101 @@ def main():
         check("daemon idle while the web believes" in app_log,
               "the contradiction heal is logged")
 
+
+        print("== R4 B1: synchronous subtask callback ==")
+        conn = db()
+        callback_rows = conn.execute(
+            "select token from api_tokens where saddle_id = 0"
+            " and token like 'saddle-callback-%'").fetchall()
+        conn.close()
+        r4_token = callback_rows[0][0] if callback_rows else callback_token
+
+        # Parent with a live parked session (suspended) hosts the callback.
+        fake.suspend_after_start = True
+        act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
+            "project_id": "1",
+            "title": "Callback parent",
+            "priority": "2",
+        })
+        cb_parent = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
+                         if t["title"] == "Callback parent"][0])
+        check(wait_for(lambda: task_state(auth, cb_parent).get("status") == "suspended"
+                       or False, timeout=60, what="callback parent parks") is not None,
+              "callback parent parks")
+
+        status, _ = saddle_api("POST", "/api/saddle/subtask", None,
+                               {"parent_task_id": cb_parent, "title": "no token"})
+        check(status == 401, "subtask callback rejects a missing token")
+
+        anchor = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
+                      if t["title"] == "Heal dispatch"][0])
+        status, body = saddle_api("POST", "/api/saddle/subtask", r4_token, {
+            "parent_task_id": cb_parent,
+            "title": "Wire the flange",
+            "description": "torque to spec",
+            "depends_on": [anchor],
+            "session_id": 1,
+        })
+        check(status == 200 and body.get("ok") is True and int(body.get("task_id") or 0) > 0,
+              "callback creates the subtask synchronously")
+        subtask_id = int(body.get("task_id"))
+        detail = task_state(auth, subtask_id)
+        check(detail["title"] == "Wire the flange" and int(detail["project_id"]) == 1
+              and int(detail["parent_task_id"]) == cb_parent,
+              "subtask row inherits the parent's project and linkage")
+        check(detail.get("task_branch") == task_state(auth, cb_parent).get("task_branch"),
+              "subtask shares the parent's task branch")
+        conn = db()
+        dep_rows = conn.execute(
+            "select depends_on_task_id from task_dependencies where task_id = ?",
+            (subtask_id,)).fetchall()
+        conn.close()
+        check([r[0] for r in dep_rows] == [anchor],
+              "depends_on wired as task_dependencies")
+
+        # Idempotency + entry-poll fallback dedup.
+        status, body2 = saddle_api("POST", "/api/saddle/subtask", r4_token, {
+            "parent_task_id": cb_parent, "title": "Wire the flange"})
+        check(status == 200 and int(body2.get("task_id") or 0) == subtask_id,
+              "retried callback returns the same task id")
+        conn = db()
+        before = conn.execute("select count(*) from tasks where parent_task_id = ?",
+                              (cb_parent,)).fetchone()[0]
+        conn.close()
+        conn = db()
+        cb_session = conn.execute(
+            "select id from sessions where task_id = ? and ended_at is null",
+            (cb_parent,)).fetchone()[0]
+        conn.close()
+        saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
+                   {"type": "subtask", "session_id": cb_session, "data": {
+                       "title": "Wire the flange", "description": "again"}})
+        conn = db()
+        after = conn.execute("select count(*) from tasks where parent_task_id = ?",
+                             (cb_parent,)).fetchone()[0]
+        timeline = conn.execute(
+            "select count(*) from session_commands where session_id = ?"
+            " and type = 'subtask'", (cb_session,)).fetchone()[0]
+        conn.close()
+        check(after == before,
+              "entry-poll fallback dedupes against the callback's row")
+        check(timeline >= 1, "timeline entry recorded on the parent's session")
+
+        # Validation: unknown parent, unknown depends_on, ancestor cycle.
+        status, body = saddle_api("POST", "/api/saddle/subtask", r4_token,
+                                  {"parent_task_id": 99999, "title": "ghost"})
+        check(status == 400, "unknown parent rejected")
+        status, body = saddle_api("POST", "/api/saddle/subtask", r4_token,
+                                  {"parent_task_id": cb_parent, "title": "bad dep",
+                                   "depends_on": [99999]})
+        check(status == 400 and "unknown task" in body.get("error", ""),
+              "unknown depends_on id rejected with a clear error")
+        status, body = saddle_api("POST", "/api/saddle/subtask", r4_token,
+                                  {"parent_task_id": cb_parent, "title": "cycle dep",
+                                   "depends_on": [cb_parent]})
+        check(status == 400 and "cycle" in body.get("error", ""),
+              "depending on an ancestor rejected as a cycle")
+
     finally:
         # Exit-code diagnostic: distinguishes a crashed app (negative
         # returncode, e.g. -11 SIGSEGV) from a clean shutdown. When