Kaynağa Gözat

r1: one JSON-entry reading surface — src/Json.vala grows EntryJson (string_member/optional_string_member/get_string_or/string_array_member/parse_iso/parse_string_array); SaddleService's four private statics, SaddleApi's get_string_or/parse_iso/string_options and TaskService.parse_block_options all collapse into it, three string-array parsers down to one, behaviour byte-identical (the malformed-options warning keeps each call site's wording).

clanker 1 ay önce
ebeveyn
işleme
34f58415c3

+ 28 - 85
src/Api/SaddleApi.vala

@@ -86,17 +86,17 @@ namespace ClankerSaddle.Api {
                     break;
 
                 case "comment":
-                    string body_text = get_string_or(data, "body", get_string_or(data, "text", ""));
+                    string body_text = EntryJson.get_string_or(data, "body", EntryJson.get_string_or(data, "text", ""));
                     yield tasks.add_comment(task_id, "agent", body_text);
                     yield tasks.log_session_entry(task_id, "comment", "saddle comment", body_text);
                     break;
 
                 case "block":
                     var block = yield tasks.add_block(task_id,
-                        get_string_or(data, "question", ""),
-                        get_string_or(data, "type", "question"),
-                        string_options(data),
-                        get_string_or(data, "body", null));
+                        EntryJson.get_string_or(data, "question", ""),
+                        EntryJson.get_string_or(data, "type", "question"),
+                        EntryJson.string_array_member(data, "options", "block options"),
+                        EntryJson.get_string_or(data, "body", null));
                     yield tasks.log_session_entry(task_id, "block",
                         @"block: $(block.question)", block.body);
                     var block_response = new Json.Object();
@@ -114,33 +114,33 @@ namespace ClankerSaddle.Api {
                         @"The '$(type)' update type is gone (plan 12) — use the task callbacks / the goal's plan flow");
 
                 case "progress":
-                    string summary = get_string_or(data, "summary",
-                        get_string_or(data, "text", ""));
+                    string summary = EntryJson.get_string_or(data, "summary",
+                        EntryJson.get_string_or(data, "text", ""));
                     yield tasks.add_progress(task_id, summary);
                     yield tasks.log_session_entry(task_id, "progress", "saddle progress-update", summary);
                     break;
 
                 case "review":
-                    string review_summary = get_string_or(data, "summary", "");
+                    string review_summary = EntryJson.get_string_or(data, "summary", "");
                     var review_block = yield tasks.request_review(task_id, review_summary);
                     yield tasks.log_session_entry(task_id, "review", "saddle review", review_summary);
                     break;
 
                 case "done":
                     yield tasks.complete_task(task_id,
-                        get_string_or(data, "summary", null),
-                        get_string_or(data, "working_branch", null), "done");
+                        EntryJson.get_string_or(data, "summary", null),
+                        EntryJson.get_string_or(data, "working_branch", null), "done");
                     break;
 
                 case "failed":
-                    string failure = get_string_or(data, "summary",
-                        get_string_or(data, "text", ""));
+                    string failure = EntryJson.get_string_or(data, "summary",
+                        EntryJson.get_string_or(data, "text", ""));
                     yield tasks.fail_task(task_id, failure.length > 0 ? failure : null);
                     yield tasks.log_session_entry(task_id, "failed", "saddle failure", failure);
                     break;
 
                 case "status":
-                    string state = get_string_or(data, "state", "");
+                    string state = EntryJson.get_string_or(data, "state", "");
                     if (state == "working" || state == "blocked") {
                         yield tasks.set_status(task_id, state);
                     }
@@ -157,17 +157,17 @@ namespace ClankerSaddle.Api {
         private async void store_output(int64 session_id, Json.Object entry) throws Error {
             var command = new SessionCommand() {
                 session_id = session_id,
-                command_text = get_string_or(entry, "command", ""),
-                output_text = get_string_or(entry, "output", null),
+                command_text = EntryJson.get_string_or(entry, "command", ""),
+                output_text = EntryJson.get_string_or(entry, "output", null),
                 exit_code = entry.has_member("exit_code")
                     && entry.get_member("exit_code").get_value_type() == typeof(int64)
                     ? entry.get_int_member("exit_code") : 0,
-                entry_type = get_string_or(entry, "type", "command"),
-                piped_input = get_string_or(entry, "input", null) != null
-                    && get_string_or(entry, "input", "").length > 0
-                    ? get_string_or(entry, "input", null) : null,
-                started_at = parse_iso(entry, "started_at"),
-                completed_at = parse_iso(entry, "completed_at"),
+                entry_type = EntryJson.get_string_or(entry, "type", "command"),
+                piped_input = EntryJson.get_string_or(entry, "input", null) != null
+                    && EntryJson.get_string_or(entry, "input", "").length > 0
+                    ? EntryJson.get_string_or(entry, "input", null) : null,
+                started_at = EntryJson.parse_iso(entry, "started_at"),
+                completed_at = EntryJson.parse_iso(entry, "completed_at"),
                 duration_ms = entry.has_member("duration_ms")
                     && entry.get_member("duration_ms").get_value_type() == typeof(int64)
                     ? entry.get_int_member("duration_ms") : 0
@@ -175,46 +175,6 @@ namespace ClankerSaddle.Api {
             yield db.insert_async<SessionCommand>(command);
         }
 
-        /** The `options` member as a string array (native or JSON-encoded). */
-        private static string[] string_options(Json.Object data) {
-            if (!data.has_member("options")) {
-                return new string[0];
-            }
-            var member = data.get_member("options");
-            if (member.get_node_type() == Json.NodeType.ARRAY) {
-                var values = new Vector<string>();
-                foreach (var element in member.get_array().get_elements()) {
-                    if (element.get_node_type() == Json.NodeType.VALUE
-                            && element.get_string() != null) {
-                        values.add(element.get_string());
-                    }
-                }
-                return values.to_array();
-            }
-            string raw = get_string_or(data, "options", "");
-            if (raw.length == 0) {
-                return new string[0];
-            }
-            try {
-                var parser = new Json.Parser();
-                parser.load_from_data(raw);
-                var root = parser.get_root();
-                if (root != null && root.get_node_type() == Json.NodeType.ARRAY) {
-                    var values = new Vector<string>();
-                    foreach (var element in root.get_array().get_elements()) {
-                        if (element.get_node_type() == Json.NodeType.VALUE
-                                && element.get_string() != null) {
-                            values.add(element.get_string());
-                        }
-                    }
-                    return values.to_array();
-                }
-            } catch (Error e) {
-                warning("[ClankerSaddle] Ignoring malformed block options: %s", e.message);
-            }
-            return new string[0];
-        }
-
         private async int64 task_id_for_session(int64 session_id) throws Error {
             var session = yield db.query<ClankerSaddle.Data.Session>()
                 .where(Invercargill.Expressions.expr("id == $0",
@@ -278,23 +238,6 @@ namespace ClankerSaddle.Api {
             return result;
         }
 
-        internal static string get_string_or(Json.Object obj, string name, string? fallback) {
-            if (obj.has_member(name) && obj.get_member(name).get_string() != null) {
-                return obj.get_string_member(name);
-            }
-            return fallback;
-        }
-
-        internal static DateTime? parse_iso(Json.Object obj, string name) {
-            if (!obj.has_member(name)) {
-                return null;
-            }
-            var value = obj.get_member(name);
-            if (value.get_node_type() != Json.NodeType.VALUE || value.get_string() == null) {
-                return null;
-            }
-            return new DateTime.from_iso8601(value.get_string(), new TimeZone.utc());
-        }
     }
 
     /**
@@ -560,8 +503,8 @@ namespace ClankerSaddle.Api {
                 return SaddleUpdateEndpoint.json_error(400, e.message);
             }
 
-            string project_name = SaddleUpdateEndpoint.get_string_or(body, "project", "");
-            string note_body = SaddleUpdateEndpoint.get_string_or(body, "body", "");
+            string project_name = EntryJson.get_string_or(body, "project", "");
+            string note_body = EntryJson.get_string_or(body, "body", "");
             int64 task_id = body.has_member("task_id")
                 && body.get_member("task_id").get_value_type() == typeof(int64)
                 ? body.get_int_member("task_id") : 0;
@@ -671,8 +614,8 @@ namespace ClankerSaddle.Api {
                     && body.get_member("session_id").get_value_type() == typeof(int64)
                     ? body.get_int_member("session_id") : 0;
                 int64 goal_id = yield goal_scope_of(saddle, session_id);
-                string title = SaddleUpdateEndpoint.get_string_or(body, "title", "");
-                string mandate = SaddleUpdateEndpoint.get_string_or(body, "mandate", "");
+                string title = EntryJson.get_string_or(body, "title", "");
+                string mandate = EntryJson.get_string_or(body, "mandate", "");
                 if (title.length == 0) {
                     return SaddleUpdateEndpoint.json_error(400, "A title is required");
                 }
@@ -680,7 +623,7 @@ namespace ClankerSaddle.Api {
                     return SaddleUpdateEndpoint.json_error(400,
                         "A mandate is required (heredoc body of `task create`)");
                 }
-                string mode = SaddleUpdateEndpoint.get_string_or(body, "mode", "implement");
+                string mode = EntryJson.get_string_or(body, "mode", "implement");
                 bool mode_valid = false;
                 foreach (string candidate in TASK_MODES) {
                     if (mode == candidate) {
@@ -1107,14 +1050,14 @@ namespace ClankerSaddle.Api {
                     @"No session $(session_id) exists on saddle $(((!)saddle).id)");
             }
 
-            string state = SaddleUpdateEndpoint.get_string_or(body, "state", "");
+            string state = EntryJson.get_string_or(body, "state", "");
             if (state.length == 0) {
                 return SaddleUpdateEndpoint.json_error(400, "A state is required");
             }
             Json.Object? block = body.has_member("block")
                 && body.get_member("block").get_node_type() == Json.NodeType.OBJECT
                 ? body.get_object_member("block") : null;
-            string? summary = SaddleUpdateEndpoint.get_string_or(body, "summary", null);
+            string? summary = EntryJson.get_string_or(body, "summary", null);
 
             poll_state.note_push(session_id);
             try {

+ 114 - 0
src/Json.vala

@@ -5,6 +5,120 @@ using Statum;
 
 namespace ClankerSaddle {
 
+    /**
+     * Helpers for reading the JSON objects that travel the wire —
+     * pushed results/chunks entries, block payloads and request
+     * bodies. One implementation of each member shape (string,
+     * nullable string, string array — native or JSON-encoded — and
+     * ISO-8601 stamps), shared by SaddleService, SaddleApi and
+     * TaskService instead of three private copies with drifting
+     * edge cases.
+     */
+    public class EntryJson : Object {
+
+        /** A string member: "" when absent or not a string. */
+        public static string string_member(Json.Object obj, string name) {
+            return obj.has_member(name) && obj.get_member(name).get_string() != null
+                ? obj.get_string_member(name) : "";
+        }
+
+        /** A nullable string member: null when absent or empty. */
+        public static string? optional_string_member(Json.Object obj, string name) {
+            string value = string_member(obj, name);
+            return value.length > 0 ? value : null;
+        }
+
+        /** A string member, or `fallback` when absent or not a string. */
+        public static string get_string_or(Json.Object obj, string name, string? fallback) {
+            if (obj.has_member(name) && obj.get_member(name).get_string() != null) {
+                return obj.get_string_member(name);
+            }
+            return fallback;
+        }
+
+        /**
+         * An ISO-8601 string member as a DateTime (null when absent or
+         * not a string; a bare local time reads as UTC, the daemon's
+         * clock domain).
+         */
+        public static DateTime? parse_iso(Json.Object obj, string name) {
+            if (!obj.has_member(name)) {
+                return null;
+            }
+            var value = obj.get_member(name);
+            if (value.get_node_type() != Json.NodeType.VALUE || value.get_string() == null) {
+                return null;
+            }
+            return new DateTime.from_iso8601(value.get_string(), new TimeZone.utc());
+        }
+
+        /**
+         * A string-array member: native JSON arrays are read
+         * element-wise; a JSON-encoded string is parsed; anything else
+         * is empty. `what` names the member in the malformed-payload
+         * warning.
+         */
+        public static string[] string_array_member(Json.Object obj, string name,
+                string what = "options array") {
+            if (!obj.has_member(name)) {
+                return new string[0];
+            }
+            var member = obj.get_member(name);
+            if (member.get_node_type() == Json.NodeType.ARRAY) {
+                return elements_of(member.get_array());
+            }
+            string raw = string_member(obj, name);
+            if (raw.length == 0) {
+                return new string[0];
+            }
+            try {
+                var parser = new Json.Parser();
+                parser.load_from_data(raw);
+                var root = parser.get_root();
+                if (root != null && root.get_node_type() == Json.NodeType.ARRAY) {
+                    return elements_of(root.get_array());
+                }
+            } catch (Error e) {
+                warning("[ClankerSaddle] Ignoring malformed %s: %s", what, e.message);
+            }
+            return new string[0];
+        }
+
+        /**
+         * A stored JSON array-of-strings text (a block's options
+         * column) back into a Vala array; null, empty, non-array or
+         * malformed input is empty.
+         */
+        public static string[] parse_string_array(string? raw) {
+            if (raw == null || ((!)raw).length == 0) {
+                return new string[0];
+            }
+            try {
+                var parser = new Json.Parser();
+                parser.load_from_data((!)raw);
+                var root = parser.get_root();
+                if (root == null || root.get_node_type() != Json.NodeType.ARRAY) {
+                    return new string[0];
+                }
+                return elements_of(root.get_array());
+            } catch (Error e) {
+                return new string[0];
+            }
+        }
+
+        /** A JSON array's string elements (non-strings are skipped). */
+        private static string[] elements_of(Json.Array array) {
+            var values = new Vector<string>();
+            foreach (var element in array.get_elements()) {
+                if (element.get_node_type() == Json.NodeType.VALUE
+                        && element.get_string() != null) {
+                    values.add(element.get_string());
+                }
+            }
+            return values.to_array();
+        }
+    }
+
     /**
      * Helpers for shaping Statum slot state. Because Statum's GObjectMapping
      * cannot serialise array/list fields, lists are built as a JsonArray and set

+ 18 - 82
src/Services/SaddleService.vala

@@ -153,7 +153,7 @@ namespace ClankerSaddle.Services {
             try {
                 var response = yield request_saddle_json(saddle.host, "POST", "/register",
                     (owned) payload);
-                string reported_key = string_member(response, "ssh_public_key");
+                string reported_key = EntryJson.string_member(response, "ssh_public_key");
                 if (reported_key.length > 0 && reported_key != (saddle.ssh_public_key ?? "")) {
                     saddle.ssh_public_key = reported_key;
                     yield db.update_async<Saddle>(saddle);
@@ -1243,9 +1243,9 @@ namespace ClankerSaddle.Services {
                 return false;
             }
             foreach (var entry in entries) {
-                string command = string_member(entry, "command");
-                string output = string_member(entry, "output");
-                switch (string_member(entry, "type")) {
+                string command = EntryJson.string_member(entry, "command");
+                string output = EntryJson.string_member(entry, "output");
+                switch (EntryJson.string_member(entry, "type")) {
                     case "comment":
                         // Plan 13 B4: body-first — the entry's output IS
                         // the comment body; the command echo (the
@@ -1396,14 +1396,14 @@ namespace ClankerSaddle.Services {
             }
             yield tasks.add_block(task_id, question,
                 block_string_or(block, "type", "block_type"),
-                string_array_member(block, block.has_member("options") ? "options" : "block_options"),
-                optional_string_member(block, block.has_member("body") ? "body" : "block_body"));
+                EntryJson.string_array_member(block, block.has_member("options") ? "options" : "block_options"),
+                EntryJson.optional_string_member(block, block.has_member("body") ? "body" : "block_body"));
         }
 
         /** The first non-empty of two string members (empty when both are). */
         private static string block_string_or(Json.Object obj, string primary, string fallback) {
-            string value = string_member(obj, primary);
-            return value.length > 0 ? value : string_member(obj, fallback);
+            string value = EntryJson.string_member(obj, primary);
+            return value.length > 0 ? value : EntryJson.string_member(obj, fallback);
         }
 
         /**
@@ -1449,7 +1449,7 @@ namespace ClankerSaddle.Services {
                 if (seq <= 0) {
                     continue;
                 }
-                string text = string_member(chunk, "text");
+                string text = EntryJson.string_member(chunk, "text");
                 if (text.length == 0) {
                     continue;
                 }
@@ -1508,18 +1508,18 @@ namespace ClankerSaddle.Services {
                 }
                 var command = new SessionCommand() {
                     session_id = session_id,
-                    command_text = string_member(entry, "command"),
+                    command_text = EntryJson.string_member(entry, "command"),
                     output_text = entry.has_member("output")
                         && entry.get_member("output").get_string() != null
                         ? entry.get_string_member("output") : null,
                     exit_code = entry.has_member("exit_code")
                         && entry.get_member("exit_code").get_value_type() == typeof(int64)
                         ? entry.get_int_member("exit_code") : 0,
-                    entry_type = string_member(entry, "type").length > 0
-                        ? string_member(entry, "type") : "command",
-                    piped_input = optional_string_member(entry, "input"),
-                    started_at = parse_iso(entry, "started_at") ?? parse_iso(entry, "timestamp"),
-                    completed_at = parse_iso(entry, "completed_at"),
+                    entry_type = EntryJson.string_member(entry, "type").length > 0
+                        ? EntryJson.string_member(entry, "type") : "command",
+                    piped_input = EntryJson.optional_string_member(entry, "input"),
+                    started_at = EntryJson.parse_iso(entry, "started_at") ?? EntryJson.parse_iso(entry, "timestamp"),
+                    completed_at = EntryJson.parse_iso(entry, "completed_at"),
                     duration_ms = entry.has_member("duration_ms")
                         && entry.get_member("duration_ms").get_value_type() == typeof(int64)
                         ? entry.get_int_member("duration_ms") : 0
@@ -1639,13 +1639,13 @@ namespace ClankerSaddle.Services {
                         }
                         if (block_string_or((!)block, "type", "block_type") == BLOCK_PLAN) {
                             yield goal_service.submit_plan(goal_id,
-                                optional_string_member((!)block, "body") ?? question);
+                                EntryJson.optional_string_member((!)block, "body") ?? question);
                         } else {
                             yield goal_service.raise_agent_block(goal_id, question,
                                 block_string_or((!)block, "type", "block_type"),
-                                string_array_member((!)block,
+                                EntryJson.string_array_member((!)block,
                                     (!)block.has_member("options") ? "options" : "block_options"),
-                                optional_string_member((!)block, "body"));
+                                EntryJson.optional_string_member((!)block, "body"));
                         }
                         yield tasks.set_status(task_id, STATUS_BLOCKED);
                         yield park_if_current(saddle.id, task_id);
@@ -1913,69 +1913,5 @@ namespace ClankerSaddle.Services {
             return text;
         }
 
-        private static string string_member(Json.Object obj, string name) {
-            return obj.has_member(name) && obj.get_member(name).get_string() != null
-                ? obj.get_string_member(name) : "";
-        }
-
-        /** A nullable string member: null when absent or empty. */
-        private static string? optional_string_member(Json.Object obj, string name) {
-            string value = string_member(obj, name);
-            return value.length > 0 ? value : null;
-        }
-
-        /**
-         * A string-array member: native JSON arrays are read element-wise;
-         * a JSON-encoded string is parsed; anything else is empty.
-         */
-        private static string[] string_array_member(Json.Object obj, string name) {
-            if (!obj.has_member(name)) {
-                return new string[0];
-            }
-            var member = obj.get_member(name);
-            if (member.get_node_type() == Json.NodeType.ARRAY) {
-                var values = new Vector<string>();
-                foreach (var element in member.get_array().get_elements()) {
-                    if (element.get_node_type() == Json.NodeType.VALUE
-                            && element.get_string() != null) {
-                        values.add(element.get_string());
-                    }
-                }
-                return values.to_array();
-            }
-            string raw = string_member(obj, name);
-            if (raw.length == 0) {
-                return new string[0];
-            }
-            try {
-                var parser = new Json.Parser();
-                parser.load_from_data(raw);
-                var root = parser.get_root();
-                if (root != null && root.get_node_type() == Json.NodeType.ARRAY) {
-                    var values = new Vector<string>();
-                    foreach (var element in root.get_array().get_elements()) {
-                        if (element.get_node_type() == Json.NodeType.VALUE
-                                && element.get_string() != null) {
-                            values.add(element.get_string());
-                        }
-                    }
-                    return values.to_array();
-                }
-            } catch (Error e) {
-                warning("[ClankerSaddle] Ignoring malformed options array: %s", e.message);
-            }
-            return new string[0];
-        }
-
-        private static DateTime? parse_iso(Json.Object obj, string name) {
-            if (!obj.has_member(name)) {
-                return null;
-            }
-            var value = obj.get_member(name);
-            if (value.get_node_type() != Json.NodeType.VALUE || value.get_string() == null) {
-                return null;
-            }
-            return new DateTime.from_iso8601(value.get_string(), new TimeZone.utc());
-        }
     }
 }

+ 1 - 26
src/Services/TaskService.vala

@@ -1105,7 +1105,7 @@ namespace ClankerSaddle.Services {
                 return answer;
             }
             if (block.block_type == BLOCK_QUESTION) {
-                foreach (var option in parse_block_options(block.options)) {
+                foreach (var option in EntryJson.parse_string_array(block.options)) {
                     if (option == answer) {
                         return "Human selected: " + answer;
                     }
@@ -1115,31 +1115,6 @@ namespace ClankerSaddle.Services {
             return "Human answer: " + answer;
         }
 
-        /** The stored options JSON (a string array) back into a Vala array. */
-        public static string[] parse_block_options(string? options_json) {
-            if (options_json == null || ((!)options_json).length == 0) {
-                return new string[0];
-            }
-            try {
-                var parser = new Json.Parser();
-                parser.load_from_data((!)options_json);
-                var root = parser.get_root();
-                if (root == null || root.get_node_type() != Json.NodeType.ARRAY) {
-                    return new string[0];
-                }
-                var values = new Vector<string>();
-                foreach (var element in root.get_array().get_elements()) {
-                    if (element.get_node_type() == Json.NodeType.VALUE
-                            && element.get_string() != null) {
-                        values.add(element.get_string());
-                    }
-                }
-                return values.to_array();
-            } catch (Error e) {
-                return new string[0];
-            }
-        }
-
         /**
          * Marks a task done: records `summary` and `working_branch`, stamps
          * `completed_at`, closes any open session (outcome `outcome`) and

+ 1 - 1
src/entrypoints/GoalDetailEntrypoint.vala

@@ -439,7 +439,7 @@ namespace ClankerSaddle {
                     id = block.id,
                     question = block.question,
                     block_type = block.block_type,
-                    options = TaskService.parse_block_options(block.options),
+                    options = EntryJson.parse_string_array(block.options),
                     body = block.body ?? "",
                     answered = block.answer != null,
                     answer = block.answer ?? "",

+ 1 - 1
src/entrypoints/TaskDetailEntrypoint.vala

@@ -331,7 +331,7 @@ namespace ClankerSaddle {
                     answer = block.answer ?? "",
                     block_type = block.block_type,
                     body = block.body ?? "",
-                    options = TaskService.parse_block_options(block.options),
+                    options = EntryJson.parse_string_array(block.options),
                     answered = block.answer != null,
                     answered_by = block.answered_by ?? "",
                     is_plan_review = block.block_type == BLOCK_PLAN,