using Invercargill; using Invercargill.DataStructures; using Astralis; using Inversion; using Soup; using ClankerSaddle.Data; using ClankerSaddle.Services; namespace ClankerSaddle.Api { /** * Static helpers of the `/api/saddle/*` wire (formerly the late * SaddleUpdateEndpoint's internals — the update/poll pair died with * the poll era, plan 16 R3 / arch 19): JSON body reading and the * ok/error response envelopes every saddle endpoint shares. */ internal class SaddleWire : Object { /** * Reads and parses the request body as a JSON object; an empty body * parses as an empty object. */ public 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; } public 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; } } /** * `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 * or description (case-insensitive substring). Compact rows — id, * title, status, goal_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 resolve_bearer(http_context, saddles)) == null) { return SaddleWire.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 ?? "")).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("goal_id", task.goal_id); array.add_object_element(element); } var body = new Json.Object(); body.set_array_member("tasks", array); return SaddleWire.json_ok(body); } catch (Error e) { return SaddleWire.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 goal's title for context * (plan 12 — the parent/plan members are gone). 404 when the * task does not exist. */ public class SaddleTaskDetailEndpoint : Object, Endpoint { private SaddleService saddles = inject(); private TaskService tasks = inject(); private InvercargillSql.Orm.OrmSession db = inject(); public virtual async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error { if ((yield resolve_bearer(http_context, saddles)) == null) { return SaddleWire.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 SaddleWire.json_error(404, @"No task exists with id $(task_id)"); } var task = ((!)detail).task; string goal_title = ""; if (task.goal_id > 0) { var goal_row = yield db.query() .where(Invercargill.Expressions.expr("id == $0", Invercargill.Expressions.elem(task.goal_id))) .first_async(); goal_title = goal_row != null ? ((!)goal_row).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("summary", task.summary ?? ""); element.set_int_member("goal_id", task.goal_id); element.set_string_member("goal_title", goal_title); var body = new Json.Object(); body.set_object_member("task", element); return SaddleWire.json_ok(body); } catch (Error e) { return SaddleWire.json_error(400, e.message); } } } /** * `GET /api/saddle/capabilities` (plan 10 phase 3) — the fleet's * tag catalogue behind the daemon's `saddle capabilities` CLI: * per-saddle-token authenticated (plan 10), every operator-managed * tag with the saddles that carry it, tag order by name. * * ```json * {"ok": true, "tags": [{"name": "gpu", "saddles": ["saddle-1"]}]} * ``` */ public class SaddleCapabilitiesEndpoint : Object, Endpoint { private SaddleService saddles = inject(); private CapabilityService capabilities = inject(); public virtual async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error { if ((yield resolve_bearer(http_context, saddles)) == null) { return SaddleWire.json_error(401, "Missing or invalid saddle token"); } try { // list_saddles is name-ordered, so iterating saddles in // the outer loop yields each tag's carriers by name. var saddle_rows = yield saddles.list_saddles(); var carrier_keys = new HashSet(); foreach (var link in yield capabilities.all_saddle_attachments()) { carrier_keys.add(@"$(link.capability_id):$(link.saddle_id)"); } var tags = new Json.Array(); foreach (var capability in yield capabilities.list_capabilities()) { var element = new Json.Object(); element.set_string_member("name", capability.name); var saddles_array = new Json.Array(); foreach (var saddle in saddle_rows) { if (carrier_keys.has(@"$(capability.id):$(saddle.id)")) { saddles_array.add_string_element(saddle.name); } } element.set_array_member("saddles", saddles_array); tags.add_object_element(element); } var body = new Json.Object(); body.set_array_member("tags", tags); return SaddleWire.json_ok(body); } catch (Error e) { return SaddleWire.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(); private ProjectService projects = inject(); public virtual async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error { if ((yield resolve_bearer(http_context, saddles)) == null) { return SaddleWire.json_error(401, "Missing or invalid saddle token"); } Json.Object body; try { body = yield SaddleWire.read_json_body(http_context); } catch (Error e) { return SaddleWire.json_error(400, e.message); } 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; if (note_body.strip().length == 0) { return SaddleWire.json_error(400, "A note body is required"); } try { var project = yield projects.get_project_by_name(project_name); if (project == null) { return SaddleWire.json_error(400, @"No project exists named '$(project_name)'"); } yield projects.add_note(((!)project).id, task_id, note_body); return SaddleWire.json_ok(new Json.Object()); } catch (Error e) { return SaddleWire.json_error(400, e.message); } } } /** * The plan-12 task CLI callbacks (`task create/delete/enqueue`): * the goal agent's synchronous task operations against its own * goal. The goal agent's identity is its goal-run session — the * `session_id` in the body must resolve to a kind 'goal_run' task * hosted on the calling saddle; ownership then means "under that * goal". Errors surface to the agent verbatim (the CLI prints the * `error` member). */ public class SaddleTaskCallbacks : Object { private SaddleService saddles; private TaskService tasks; private GoalService goals; private CapabilityService capabilities; private InvercargillSql.Orm.OrmSession db; public SaddleTaskCallbacks(SaddleService saddles, TaskService tasks, GoalService goals, CapabilityService capabilities, InvercargillSql.Orm.OrmSession db) { this.saddles = saddles; this.tasks = tasks; this.goals = goals; this.capabilities = capabilities; this.db = db; } /** * Resolves the caller's goal scope: the session must exist, * belong to the calling saddle, and be a goal-run session. * Returns the goal id, or an error naming the problem * verbatim. */ private async int64 goal_scope_of(Saddle saddle, int64 session_id) throws Error { if (session_id <= 0) { throw new Error(Quark.from_string("clanker-saddle"), 80, "A session_id is required (the goal-run session identifies the goal agent)"); } var session = yield SaddleChunksEndpoint.session_owned_by(db, session_id, saddle); if (session == null) { throw new Error(Quark.from_string("clanker-saddle"), 81, @"No session $(session_id) exists on this saddle"); } var task = yield db.query() .where(Invercargill.Expressions.expr("id == $0", Invercargill.Expressions.elem(((!)session).task_id))) .first_async(); if (task == null || ((!)task).kind != TASK_KIND_GOAL_RUN || ((!)task).goal_id <= 0) { throw new Error(Quark.from_string("clanker-saddle"), 82, "task commands are only available to a goal agent (its goal-run session)"); } var goal = yield goals.get_goal(((!)task).goal_id); if (goal == null) { throw new Error(Quark.from_string("clanker-saddle"), 83, @"The goal of session $(session_id) no longer exists"); } return ((!)task).goal_id; } /** * `POST /api/saddle/task/create` — `task create` (heredoc * mandate + flags): creates a draft task under the goal with * the given mode and explicit capabilities. The goal agent * sequences its own tasks, so the row carries NO * auto-enqueue predecessor and lands in draft until the agent * enqueues it. * * ```json * {"session_id": 12, "title": "…", "mandate": "…", * "mode": "implement", "capabilities": ["gpu"]} * ``` * * → `{"ok": true, "task_id": 7}`. */ public async HttpResult create(HttpContext http_context, Saddle saddle) throws Error { Json.Object body; try { body = yield SaddleWire.read_json_body(http_context); } catch (Error e) { return SaddleWire.json_error(400, e.message); } try { int64 session_id = body.has_member("session_id") && 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 = EntryJson.get_string_or(body, "title", ""); string mandate = EntryJson.get_string_or(body, "mandate", ""); if (title.length == 0) { return SaddleWire.json_error(400, "A title is required"); } if (mandate.length == 0) { return SaddleWire.json_error(400, "A mandate is required (heredoc body of `task create`)"); } string mode = EntryJson.get_string_or(body, "mode", "implement"); if (!is_valid_mode(mode)) { return SaddleWire.json_error(400, @"Unknown mode '$(mode)' — valid modes: " + string.joinv(", ", TASK_MODES)); } var goal = yield goals.get_goal(goal_id); string[] requested_tags = {}; if (body.has_member("capabilities")) { var member = body.get_member("capabilities"); if (member.get_node_type() != Json.NodeType.ARRAY) { return SaddleWire.json_error(400, "capabilities must be a JSON array of tag names"); } foreach (var element in member.get_array().get_elements()) { if (element.get_node_type() == Json.NodeType.VALUE && element.get_string() != null) { requested_tags += element.get_string(); } } } var resolved = new Vector(); if (requested_tags.length > 0) { var unknown = new Vector(); foreach (string tag in requested_tags) { string trimmed = tag.strip(); if (trimmed.length == 0) { continue; } var capability = yield capabilities.find_by_name(trimmed); if (capability == null) { unknown.add(trimmed); } else { resolved.add((!)capability); } } if (unknown.length > 0) { var valid = new Vector(); foreach (var capability in yield capabilities.list_capabilities()) { valid.add(capability.name); } return SaddleWire.json_error(400, @"Unknown capability tag(s): $(string.joinv(", ", unknown.to_array()))" + @" — valid tags: $(string.joinv(", ", valid.to_array()))"); } } var task = yield tasks.create_task(((!)goal).project_id, title, mandate, 5, null, null, STATUS_DRAFT, TASK_KIND_TASK, mode, CREATOR_GOAL, goal_id, 0); foreach (var capability in resolved) { yield capabilities.attach_to_task(capability.id, task.id); } var response = new Json.Object(); response.set_int_member("task_id", task.id); return SaddleWire.json_ok(response); } catch (Error e) { return SaddleWire.json_error(400, e.message); } } /** * `POST /api/saddle/task/delete` — `task delete `: removes * one of the goal's DRAFT tasks (ownership: the task must be * under the caller's goal). → `{"ok": true}`. */ public async HttpResult delete(HttpContext http_context, Saddle saddle) throws Error { Json.Object body; try { body = yield SaddleWire.read_json_body(http_context); } catch (Error e) { return SaddleWire.json_error(400, e.message); } try { int64 session_id = body.has_member("session_id") && 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); 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; var detail = yield tasks.get_task(task_id); if (detail == null) { return SaddleWire.json_error(400, @"No task exists with id $(task_id)"); } if (((!)detail).task.goal_id != goal_id) { return SaddleWire.json_error(400, @"Task $(task_id) does not belong to your goal — nothing was deleted; `saddle tasks` lists this goal's tasks"); } if (((!)detail).task.status != STATUS_DRAFT) { return SaddleWire.json_error(400, "Only draft tasks can be deleted (cancel handles the rest)"); } yield tasks.delete_task(task_id); return SaddleWire.json_ok(new Json.Object()); } catch (Error e) { return SaddleWire.json_error(400, e.message); } } /** * `POST /api/saddle/task/enqueue` — `task enqueue `: moves * one of the goal's DRAFT tasks into the queue. The * creator-only rule is honoured by construction: goal tasks * are created by 'goal', and only a goal agent may act as * 'goal'. → `{"ok": true}`. */ public async HttpResult enqueue(HttpContext http_context, Saddle saddle) throws Error { Json.Object body; try { body = yield SaddleWire.read_json_body(http_context); } catch (Error e) { return SaddleWire.json_error(400, e.message); } try { int64 session_id = body.has_member("session_id") && 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); 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; var detail = yield tasks.get_task(task_id); if (detail == null) { return SaddleWire.json_error(400, @"No task exists with id $(task_id)"); } if (((!)detail).task.goal_id != goal_id) { return SaddleWire.json_error(400, @"Task $(task_id) does not belong to your goal — nothing was enqueued; `saddle tasks` lists this goal's tasks"); } yield tasks.enqueue_task(task_id, CREATOR_GOAL); return SaddleWire.json_ok(new Json.Object()); } catch (Error e) { return SaddleWire.json_error(400, e.message); } } } /** * `POST /api/saddle/task/create` — the endpoint wrapper around * {@link SaddleTaskCallbacks.create} (per-saddle-token * authenticated). */ public class SaddleTaskCreateEndpoint : Object, Endpoint { private SaddleService saddles = inject(); private TaskService tasks = inject(); private GoalService goals = inject(); private CapabilityService capabilities = inject(); private InvercargillSql.Orm.OrmSession db = inject(); public virtual async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error { var saddle = yield resolve_bearer(http_context, saddles); if (saddle == null) { return SaddleWire.json_error(401, "Missing or invalid saddle token"); } var callbacks = new SaddleTaskCallbacks(saddles, tasks, goals, capabilities, db); return yield callbacks.create(http_context, (!)saddle); } } /** * `POST /api/saddle/task/delete` — the endpoint wrapper around * {@link SaddleTaskCallbacks.delete}. */ public class SaddleTaskDeleteEndpoint : Object, Endpoint { private SaddleService saddles = inject(); private TaskService tasks = inject(); private GoalService goals = inject(); private CapabilityService capabilities = inject(); private InvercargillSql.Orm.OrmSession db = inject(); public virtual async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error { var saddle = yield resolve_bearer(http_context, saddles); if (saddle == null) { return SaddleWire.json_error(401, "Missing or invalid saddle token"); } var callbacks = new SaddleTaskCallbacks(saddles, tasks, goals, capabilities, db); return yield callbacks.delete(http_context, (!)saddle); } } /** * `POST /api/saddle/task/enqueue` — the endpoint wrapper around * {@link SaddleTaskCallbacks.enqueue}. */ public class SaddleTaskEnqueueEndpoint : Object, Endpoint { private SaddleService saddles = inject(); private TaskService tasks = inject(); private GoalService goals = inject(); private CapabilityService capabilities = inject(); private InvercargillSql.Orm.OrmSession db = inject(); public virtual async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error { var saddle = yield resolve_bearer(http_context, saddles); if (saddle == null) { return SaddleWire.json_error(401, "Missing or invalid saddle token"); } var callbacks = new SaddleTaskCallbacks(saddles, tasks, goals, capabilities, db); return yield callbacks.enqueue(http_context, (!)saddle); } } /** * `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`, * `project_notes`, `callback`, `llm_config`, `git_identity` * from the saddle row, the plan-12 `kind`/`mode`/`creator`/ * `goal`/`ephemeral_branch` context, `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(); private AllocationService allocation = inject(); private ProjectService projects = inject(); private SaddlePollState poll_state = inject(); private InvercargillSql.Orm.OrmSession db = inject(); private StatePulse pulse = inject(); public virtual async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error { var saddle = yield resolve_bearer(http_context, saddles); if (saddle == null) { return SaddleWire.json_error(401, "Missing or invalid saddle token"); } // An ask from an offline saddle is proof of life: the daemon // recovered (its 15-minute safety self-ask, or it came back // after the probe/guard marked it offline) and no recovery // pass exists to flip the row — the ask itself does. if (((!)saddle).status == SADDLE_OFFLINE) { ((!)saddle).status = SADDLE_IDLE; yield db.update_async(((!)saddle)); yield pulse.bump(0, "saddle asked (offline -> idle)"); print(@"[ClankerSaddle] Saddle '$(((!)saddle).name)' asked while offline; marking idle\n"); } // 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 SaddleWire.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; } } /** * `POST /api/saddle/chunks` (plan 10 phase 2; plan 17 §7.2) — the * daemon's event push channel, authorised by the pushing saddle's * bearer token: * * ```json * {"task_id": 20, "session_id": 34, * "events": [ { "seq": 61, "kind": "tool_call", "actor": "model", * "ts": "…ISO8601 UTC…", * "payload": { "turn": 12, "tool": "run_command", * "args": { "command": "…" } } } ]} * ``` * * `seq` is monotonic per session across all kinds; the batch stores * idempotently on the events ledger's UNIQUE(session_id, seq) — a * redelivered seq is consumed, not an error. Kinds: turn_text, * tool_call, tool_result, progress, lifecycle, usage, state_change; * actors model|harness; the daemon's `ts` is authoritative. Any * push implies the daemon is alive (the staleness clock resets). * Response `{"ok": true, "stored": }`. */ public class SaddleChunksEndpoint : Object, Endpoint { private SaddleService saddles = inject(); private SaddlePollState poll_state = inject(); private EventService events = inject(); private InvercargillSql.Orm.OrmSession db = inject(); public virtual async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error { var saddle = yield resolve_bearer(http_context, saddles); if (saddle == null) { return SaddleWire.json_error(401, "Missing or invalid saddle token"); } Json.Object body; try { body = yield SaddleWire.read_json_body(http_context); } catch (Error e) { return SaddleWire.json_error(400, e.message); } int64 session_id = body.has_member("session_id") && body.get_member("session_id").get_value_type() == typeof(int64) ? body.get_int_member("session_id") : 0; var session = yield session_owned_by(db, session_id, (!)saddle); if (session == null) { return SaddleWire.json_error(400, @"No session $(session_id) exists on saddle $(((!)saddle).id)"); } if (!(body.has_member("events") && body.get_member("events").get_node_type() == Json.NodeType.ARRAY)) { return SaddleWire.json_error(400, "An events array is required"); } poll_state.note_push(session_id); try { int64 stored = yield events.store_pushed_events(session_id, ((!)session).task_id, body.get_array_member("events")); var response = new Json.Object(); response.set_int_member("stored", stored); return SaddleWire.json_ok(response); } catch (Error e) { return SaddleWire.json_error(400, e.message); } } internal static async ClankerSaddle.Data.Session? session_owned_by( InvercargillSql.Orm.OrmSession db, int64 session_id, Saddle saddle) throws Error { if (session_id <= 0) { return null; } var session = yield db.query() .where(Invercargill.Expressions.expr("id == $0", Invercargill.Expressions.elem(session_id))) .first_async(); if (session == null || ((!)session).saddle_id != saddle.id) { return null; } return session; } } /** * `POST /api/saddle/results` (plan 10 phase 2; plan 17 §7.2 — the * state transitions are unchanged, the entries array is gone) * — the round-results push channel, authorised by the pushing * saddle's bearer token: * * ```json * {"session_id": 12, "state": "blocked", * "block": {"question": "…", "type": "question", "options": […], * "body": "…"}, * "summary": "…"} * ``` * * The state transitions drive the same bookkeeping the /status * poll switch ran: `blocked` raises the block payload as an * answerable `task_blocks` row (the pulse bump makes the question * appear on live pages) and parks the saddle; `suspended` parks * for the task-terminal-event resume; `done`/`failed` complete or * fail the task with the pushed summary; `working` flips the task * to working. Round entries travel as ledger events on the chunks * channel now — an `entries` member is consumed silently (the * daemon workstream stops sending it). */ public class SaddleResultsEndpoint : Object, Endpoint { private SaddleService saddles = inject(); private TaskService tasks = inject(); private SaddlePollState poll_state = inject(); private InvercargillSql.Orm.OrmSession db = inject(); public virtual async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error { var saddle = yield resolve_bearer(http_context, saddles); if (saddle == null) { return SaddleWire.json_error(401, "Missing or invalid saddle token"); } Json.Object body; try { body = yield SaddleWire.read_json_body(http_context); } catch (Error e) { return SaddleWire.json_error(400, e.message); } int64 session_id = body.has_member("session_id") && body.get_member("session_id").get_value_type() == typeof(int64) ? body.get_int_member("session_id") : 0; var session = yield SaddleChunksEndpoint.session_owned_by(db, session_id, (!)saddle); if (session == null) { return SaddleWire.json_error(400, @"No session $(session_id) exists on saddle $(((!)saddle).id)"); } string state = EntryJson.get_string_or(body, "state", ""); if (state.length == 0) { return SaddleWire.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 = EntryJson.get_string_or(body, "summary", null); poll_state.note_push(session_id); try { yield saddles.apply_result_state((!)session, state, block, summary, tasks); return SaddleWire.json_ok(new Json.Object()); } catch (Error e) { return SaddleWire.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(); } /** * The ONE bearer resolution for the `/api/saddle/*` wire (plan 16 * R2, arch item 26): the `Authorization: Bearer` credential * resolved to its saddle — null fails the call. The {id}-anchored * poll-era routes (update, poll) died with arch 19, so every * remaining route is id-less and accepts whichever registered * saddle owns the token ({@link SaddleService.saddle_for_token}; * removing the saddle revokes it). */ private static async Saddle? resolve_bearer(HttpContext http_context, SaddleService saddles) throws Error { return yield saddles.saddle_for_token(bearer_token(http_context)); } }