Browse Source

feat: required-capabilities multi-select on the task create form (validated before create; detail-page attach/detach unchanged)

clanker 1 month ago
parent
commit
7a2d2f49c9

+ 24 - 0
src/actions/TaskActions.vala

@@ -44,6 +44,7 @@ namespace ClankerSaddle {
     public class CreateTaskAction : StatumAction {
 
         private TaskService tasks = inject<TaskService>();
+        private CapabilityService capabilities = inject<CapabilityService>();
         private UserService users = inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -64,6 +65,26 @@ namespace ClankerSaddle {
                 parent_id = parsed_parent;
             }
 
+            // Required capabilities: the create form's multi-select posts
+            // several capability_id fields; every id must resolve before
+            // the task is created. (A plain array — the Invercargill
+            // collections mishandle boxed value types in foreach.)
+            int64[] required = {};
+            if (request.form != null) {
+                foreach (var value in request.form.get_field_all("capability_id")) {
+                    int64 parsed = int64.parse(value.strip());
+                    if (parsed <= 0) {
+                        continue;
+                    }
+                    var capability = yield capabilities.get_capability(parsed);
+                    if (capability == null) {
+                        return directives().notify("error",
+                                @"No capability exists with id $(parsed)").navigate("/tasks");
+                    }
+                    required += parsed;
+                }
+            }
+
             try {
                 var task = yield tasks.create_task(project_id, title,
                     Forms.field_raw(request, "description"),
@@ -74,6 +95,9 @@ namespace ClankerSaddle {
                 if (Forms.field(request, "plan").length > 0) {
                     yield tasks.update_plan(task.id, Forms.field_raw(request, "plan"));
                 }
+                foreach (int64 capability_id in required) {
+                    yield capabilities.attach_to_task(capability_id, task.id);
+                }
                 return directives().notify("success", @"Task '$(task.title)' queued")
                     .navigate(@"/tasks/$(task.id)");
             } catch (Error e) {

+ 9 - 0
src/entrypoints/TasksEntrypoint.vala

@@ -186,6 +186,14 @@ namespace ClankerSaddle {
                 create = action_registry.author<CreateTaskAction>()
             };
 
+            var capability_options = new Vector<CapabilityOptionView>();
+            foreach (var capability in yield capability_service.list_capabilities()) {
+                capability_options.add(new CapabilityOptionView() {
+                    id = capability.id,
+                    name = capability.name
+                });
+            }
+
             var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
             props["tasks"] = StateJson.list_rich<TaskRowView>(rows.to_immutable_buffer(),
                 (item, dict) => {
@@ -193,6 +201,7 @@ namespace ClankerSaddle {
                         ((TaskRowView) item).capabilities ?? new string[0]);
                 });
             props["projects"] = StateJson.list<TaskProjectOptionView>(options.to_immutable_buffer());
+            props["capability_options"] = StateJson.list<CapabilityOptionView>(capability_options.to_immutable_buffer());
 
             var slot = state_service.new_slot(Scope.PAGE, new State() {
                 type_name = "tasks",

+ 9 - 0
src/pages/tasks.html

@@ -123,6 +123,15 @@
                             <input type="checkbox" name="is_design_task">
                             <span>Require plan approval — the agent explores, asks questions, writes a plan, and waits for your approval before writing code</span>
                         </label></div>
+                    <div class="form-row" stm-if="tasks.capability_options.length > 0"><label>Required capabilities</label>
+                        <p stm-for-option-in="tasks.capability_options" class="inline">
+                            <label class="option-card">
+                                <input type="checkbox" name="capability_id" stm-attribute.value="option.id">
+                                <span stm-text="option.name">tag</span>
+                            </label>
+                        </p>
+                        <p class="muted">Only saddles carrying every ticked tag will run this task.</p>
+                    </div>
                     <button type="submit" class="primary">Create task</button>
                 </form>
             </details>

BIN
test/__pycache__/web_stack_test.cpython-314.pyc


+ 44 - 1
test/web_stack_test.py

@@ -70,7 +70,11 @@ WEB = "http://127.0.0.1:%d" % APP_PORT
 def http(method, url, data=None, headers=None, timeout=30):
     body = None
     if data is not None:
-        body = "&".join("%s=%s" % (k, urllib.parse.quote(str(v))) for k, v in data.items()).encode()
+        pairs = []
+        for k, v in data.items():
+            for item in (v if isinstance(v, (list, tuple)) else [v]):
+                pairs.append((k, item))
+        body = "&".join("%s=%s" % (k, urllib.parse.quote(str(item))) for k, item in pairs).encode()
     request = urllib.request.Request(WEB + url if url.startswith("/") else url,
                                      data=body, method=method)
     if data is not None:
@@ -868,6 +872,45 @@ def main():
                   for c in saddle_card("fake-saddle")["capabilities"]),
               "deleting a capability drops it from saddles")
 
+        print("== B4: required capabilities on the create form ==")
+        tasks_state = slot_public(auth, "/tasks", "tasks")
+        gpu_option = [o for o in tasks_state.get("capability_options", [])
+                      if o["name"] == "gpu"][0]
+        check(len(tasks_state.get("capability_options", [])) >= 1,
+              "queue page offers capability options in the create form")
+
+        conn = db()
+        before = conn.execute("select count(*) from tasks").fetchone()[0]
+        conn.close()
+        act(auth, tasks_state["create"], {
+            "project_id": "1",
+            "title": "Bad cap task",
+            "capability_id": [gpu_option["id"], "999"],
+        })
+        conn = db()
+        after = conn.execute("select count(*) from tasks").fetchone()[0]
+        conn.close()
+        check(after == before, "unknown capability id rejects the create")
+
+        act(auth, tasks_state["create"], {
+            "project_id": "1",
+            "title": "Cap-on-create task",
+            "capability_id": [gpu_option["id"]],
+        })
+        cap_create = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
+                          if t["title"] == "Cap-on-create task"][0])
+        detail = task_state(auth, cap_create)
+        check([c["name"] for c in detail["capabilities"]] == ["gpu"],
+              "capabilities from the create form land on the task")
+        queue = [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
+                 if int(t["id"]) == cap_create][0]
+        check(queue["capabilities"] == ["gpu"],
+              "queue row shows the created task's requirements")
+        # The waiting flag must hold it back until the capable saddle frees
+        # up (the fake saddle still carries gpu from the routing checks).
+        check(queue.get("waiting_for_saddle") is not True,
+              "capable saddle available: no wait flag on the new task")
+
         print("== retry + resume ==")
         # A daemon-side failure fails the task with its explanation.
         fake.fail_next = True