using Invercargill; using Invercargill.DataStructures; using Astralis; using Inversion; using Soup; using ClankerSaddle.Data; using ClankerSaddle.Services; namespace ClankerSaddle.Api { /** * `POST /api/saddle/{id}/update` — the ingest channel for a saddle * daemon, authorised by its `Authorization: Bearer ` header * (see {@link SaddleService.validate_token}). The body carries a type * discriminator, the session id and a type-specific `data` object: * * ```json * {"type": "output", "session_id": 12, "data": { * "command": "ls -la", "output": "…", "exit_code": 0, "type": "command", * "started_at": "…", "completed_at": "…", "duration_ms": 41}} * ``` * * Handled types: `output` (a single entry or an `entries` array; both * `data` shapes store `session_commands` rows), `comment`, `block` * (question/rfc/review with optional `options` and markdown `body`; * returns the new `block_id`), `subtask`, `plan`, `progress`, * `review`, `done` (completes the task), `failed` (fails the task * with the explanation) and `status` (working|blocked). * Conversation-level types also append a typed entry to the session's * terminal log. */ public class SaddleUpdateEndpoint : Object, Endpoint { protected TaskService tasks = inject(); protected SaddleService saddles = inject(); protected InvercargillSql.Orm.OrmSession db = inject(); public virtual async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error { string id_text = ""; route_context.mapped_parameters.try_get("id", out id_text); int64 saddle_id = int64.parse(id_text); if (!(yield saddles.validate_token(saddle_id, bearer_token(http_context)))) { return json_error(401, "Missing or invalid saddle token"); } Json.Object body; try { body = yield read_json_body(http_context); } catch (Error e) { return json_error(400, e.message); } string type = body.has_member("type") ? body.get_string_member_with_default("type", "") : ""; int64 session_id = body.has_member("session_id") ? body.get_int_member("session_id") : 0; var data = body.has_member("data") && body.get_member("data").get_node_type() == Json.NodeType.OBJECT ? body.get_object_member("data") : new Json.Object(); try { var response = yield handle_update(saddle_id, session_id, type, data); return json_ok(response); } catch (Error e) { return json_error(400, e.message); } } private async Json.Object handle_update(int64 saddle_id, int64 session_id, string type, Json.Object data) throws Error { var task_id = yield task_id_for_session(session_id); switch (type) { case "output": if (data.has_member("entries") && data.get_member("entries").get_node_type() == Json.NodeType.ARRAY) { foreach (var element in data.get_array_member("entries").get_elements()) { if (element.get_node_type() == Json.NodeType.OBJECT) { yield store_output(session_id, element.get_object()); } } } else { yield store_output(session_id, data); } break; case "comment": string body_text = get_string_or(data, "body", get_string_or(data, "text", "")); yield tasks.add_comment(task_id, "bot", 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)); yield tasks.log_session_entry(task_id, "block", @"block: $(block.question)", block.body); var block_response = new Json.Object(); block_response.set_int_member("block_id", block.id); return block_response; case "subtask": // 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": yield tasks.update_plan(task_id, get_string_or(data, "plan", get_string_or(data, "markdown", ""))); yield tasks.log_session_entry(task_id, "plan", "saddle plan", get_string_or(data, "plan", get_string_or(data, "markdown", ""))); break; case "progress": string summary = get_string_or(data, "summary", 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", ""); var review_block = yield tasks.request_review(task_id, review_summary); yield tasks.log_session_entry(task_id, "review", "saddle review", review_summary); yield tasks.auto_answer_if_covered(review_block); break; case "done": yield tasks.complete_task(task_id, get_string_or(data, "summary", null), get_string_or(data, "working_branch", null), "done"); break; case "failed": string failure = get_string_or(data, "summary", 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", ""); if (state == "working" || state == "blocked") { yield tasks.update_task(task_id, null, null, null, state); } break; default: throw new Error(Quark.from_string("clanker-saddle"), 20, @"Unknown update type '$(type)'"); } return new Json.Object(); } 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), 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"), duration_ms = entry.has_member("duration_ms") && entry.get_member("duration_ms").get_value_type() == typeof(int64) ? entry.get_int_member("duration_ms") : 0 }; yield db.insert_async(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(); 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(); 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() .where(Invercargill.Expressions.expr("id == $0", Invercargill.Expressions.elem(session_id))) .first_async(); if (session == null) { throw new Error(Quark.from_string("clanker-saddle"), 21, @"No session exists with id $(session_id)"); } return ((!)session).task_id; } /** * Reads and parses the request body as a JSON object; an empty body * parses as an empty object. */ internal static async Json.Object read_json_body(HttpContext http_context) throws Error { var bytes = yield http_context.request.request_body.read_all(); // to_raw_string on an empty body is not safe; guard the count. string text = ""; if (bytes != null && (bytes.peek_count() ?? 0) > 0) { text = bytes.to_raw_string(); } if (text.strip().length == 0) { text = "{}"; } var parser = new Json.Parser(); try { parser.load_from_data(text); } catch (Error e) { throw new Error(Quark.from_string("clanker-saddle"), 22, @"Request body is not valid JSON: $(e.message)"); } var root = parser.get_root(); if (root == null || root.get_node_type() != Json.NodeType.OBJECT) { throw new Error(Quark.from_string("clanker-saddle"), 23, "Request body must be a JSON object"); } return root.get_object(); } internal static HttpResult json_ok(Json.Object body) { body.set_boolean_member("ok", true); var generator = new Json.Generator(); generator.set_root(new Json.Node(Json.NodeType.OBJECT).init_object(body)); var result = new HttpDataResult(new ByteBuffer.from_byte_array(generator.to_data(null).data)); result.set_header("Content-Type", "application/json"); return result; } internal static HttpResult json_error(uint16 status_code, string message) { var body = new Json.Object(); body.set_boolean_member("ok", false); body.set_string_member("error", message); var generator = new Json.Generator(); generator.set_root(new Json.Node(Json.NodeType.OBJECT).init_object(body)); var result = new HttpDataResult(new ByteBuffer.from_byte_array(generator.to_data(null).data), (StatusCode) status_code); result.set_header("Content-Type", "application/json"); 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()); } } /** * `GET /api/saddle/{id}/poll` — the outbound channel for a saddle * daemon, authorised by its `Authorization: Bearer ` header: * answered blocks and undelivered human comments for the saddle's * current task, each handed over exactly once. * * ```json * {"answers": [{"block_id": 4, "answer": "Use the flange tool"}], * "comments": [{"comment_id": 9, "author": "user:admin", * "body": "…", "quote": "…"}]} * ``` */ public class SaddlePollEndpoint : Object, Endpoint { private SaddleService saddles = inject(); public virtual async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error { string id_text = ""; route_context.mapped_parameters.try_get("id", out id_text); int64 saddle_id = int64.parse(id_text); if (!(yield saddles.validate_token(saddle_id, bearer_token(http_context)))) { return SaddleUpdateEndpoint.json_error(401, "Missing or invalid saddle token"); } var answers = new Json.Array(); var comments = new Json.Array(); try { foreach (var pending in yield saddles.take_pending_answers(saddle_id)) { var element = new Json.Object(); element.set_int_member("block_id", pending.block_id); element.set_string_member("answer", pending.answer); answers.add_object_element(element); } foreach (var pending in yield saddles.take_pending_comments(saddle_id)) { var element = new Json.Object(); element.set_int_member("comment_id", pending.comment_id); element.set_string_member("author", pending.author); element.set_string_member("body", pending.body); element.set_string_member("quote", pending.quote); comments.add_object_element(element); } } catch (Error e) { return SaddleUpdateEndpoint.json_error(400, e.message); } var body = new Json.Object(); body.set_array_member("answers", answers); body.set_array_member("comments", comments); return SaddleUpdateEndpoint.json_ok(body); } } /** * `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(); private TaskService tasks = inject(); 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(); private TaskService tasks = inject(); 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): * 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(); private ProjectService projects = inject(); private TaskService tasks = inject(); 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 * dedicated saddle-callback bearer token (see * {@link SaddleService.ensure_callback_token}), inserting * * ```json * {"task_id": 1, "project": "ZL4GQ", "body": "…"} * ``` * * `task_id` is optional (stored as the note's source task); the * project is resolved by name and must exist. Response * `{"ok": true}`. */ public class SaddleNoteEndpoint : Object, Endpoint { private SaddleService saddles = inject(); private ProjectService projects = inject(); 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); } string project_name = SaddleUpdateEndpoint.get_string_or(body, "project", ""); string note_body = SaddleUpdateEndpoint.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; if (note_body.strip().length == 0) { return SaddleUpdateEndpoint.json_error(400, "A note body is required"); } try { var project = yield projects.get_project_by_name(project_name); if (project == null) { return SaddleUpdateEndpoint.json_error(400, @"No project exists named '$(project_name)'"); } yield projects.add_note(((!)project).id, task_id, note_body); return SaddleUpdateEndpoint.json_ok(new Json.Object()); } catch (Error e) { return SaddleUpdateEndpoint.json_error(400, e.message); } } } /** * The request's bearer token: the `Authorization` header minus its * `Bearer ` prefix, or "" when the header is missing or shaped * differently. */ private static string bearer_token(HttpContext http_context) { string? header = http_context.request.get_header("Authorization"); if (header == null || !((!)header).has_prefix("Bearer ")) { return ""; } return ((!)header).substring("Bearer ".length).strip(); } }