| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715 |
- 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 <token>` 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<TaskService>();
- protected SaddleService saddles = inject<SaddleService>();
- protected InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
- 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<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",
- Invercargill.Expressions.elem<int64?>(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 <token>` 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<SaddleService>();
- private TaskService tasks = inject<TaskService>();
- 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 {
- bool advanced = false;
- bool advanced_known = false;
- foreach (var pending in yield saddles.take_pending_answers(saddle_id)) {
- if (!advanced_known) {
- advanced_known = true;
- var saddle_row = yield saddles.get_saddle(saddle_id);
- advanced = saddle_row != null
- && yield tasks.branch_advanced_since(((!)saddle_row).current_task_id);
- }
- var element = new Json.Object();
- element.set_int_member("block_id", pending.block_id);
- element.set_string_member("answer", pending.answer);
- element.set_boolean_member("branch_advanced", advanced);
- 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 per-saddle bearer token (plan 10 — any
- * registered saddle's credential authorises the read-only view).
- * 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<SaddleService>();
- private TaskService tasks = inject<TaskService>();
- public virtual async HttpResult handle_request(HttpContext http_context,
- RouteContext route_context) throws Error {
- if (!(yield validate_saddle_bearer(http_context, saddles))) {
- return SaddleUpdateEndpoint.json_error(401, "Missing or invalid saddle 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): per-saddle-token
- * authenticated (plan 10), joining the parent's title for lineage
- * context. 404 when the task does not exist.
- */
- public class SaddleTaskDetailEndpoint : Object, Endpoint {
- private SaddleService saddles = inject<SaddleService>();
- private TaskService tasks = inject<TaskService>();
- public virtual async HttpResult handle_request(HttpContext http_context,
- RouteContext route_context) throws Error {
- if (!(yield validate_saddle_bearer(http_context, saddles))) {
- return SaddleUpdateEndpoint.json_error(401, "Missing or invalid saddle 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 per-saddle bearer token (plan 10), 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 validate_saddle_bearer(http_context, saddles))) {
- return SaddleUpdateEndpoint.json_error(401, "Missing or invalid saddle 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
- * per-saddle bearer token (plan 10 — the credential the daemon
- * persisted at registration), 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<SaddleService>();
- private ProjectService projects = inject<ProjectService>();
- public virtual async HttpResult handle_request(HttpContext http_context,
- RouteContext route_context) throws Error {
- if (!(yield validate_saddle_bearer(http_context, saddles))) {
- return SaddleUpdateEndpoint.json_error(401, "Missing or invalid saddle 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);
- }
- }
- }
- /**
- * `POST /api/saddle/next-task` (plan 10) — a saddle daemon asks for
- * work, authorised by its per-saddle bearer token. The allocation
- * is a single write transaction with two filters: workspace
- * affinity (a task parked/blocked/suspended on this saddle is
- * offered only to this saddle) and capability match (the saddle's
- * tags must cover the task's required tags). Concurrent asks race
- * to exactly one winner.
- *
- * - **200** + the full assignment payload (today's `/start` shape:
- * `task` — with pending block answers — `rules`, `subtasks`,
- * `parent` for subtasks, `project_notes`, `callback`,
- * `llm_config`, `git_identity` from the saddle row, `session_id`,
- * and for resumed workspaces `resume_notice`/`branch_advanced`).
- * - **204** — nothing is offerable; the daemon sleeps until woken.
- */
- public class SaddleNextTaskEndpoint : Object, Endpoint {
- private SaddleService saddles = inject<SaddleService>();
- private AllocationService allocation = inject<AllocationService>();
- private ProjectService projects = inject<ProjectService>();
- private SaddlePollState poll_state = inject<SaddlePollState>();
- public virtual async HttpResult handle_request(HttpContext http_context,
- RouteContext route_context) throws Error {
- var saddle = yield saddles.saddle_for_token(bearer_token(http_context));
- if (saddle == null) {
- return SaddleUpdateEndpoint.json_error(401, "Missing or invalid saddle token");
- }
- // The ask's arrival answers any pending wake (the lost-wake
- // guard must not fire for a saddle that just asked); the
- // outcome is recorded once the allocation resolves.
- poll_state.mark_ask(((!)saddle).id);
- Json.Object? assignment = null;
- string error_text = "";
- try {
- assignment = yield allocation.allocate_next(saddles, projects, (!)saddle);
- } catch (Error e) {
- error_text = e.message;
- }
- poll_state.mark_ask_result(((!)saddle).id, assignment != null);
- if (error_text.length > 0) {
- return SaddleUpdateEndpoint.json_error(500,
- @"Allocation failed: $(error_text)");
- }
- if (assignment == null) {
- var empty = new HttpDataResult(new ByteBuffer.from_byte_array(new uint8[0]),
- (StatusCode) 204);
- return empty;
- }
- var generator = new Json.Generator();
- generator.set_root(new Json.Node(Json.NodeType.OBJECT).init_object(assignment));
- var result = new HttpDataResult(new ByteBuffer.from_byte_array(generator.to_data(null).data));
- result.set_header("Content-Type", "application/json");
- return result;
- }
- }
- /**
- * 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();
- }
- /**
- * The per-saddle credential check shared by the id-less
- * `/api/saddle/*` endpoints (plan 10): the bearer token must belong
- * to a registered saddle (its row token, or a legacy api_tokens row
- * mapped to one) — removing the saddle revokes it.
- */
- private static async bool validate_saddle_bearer(HttpContext http_context,
- SaddleService saddles) throws Error {
- var saddle = yield saddles.saddle_for_token(bearer_token(http_context));
- return saddle != null;
- }
- }
|