| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860 |
- 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<SaddleService>();
- private TaskService tasks = inject<TaskService>();
- 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<SaddleService>();
- private TaskService tasks = inject<TaskService>();
- private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
- 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<Goal>()
- .where(Invercargill.Expressions.expr("id == $0",
- Invercargill.Expressions.elem<int64?>(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<SaddleService>();
- private CapabilityService capabilities = inject<CapabilityService>();
- 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<string>();
- 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<SaddleService>();
- private ProjectService projects = inject<ProjectService>();
- 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<TaskEntity>()
- .where(Invercargill.Expressions.expr("id == $0",
- Invercargill.Expressions.elem<int64?>(((!)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<Capability>();
- if (requested_tags.length > 0) {
- var unknown = new Vector<string>();
- 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<string>();
- 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 <id>`: 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 <id>`: 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<SaddleService>();
- private TaskService tasks = inject<TaskService>();
- private GoalService goals = inject<GoalService>();
- private CapabilityService capabilities = inject<CapabilityService>();
- private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
- 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<SaddleService>();
- private TaskService tasks = inject<TaskService>();
- private GoalService goals = inject<GoalService>();
- private CapabilityService capabilities = inject<CapabilityService>();
- private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
- 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<SaddleService>();
- private TaskService tasks = inject<TaskService>();
- private GoalService goals = inject<GoalService>();
- private CapabilityService capabilities = inject<CapabilityService>();
- private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
- 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<SaddleService>();
- private AllocationService allocation = inject<AllocationService>();
- private ProjectService projects = inject<ProjectService>();
- private SaddlePollState poll_state = inject<SaddlePollState>();
- private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
- private StatePulse pulse = inject<StatePulse>();
- 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>(((!)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": <newly stored count>}`.
- */
- public class SaddleChunksEndpoint : Object, Endpoint {
- private SaddleService saddles = inject<SaddleService>();
- private SaddlePollState poll_state = inject<SaddlePollState>();
- private EventService events = inject<EventService>();
- private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
- 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<ClankerSaddle.Data.Session>()
- .where(Invercargill.Expressions.expr("id == $0",
- Invercargill.Expressions.elem<int64?>(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<SaddleService>();
- private TaskService tasks = inject<TaskService>();
- private SaddlePollState poll_state = inject<SaddlePollState>();
- private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
- 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));
- }
- }
|