Эх сурвалжийг харах

feat: computed task display state (TaskService.TaskDisplay) used by all lists and the detail page — review-requested over stored done, delegating x/y with open subtasks, terminal/failed flags; presentation only

clanker 1 сар өмнө
parent
commit
43915c6c15

+ 112 - 0
src/Services/TaskService.vala

@@ -13,6 +13,7 @@ namespace ClankerSaddle.Services {
     public const string STATUS_WORKING = "working";
     public const string STATUS_BLOCKED = "blocked";
     public const string STATUS_REVIEW_REQUESTED = "review_requested";
+    public const string STATUS_SUSPENDED = "suspended";
     public const string STATUS_DONE = "done";
     public const string STATUS_FAILED = "failed";
 
@@ -21,6 +22,44 @@ namespace ClankerSaddle.Services {
     public const string BLOCK_RFC = "rfc";
     public const string BLOCK_REVIEW = "review";
 
+    /**
+     * A task's computed presentation state (stored status + open
+     * subtasks + unanswered blocks): `review requested` while a review
+     * block is unanswered (even when the stored status is terminal —
+     * the post-mortem's `done`-with-open-review case), `delegating x/y`
+     * while non-terminal subtasks exist, `waiting on subtasks` for the
+     * parked suspended state, otherwise the stored status. Pure
+     * presentation — the stored statuses and the dispatcher are
+     * untouched.
+     */
+    public class TaskDisplay : Object {
+        public string status { get; set; default = ""; }
+        public bool terminal { get; set; }
+        public bool failed { get; set; }
+        public int64 subtask_total { get; set; }
+        public int64 subtask_open { get; set; }
+
+        public static TaskDisplay compute(TaskEntity task, bool review_pending,
+                int64 subtask_total, int64 subtask_open) {
+            var view = new TaskDisplay() {
+                terminal = task.status == STATUS_DONE || task.status == STATUS_FAILED,
+                failed = task.status == STATUS_FAILED,
+                subtask_total = subtask_total,
+                subtask_open = subtask_open
+            };
+            if (review_pending) {
+                view.status = "review requested";
+            } else if (subtask_open > 0) {
+                view.status = @"delegating $(subtask_total - subtask_open)/$(subtask_total)";
+            } else if (task.status == STATUS_SUSPENDED) {
+                view.status = "waiting on subtasks";
+            } else {
+                view.status = task.status;
+            }
+            return view;
+        }
+    }
+
     /**
      * The full task aggregate loaded by {@link TaskService.get_task}: the
      * task itself plus its comments, blocks, progress history, child
@@ -128,6 +167,79 @@ namespace ClankerSaddle.Services {
          * Lists tasks, optionally filtered by status and/or project, ordered
          * by priority (1 first) then creation time.
          */
+        /**
+         * Computed display states for a batch of tasks (all lists share
+         * this), keyed by task id string. Unanswered review blocks and
+         * child-task states each load once, not per task.
+         */
+        public async Dictionary<string, TaskDisplay> display_map_for(
+                Lot<TaskEntity> tasks) throws Error {
+            var wanted = new Invercargill.DataStructures.HashSet<string>();
+            foreach (var task in tasks) {
+                wanted.add(task.id.to_string());
+            }
+
+            var review_pending = new Invercargill.DataStructures.HashSet<string>();
+            foreach (var block in yield db.query<TaskBlock>()
+                    .where(expr("type == $0", elem(BLOCK_REVIEW)))
+                    .materialise_async()) {
+                if (block.answered_at == null && wanted.has(block.task_id.to_string())) {
+                    review_pending.add(block.task_id.to_string());
+                }
+            }
+
+            // Subtask counters per parent (stringly keyed: the Invercargill
+            // collections cannot key on value types).
+            var open_by_parent = new Dictionary<string, string>();
+            var total_by_parent = new Dictionary<string, string>();
+            foreach (var child in yield db.query<TaskEntity>()
+                    .where(expr("parent_task_id > 0"))
+                    .materialise_async()) {
+                string parent = child.parent_task_id.to_string();
+                if (!wanted.has(parent)) {
+                    continue;
+                }
+                total_by_parent.set(parent,
+                    (int64.parse(total_by_parent.get_or_default(parent) ?? "0") + 1).to_string());
+                if (child.status != STATUS_DONE && child.status != STATUS_FAILED) {
+                    open_by_parent.set(parent,
+                        (int64.parse(open_by_parent.get_or_default(parent) ?? "0") + 1).to_string());
+                }
+            }
+
+            var map = new Dictionary<string, TaskDisplay>();
+            foreach (var task in tasks) {
+                string key = task.id.to_string();
+                map.set(key, TaskDisplay.compute(task,
+                    review_pending.has(key),
+                    int64.parse(total_by_parent.get_or_default(key) ?? "0"),
+                    int64.parse(open_by_parent.get_or_default(key) ?? "0")));
+            }
+            return map;
+        }
+
+        /**
+         * The display state of a single task — the detail page's path,
+         * computed from its already-loaded aggregate (no extra queries).
+         */
+        public static TaskDisplay display_of_detail(TaskDetail detail) {
+            bool review_pending = false;
+            int64 subtask_total = 0;
+            int64 subtask_open = 0;
+            foreach (var block in detail.blocks) {
+                if (block.block_type == BLOCK_REVIEW && block.answered_at == null) {
+                    review_pending = true;
+                }
+            }
+            foreach (var child in detail.subtasks) {
+                subtask_total++;
+                if (child.status != STATUS_DONE && child.status != STATUS_FAILED) {
+                    subtask_open++;
+                }
+            }
+            return TaskDisplay.compute(detail.task, review_pending, subtask_total, subtask_open);
+        }
+
         public async ImmutableLot<TaskEntity> list_tasks(string? status, int64? project_id) throws Error {
             var tasks = yield db.query<TaskEntity>().materialise_async();
             var rows = new Vector<TaskEntity>();

+ 6 - 1
src/entrypoints/ProjectsEntrypoint.vala

@@ -94,6 +94,7 @@ namespace ClankerSaddle {
         public int64 id { get; set; }
         public string title { get; set; default = ""; }
         public string status { get; set; default = ""; }
+        public string display { get; set; default = ""; }
         public int64 priority { get; set; }
         public bool is_design_task { get; set; }
     }
@@ -216,12 +217,16 @@ namespace ClankerSaddle {
                 }
             }
 
+            var project_tasks = yield task_service.list_tasks(null, project_id);
+            var task_display = yield task_service.display_map_for(project_tasks);
             var task_rows = new Vector<ProjectTaskRowView>();
-            foreach (var task in yield task_service.list_tasks(null, project_id)) {
+            foreach (var task in project_tasks) {
+                var display = task_display.get_or_default(task.id.to_string());
                 task_rows.add(new ProjectTaskRowView() {
                     id = task.id,
                     title = task.title,
                     status = task.status,
+                    display = display != null ? ((!)display).status : task.status,
                     priority = task.priority,
                     is_design_task = task.is_design_task
                 });

+ 18 - 1
src/entrypoints/TaskDetailEntrypoint.vala

@@ -51,10 +51,12 @@ namespace ClankerSaddle {
     }
 
     /** A subtask row in the task detail tree. */
+    /** A subtask row on the task page, with its computed display state. */
     public class SubtaskView : Object {
         public int64 id { get; set; }
         public string title { get; set; default = ""; }
         public string status { get; set; default = ""; }
+        public string display { get; set; default = ""; }
     }
 
     /** A dependency row (an edge to another task). */
@@ -108,6 +110,11 @@ namespace ClankerSaddle {
         public string review_summary { get; set; default = ""; }
         public string review_diff_url { get; set; default = ""; }
         public bool resume_available { get; set; }
+        public string display { get; set; default = ""; }
+        public bool terminal { get; set; }
+        public bool failed { get; set; }
+        public int64 subtask_total { get; set; }
+        public int64 subtask_open { get; set; }
         public int64 project_id { get; set; }
         public string project_name { get; set; default = ""; }
         public string git_url { get; set; default = ""; }
@@ -178,6 +185,13 @@ namespace ClankerSaddle {
                 completed_display = format_time(task.completed_at)
             };
 
+            var display = TaskService.display_of_detail(((!)detail));
+            pub.display = display.status;
+            pub.terminal = display.terminal;
+            pub.failed = display.failed;
+            pub.subtask_total = display.subtask_total;
+            pub.subtask_open = display.subtask_open;
+
             var sealed = new TaskActionPrivate() { task_id = task.id };
             pub.update = actions.author_private<UpdateTaskAction, TaskActionPrivate>(sealed);
             pub.comment = actions.author_private<AddCommentAction, TaskActionPrivate>(sealed);
@@ -247,11 +261,14 @@ namespace ClankerSaddle {
             }
 
             var subtasks = new Vector<SubtaskView>();
+            var subtask_display = yield tasks.display_map_for(((!)detail).subtasks);
             foreach (var subtask in ((!)detail).subtasks) {
+                var subtask_state = subtask_display.get_or_default(subtask.id.to_string());
                 subtasks.add(new SubtaskView() {
                     id = subtask.id,
                     title = subtask.title,
-                    status = subtask.status
+                    status = subtask.status,
+                    display = subtask_state != null ? ((!)subtask_state).status : subtask.status
                 });
             }
 

+ 16 - 1
src/entrypoints/TasksEntrypoint.vala

@@ -13,6 +13,9 @@ namespace ClankerSaddle {
         public int64 id { get; set; }
         public string title { get; set; default = ""; }
         public string status { get; set; default = ""; }
+        public string display { get; set; default = ""; }
+        public bool terminal { get; set; }
+        public bool failed { get; set; }
         public int64 priority { get; set; }
         public bool is_design_task { get; set; }
         public int64 parent_task_id { get; set; }
@@ -91,6 +94,14 @@ namespace ClankerSaddle {
             var all_dependencies = yield task_service.list_all_dependencies();
             var ready = yield task_service.get_ready_tasks();
 
+            // Computed display state for every listed task (review
+            // requested / delegating / suspended surface here).
+            var listed = new Vector<ClankerSaddle.Data.TaskEntity>();
+            foreach (var task in yield task_service.list_tasks(status, project_id)) {
+                listed.add(task);
+            }
+            var display_map = yield task_service.display_map_for(listed.to_immutable_buffer());
+
             // Capability routing context: required names per task, carried
             // names per idle saddle — a queued task waits for a capable
             // saddle when no idle saddle's set covers its requirements.
@@ -118,7 +129,7 @@ namespace ClankerSaddle {
             }
 
             var rows = new Vector<TaskRowView>();
-            foreach (var task in yield task_service.list_tasks(status, project_id)) {
+            foreach (var task in listed) {
                 bool is_ready = false;
                 foreach (var ready_task in ready) {
                     if (ready_task.id == task.id) {
@@ -126,10 +137,14 @@ namespace ClankerSaddle {
                         break;
                     }
                 }
+                var display = display_map.get_or_default(task.id.to_string());
                 var view = new TaskRowView() {
                     id = task.id,
                     title = task.title,
                     status = task.status,
+                    display = display != null ? ((!)display).status : task.status,
+                    terminal = display != null && ((!)display).terminal,
+                    failed = display != null && ((!)display).failed,
                     priority = task.priority,
                     is_design_task = task.is_design_task,
                     parent_task_id = task.parent_task_id,

+ 1 - 1
src/pages/project_detail.html

@@ -106,7 +106,7 @@
                         <td stm-text="task.id">id</td>
                         <td><a stm-attribute.href="'/tasks/' + task.id" stm-text="task.title">title</a>
                             <span class="muted" stm-if="task.is_design_task"> · design</span></td>
-                        <td><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.status">status</span></td>
+                        <td><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span></td>
                         <td stm-text="task.priority">priority</td>
                     </tr>
                     <tr stm-if="project.tasks.length == 0"><td colspan="4" class="muted">No tasks yet — create one from the <a href="/tasks">queue</a>.</td></tr>

+ 2 - 2
src/pages/task_detail.html

@@ -220,7 +220,7 @@
             <div class="cards">
                 <section class="card">
                     <h3>Status</h3>
-                    <p><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.status">status</span>
+                    <p><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span>
                         · priority <strong stm-text="task.priority">5</strong>
                         <span stm-if="task.is_design_task"> · <strong>plan review required</strong></span></p>
                     <p class="muted">Created <span stm-text="task.created_display">when</span></p>
@@ -425,7 +425,7 @@
                     <tr stm-for-subtask-in="task.subtasks">
                         <td stm-text="subtask.id">id</td>
                         <td><a stm-attribute.href="'/tasks/' + subtask.id" stm-text="subtask.title">title</a></td>
-                        <td><span stm-attribute.class="'badge badge-' + subtask.status" stm-text="subtask.status">status</span></td>
+                        <td><span stm-attribute.class="'badge badge-' + subtask.status" stm-text="subtask.display">status</span></td>
                     </tr>
                     <tr stm-if="task.subtasks.length == 0"><td colspan="3" class="muted">No subtasks.</td></tr>
                 </tbody>

+ 1 - 1
src/pages/tasks.html

@@ -86,7 +86,7 @@
                                 <span class="muted" stm-if="task.waiting_for_saddle"> · waiting for a capable saddle</span>
                             </span></td>
                         <td stm-text="task.project_name">project</td>
-                        <td><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.status">status</span></td>
+                        <td><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span></td>
                         <td stm-text="task.priority">priority</td>
                         <td stm-if="task.has_dependencies" stm-text="task.dependency_summary">deps</td>
                         <td stm-if="!task.has_dependencies" class="muted">—</td>

+ 54 - 0
test/web_stack_test.py

@@ -911,6 +911,60 @@ def main():
         check(queue.get("waiting_for_saddle") is not True,
               "capable saddle available: no wait flag on the new task")
 
+        print("== B6: computed display state ==")
+        conn = db()
+        conn.execute("insert into tasks (project_id, title, status, priority, created_at)"
+                     " values (1, 'Parent', 'done', 5, strftime('%s','now'))")
+        parent6 = conn.execute("select max(id) from tasks").fetchone()[0]
+        # The post-mortem case: stored done WITH an unanswered review block.
+        conn.execute("insert into sessions (task_id, saddle_id, started_at, ended_at, outcome)"
+                     " values (?, ?, strftime('%s','now'), strftime('%s','now'), 'done')",
+                     (parent6, fake_id))
+        session6 = conn.execute("select max(id) from sessions").fetchone()[0]
+        conn.commit()
+        conn.close()
+        saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
+                   {"type": "block", "session_id": session6, "data": {
+                       "question": "Review the final diff", "type": "review"}})
+
+        detail = task_state(auth, parent6)
+        check(detail.get("display") == "review requested" and detail.get("terminal") is True,
+              "unanswered review overrides the stored done status (terminal flag keeps stored truth)")
+        queue = [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
+                 if int(t["id"]) == parent6][0]
+        check(queue["display"] == "review requested",
+              "queue row shows the computed review-requested display")
+
+        # Delegation: with the review answered, open subtasks delegate.
+        conn = db()
+        conn.execute("update task_blocks set answered_at = strftime('%s','now'),"
+                     " answer = 'Human approved the review' where task_id = ?", (parent6,))
+        conn.execute("insert into tasks (project_id, title, status, priority, parent_task_id,"
+                     " created_at) values (1, 'Sub A', 'working', 5, ?, strftime('%s','now'))",
+                     (parent6,))
+        conn.execute("insert into tasks (project_id, title, status, priority, parent_task_id,"
+                     " created_at) values (1, 'Sub B', 'done', 5, ?, strftime('%s','now'))",
+                     (parent6,))
+        conn.commit()
+        conn.close()
+        detail = task_state(auth, parent6)
+        check(detail.get("display") == "delegating 1/2" and detail.get("subtask_open") == 1,
+              "open subtasks show the delegating breakdown (%s)" % detail.get("display"))
+
+        # Flags track the stored status once the display falls through
+        # (subtasks terminal first — delegating leads while any are open).
+        conn = db()
+        conn.execute("update tasks set status = 'failed', completed_at = strftime('%s','now')"
+                     " where id = ?", (parent6,))
+        conn.execute("update tasks set status = 'failed', completed_at = strftime('%s','now')"
+                     " where title = 'Sub A'")
+        conn.commit()
+        conn.close()
+        detail = task_state(auth, parent6)
+        check(detail.get("display") == "failed" and detail.get("failed") is True
+              and detail.get("terminal") is True,
+              "failed task with answered review and terminal subtasks shows failed + terminal flags")
+
         print("== retry + resume ==")
         # A daemon-side failure fails the task with its explanation.
         fake.fail_next = True