SaddleApi.vala 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Astralis;
  4. using Inversion;
  5. using Soup;
  6. using ClankerSaddle.Data;
  7. using ClankerSaddle.Services;
  8. namespace ClankerSaddle.Api {
  9. /**
  10. * Static helpers of the `/api/saddle/*` wire (formerly the late
  11. * SaddleUpdateEndpoint's internals — the update/poll pair died with
  12. * the poll era, plan 16 R3 / arch 19): JSON body reading and the
  13. * ok/error response envelopes every saddle endpoint shares.
  14. */
  15. internal class SaddleWire : Object {
  16. /**
  17. * Reads and parses the request body as a JSON object; an empty body
  18. * parses as an empty object.
  19. */
  20. public static async Json.Object read_json_body(HttpContext http_context) throws Error {
  21. var bytes = yield http_context.request.request_body.read_all();
  22. // to_raw_string on an empty body is not safe; guard the count.
  23. string text = "";
  24. if (bytes != null && (bytes.peek_count() ?? 0) > 0) {
  25. text = bytes.to_raw_string();
  26. }
  27. if (text.strip().length == 0) {
  28. text = "{}";
  29. }
  30. var parser = new Json.Parser();
  31. try {
  32. parser.load_from_data(text);
  33. } catch (Error e) {
  34. throw new Error(Quark.from_string("clanker-saddle"), 22,
  35. @"Request body is not valid JSON: $(e.message)");
  36. }
  37. var root = parser.get_root();
  38. if (root == null || root.get_node_type() != Json.NodeType.OBJECT) {
  39. throw new Error(Quark.from_string("clanker-saddle"), 23,
  40. "Request body must be a JSON object");
  41. }
  42. return root.get_object();
  43. }
  44. internal static HttpResult json_ok(Json.Object body) {
  45. body.set_boolean_member("ok", true);
  46. var generator = new Json.Generator();
  47. generator.set_root(new Json.Node(Json.NodeType.OBJECT).init_object(body));
  48. var result = new HttpDataResult(new ByteBuffer.from_byte_array(generator.to_data(null).data));
  49. result.set_header("Content-Type", "application/json");
  50. return result;
  51. }
  52. public static HttpResult json_error(uint16 status_code, string message) {
  53. var body = new Json.Object();
  54. body.set_boolean_member("ok", false);
  55. body.set_string_member("error", message);
  56. var generator = new Json.Generator();
  57. generator.set_root(new Json.Node(Json.NodeType.OBJECT).init_object(body));
  58. var result = new HttpDataResult(new ByteBuffer.from_byte_array(generator.to_data(null).data),
  59. (StatusCode) status_code);
  60. result.set_header("Content-Type", "application/json");
  61. return result;
  62. }
  63. }
  64. /**
  65. * `GET /api/saddle/tasks?project_id=N&q=free-text` — the task-reading
  66. * API behind the daemon's `saddle tasks` CLI (wire contract):
  67. * authenticated by the per-saddle bearer token (plan 10 — any
  68. * registered saddle's credential authorises the read-only view).
  69. * With `project_id` only that project's tasks; `q` matches title
  70. * or description (case-insensitive substring). Compact rows — id,
  71. * title, status, goal_id.
  72. */
  73. public class SaddleTaskListEndpoint : Object, Endpoint {
  74. private SaddleService saddles = inject<SaddleService>();
  75. private TaskService tasks = inject<TaskService>();
  76. public virtual async HttpResult handle_request(HttpContext http_context,
  77. RouteContext route_context) throws Error {
  78. if ((yield resolve_bearer(http_context, saddles)) == null) {
  79. return SaddleWire.json_error(401, "Missing or invalid saddle token");
  80. }
  81. string project_text = "";
  82. int64? project_id = null;
  83. if (http_context.request.query_params.try_get_any("project_id", out project_text)
  84. && project_text != null && project_text.length > 0) {
  85. project_id = int64.parse(project_text);
  86. }
  87. string q = "";
  88. if (!http_context.request.query_params.try_get_any("q", out q) || q == null) {
  89. // try_get_any leaves the out parameter null when absent.
  90. q = "";
  91. }
  92. q = q.strip().down();
  93. try {
  94. var rows = yield tasks.list_tasks(null, project_id);
  95. var array = new Json.Array();
  96. foreach (var task in rows) {
  97. if (q.length > 0) {
  98. string haystack = (task.title + "\n"
  99. + (task.description ?? "")).down();
  100. if (!(q in haystack)) {
  101. continue;
  102. }
  103. }
  104. var element = new Json.Object();
  105. element.set_int_member("id", task.id);
  106. element.set_string_member("title", task.title);
  107. element.set_string_member("status", task.status);
  108. element.set_int_member("goal_id", task.goal_id);
  109. array.add_object_element(element);
  110. }
  111. var body = new Json.Object();
  112. body.set_array_member("tasks", array);
  113. return SaddleWire.json_ok(body);
  114. } catch (Error e) {
  115. return SaddleWire.json_error(400, e.message);
  116. }
  117. }
  118. }
  119. /**
  120. * `GET /api/saddle/task/{id}` — the single-task view behind the
  121. * daemon's `saddle task` CLI (wire contract): per-saddle-token
  122. * authenticated (plan 10), joining the goal's title for context
  123. * (plan 12 — the parent/plan members are gone). 404 when the
  124. * task does not exist.
  125. */
  126. public class SaddleTaskDetailEndpoint : Object, Endpoint {
  127. private SaddleService saddles = inject<SaddleService>();
  128. private TaskService tasks = inject<TaskService>();
  129. private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
  130. public virtual async HttpResult handle_request(HttpContext http_context,
  131. RouteContext route_context) throws Error {
  132. if ((yield resolve_bearer(http_context, saddles)) == null) {
  133. return SaddleWire.json_error(401, "Missing or invalid saddle token");
  134. }
  135. string id_text = "";
  136. route_context.mapped_parameters.try_get("id", out id_text);
  137. int64 task_id = int64.parse(id_text);
  138. try {
  139. var detail = yield tasks.get_task(task_id);
  140. if (detail == null) {
  141. return SaddleWire.json_error(404,
  142. @"No task exists with id $(task_id)");
  143. }
  144. var task = ((!)detail).task;
  145. string goal_title = "";
  146. if (task.goal_id > 0) {
  147. var goal_row = yield db.query<Goal>()
  148. .where(Invercargill.Expressions.expr("id == $0",
  149. Invercargill.Expressions.elem<int64?>(task.goal_id)))
  150. .first_async();
  151. goal_title = goal_row != null ? ((!)goal_row).title : "";
  152. }
  153. var element = new Json.Object();
  154. element.set_int_member("id", task.id);
  155. element.set_string_member("title", task.title);
  156. element.set_string_member("description", task.description ?? "");
  157. element.set_string_member("status", task.status);
  158. element.set_string_member("summary", task.summary ?? "");
  159. element.set_int_member("goal_id", task.goal_id);
  160. element.set_string_member("goal_title", goal_title);
  161. var body = new Json.Object();
  162. body.set_object_member("task", element);
  163. return SaddleWire.json_ok(body);
  164. } catch (Error e) {
  165. return SaddleWire.json_error(400, e.message);
  166. }
  167. }
  168. }
  169. /**
  170. * `GET /api/saddle/capabilities` (plan 10 phase 3) — the fleet's
  171. * tag catalogue behind the daemon's `saddle capabilities` CLI:
  172. * per-saddle-token authenticated (plan 10), every operator-managed
  173. * tag with the saddles that carry it, tag order by name.
  174. *
  175. * ```json
  176. * {"ok": true, "tags": [{"name": "gpu", "saddles": ["saddle-1"]}]}
  177. * ```
  178. */
  179. public class SaddleCapabilitiesEndpoint : Object, Endpoint {
  180. private SaddleService saddles = inject<SaddleService>();
  181. private CapabilityService capabilities = inject<CapabilityService>();
  182. public virtual async HttpResult handle_request(HttpContext http_context,
  183. RouteContext route_context) throws Error {
  184. if ((yield resolve_bearer(http_context, saddles)) == null) {
  185. return SaddleWire.json_error(401, "Missing or invalid saddle token");
  186. }
  187. try {
  188. // list_saddles is name-ordered, so iterating saddles in
  189. // the outer loop yields each tag's carriers by name.
  190. var saddle_rows = yield saddles.list_saddles();
  191. var carrier_keys = new HashSet<string>();
  192. foreach (var link in yield capabilities.all_saddle_attachments()) {
  193. carrier_keys.add(@"$(link.capability_id):$(link.saddle_id)");
  194. }
  195. var tags = new Json.Array();
  196. foreach (var capability in yield capabilities.list_capabilities()) {
  197. var element = new Json.Object();
  198. element.set_string_member("name", capability.name);
  199. var saddles_array = new Json.Array();
  200. foreach (var saddle in saddle_rows) {
  201. if (carrier_keys.has(@"$(capability.id):$(saddle.id)")) {
  202. saddles_array.add_string_element(saddle.name);
  203. }
  204. }
  205. element.set_array_member("saddles", saddles_array);
  206. tags.add_object_element(element);
  207. }
  208. var body = new Json.Object();
  209. body.set_array_member("tags", tags);
  210. return SaddleWire.json_ok(body);
  211. } catch (Error e) {
  212. return SaddleWire.json_error(400, e.message);
  213. }
  214. }
  215. }
  216. /**
  217. * `POST /api/saddle/note` — the callback endpoint a daemon's
  218. * `saddle note` CLI posts to (wire contract): authenticated by the
  219. * per-saddle bearer token (plan 10 — the credential the daemon
  220. * persisted at registration), inserting
  221. *
  222. * ```json
  223. * {"task_id": 1, "project": "ZL4GQ", "body": "…"}
  224. * ```
  225. *
  226. * `task_id` is optional (stored as the note's source task); the
  227. * project is resolved by name and must exist. Response
  228. * `{"ok": true}`.
  229. */
  230. public class SaddleNoteEndpoint : Object, Endpoint {
  231. private SaddleService saddles = inject<SaddleService>();
  232. private ProjectService projects = inject<ProjectService>();
  233. public virtual async HttpResult handle_request(HttpContext http_context,
  234. RouteContext route_context) throws Error {
  235. if ((yield resolve_bearer(http_context, saddles)) == null) {
  236. return SaddleWire.json_error(401, "Missing or invalid saddle token");
  237. }
  238. Json.Object body;
  239. try {
  240. body = yield SaddleWire.read_json_body(http_context);
  241. } catch (Error e) {
  242. return SaddleWire.json_error(400, e.message);
  243. }
  244. string project_name = EntryJson.get_string_or(body, "project", "");
  245. string note_body = EntryJson.get_string_or(body, "body", "");
  246. int64 task_id = body.has_member("task_id")
  247. && body.get_member("task_id").get_value_type() == typeof(int64)
  248. ? body.get_int_member("task_id") : 0;
  249. if (note_body.strip().length == 0) {
  250. return SaddleWire.json_error(400, "A note body is required");
  251. }
  252. try {
  253. var project = yield projects.get_project_by_name(project_name);
  254. if (project == null) {
  255. return SaddleWire.json_error(400,
  256. @"No project exists named '$(project_name)'");
  257. }
  258. yield projects.add_note(((!)project).id, task_id, note_body);
  259. return SaddleWire.json_ok(new Json.Object());
  260. } catch (Error e) {
  261. return SaddleWire.json_error(400, e.message);
  262. }
  263. }
  264. }
  265. /**
  266. * The plan-12 task CLI callbacks (`task create/delete/enqueue`):
  267. * the goal agent's synchronous task operations against its own
  268. * goal. The goal agent's identity is its goal-run session — the
  269. * `session_id` in the body must resolve to a kind 'goal_run' task
  270. * hosted on the calling saddle; ownership then means "under that
  271. * goal". Errors surface to the agent verbatim (the CLI prints the
  272. * `error` member).
  273. */
  274. public class SaddleTaskCallbacks : Object {
  275. private SaddleService saddles;
  276. private TaskService tasks;
  277. private GoalService goals;
  278. private CapabilityService capabilities;
  279. private InvercargillSql.Orm.OrmSession db;
  280. public SaddleTaskCallbacks(SaddleService saddles, TaskService tasks,
  281. GoalService goals, CapabilityService capabilities,
  282. InvercargillSql.Orm.OrmSession db) {
  283. this.saddles = saddles;
  284. this.tasks = tasks;
  285. this.goals = goals;
  286. this.capabilities = capabilities;
  287. this.db = db;
  288. }
  289. /**
  290. * Resolves the caller's goal scope: the session must exist,
  291. * belong to the calling saddle, and be a goal-run session.
  292. * Returns the goal id, or an error naming the problem
  293. * verbatim.
  294. */
  295. private async int64 goal_scope_of(Saddle saddle, int64 session_id) throws Error {
  296. if (session_id <= 0) {
  297. throw new Error(Quark.from_string("clanker-saddle"), 80,
  298. "A session_id is required (the goal-run session identifies the goal agent)");
  299. }
  300. var session = yield SaddleChunksEndpoint.session_owned_by(db, session_id, saddle);
  301. if (session == null) {
  302. throw new Error(Quark.from_string("clanker-saddle"), 81,
  303. @"No session $(session_id) exists on this saddle");
  304. }
  305. var task = yield db.query<TaskEntity>()
  306. .where(Invercargill.Expressions.expr("id == $0",
  307. Invercargill.Expressions.elem<int64?>(((!)session).task_id)))
  308. .first_async();
  309. if (task == null || ((!)task).kind != TASK_KIND_GOAL_RUN
  310. || ((!)task).goal_id <= 0) {
  311. throw new Error(Quark.from_string("clanker-saddle"), 82,
  312. "task commands are only available to a goal agent (its goal-run session)");
  313. }
  314. var goal = yield goals.get_goal(((!)task).goal_id);
  315. if (goal == null) {
  316. throw new Error(Quark.from_string("clanker-saddle"), 83,
  317. @"The goal of session $(session_id) no longer exists");
  318. }
  319. return ((!)task).goal_id;
  320. }
  321. /**
  322. * `POST /api/saddle/task/create` — `task create` (heredoc
  323. * mandate + flags): creates a draft task under the goal with
  324. * the given mode and explicit capabilities. The goal agent
  325. * sequences its own tasks, so the row carries NO
  326. * auto-enqueue predecessor and lands in draft until the agent
  327. * enqueues it.
  328. *
  329. * ```json
  330. * {"session_id": 12, "title": "…", "mandate": "…",
  331. * "mode": "implement", "capabilities": ["gpu"]}
  332. * ```
  333. *
  334. * → `{"ok": true, "task_id": 7}`.
  335. */
  336. public async HttpResult create(HttpContext http_context, Saddle saddle) throws Error {
  337. Json.Object body;
  338. try {
  339. body = yield SaddleWire.read_json_body(http_context);
  340. } catch (Error e) {
  341. return SaddleWire.json_error(400, e.message);
  342. }
  343. try {
  344. int64 session_id = body.has_member("session_id")
  345. && body.get_member("session_id").get_value_type() == typeof(int64)
  346. ? body.get_int_member("session_id") : 0;
  347. int64 goal_id = yield goal_scope_of(saddle, session_id);
  348. string title = EntryJson.get_string_or(body, "title", "");
  349. string mandate = EntryJson.get_string_or(body, "mandate", "");
  350. if (title.length == 0) {
  351. return SaddleWire.json_error(400, "A title is required");
  352. }
  353. if (mandate.length == 0) {
  354. return SaddleWire.json_error(400,
  355. "A mandate is required (heredoc body of `task create`)");
  356. }
  357. string mode = EntryJson.get_string_or(body, "mode", "implement");
  358. if (!is_valid_mode(mode)) {
  359. return SaddleWire.json_error(400,
  360. @"Unknown mode '$(mode)' — valid modes: "
  361. + string.joinv(", ", TASK_MODES));
  362. }
  363. var goal = yield goals.get_goal(goal_id);
  364. string[] requested_tags = {};
  365. if (body.has_member("capabilities")) {
  366. var member = body.get_member("capabilities");
  367. if (member.get_node_type() != Json.NodeType.ARRAY) {
  368. return SaddleWire.json_error(400,
  369. "capabilities must be a JSON array of tag names");
  370. }
  371. foreach (var element in member.get_array().get_elements()) {
  372. if (element.get_node_type() == Json.NodeType.VALUE
  373. && element.get_string() != null) {
  374. requested_tags += element.get_string();
  375. }
  376. }
  377. }
  378. var resolved = new Vector<Capability>();
  379. if (requested_tags.length > 0) {
  380. var unknown = new Vector<string>();
  381. foreach (string tag in requested_tags) {
  382. string trimmed = tag.strip();
  383. if (trimmed.length == 0) {
  384. continue;
  385. }
  386. var capability = yield capabilities.find_by_name(trimmed);
  387. if (capability == null) {
  388. unknown.add(trimmed);
  389. } else {
  390. resolved.add((!)capability);
  391. }
  392. }
  393. if (unknown.length > 0) {
  394. var valid = new Vector<string>();
  395. foreach (var capability in yield capabilities.list_capabilities()) {
  396. valid.add(capability.name);
  397. }
  398. return SaddleWire.json_error(400,
  399. @"Unknown capability tag(s): $(string.joinv(", ", unknown.to_array()))"
  400. + @" — valid tags: $(string.joinv(", ", valid.to_array()))");
  401. }
  402. }
  403. var task = yield tasks.create_task(((!)goal).project_id, title, mandate,
  404. 5, null, null, STATUS_DRAFT,
  405. TASK_KIND_TASK, mode, CREATOR_GOAL, goal_id, 0);
  406. foreach (var capability in resolved) {
  407. yield capabilities.attach_to_task(capability.id, task.id);
  408. }
  409. var response = new Json.Object();
  410. response.set_int_member("task_id", task.id);
  411. return SaddleWire.json_ok(response);
  412. } catch (Error e) {
  413. return SaddleWire.json_error(400, e.message);
  414. }
  415. }
  416. /**
  417. * `POST /api/saddle/task/delete` — `task delete <id>`: removes
  418. * one of the goal's DRAFT tasks (ownership: the task must be
  419. * under the caller's goal). → `{"ok": true}`.
  420. */
  421. public async HttpResult delete(HttpContext http_context, Saddle saddle) throws Error {
  422. Json.Object body;
  423. try {
  424. body = yield SaddleWire.read_json_body(http_context);
  425. } catch (Error e) {
  426. return SaddleWire.json_error(400, e.message);
  427. }
  428. try {
  429. int64 session_id = body.has_member("session_id")
  430. && body.get_member("session_id").get_value_type() == typeof(int64)
  431. ? body.get_int_member("session_id") : 0;
  432. int64 goal_id = yield goal_scope_of(saddle, session_id);
  433. int64 task_id = body.has_member("task_id")
  434. && body.get_member("task_id").get_value_type() == typeof(int64)
  435. ? body.get_int_member("task_id") : 0;
  436. var detail = yield tasks.get_task(task_id);
  437. if (detail == null) {
  438. return SaddleWire.json_error(400,
  439. @"No task exists with id $(task_id)");
  440. }
  441. if (((!)detail).task.goal_id != goal_id) {
  442. return SaddleWire.json_error(400,
  443. @"Task $(task_id) does not belong to your goal — nothing was deleted; `saddle tasks` lists this goal's tasks");
  444. }
  445. if (((!)detail).task.status != STATUS_DRAFT) {
  446. return SaddleWire.json_error(400,
  447. "Only draft tasks can be deleted (cancel handles the rest)");
  448. }
  449. yield tasks.delete_task(task_id);
  450. return SaddleWire.json_ok(new Json.Object());
  451. } catch (Error e) {
  452. return SaddleWire.json_error(400, e.message);
  453. }
  454. }
  455. /**
  456. * `POST /api/saddle/task/enqueue` — `task enqueue <id>`: moves
  457. * one of the goal's DRAFT tasks into the queue. The
  458. * creator-only rule is honoured by construction: goal tasks
  459. * are created by 'goal', and only a goal agent may act as
  460. * 'goal'. → `{"ok": true}`.
  461. */
  462. public async HttpResult enqueue(HttpContext http_context, Saddle saddle) throws Error {
  463. Json.Object body;
  464. try {
  465. body = yield SaddleWire.read_json_body(http_context);
  466. } catch (Error e) {
  467. return SaddleWire.json_error(400, e.message);
  468. }
  469. try {
  470. int64 session_id = body.has_member("session_id")
  471. && body.get_member("session_id").get_value_type() == typeof(int64)
  472. ? body.get_int_member("session_id") : 0;
  473. int64 goal_id = yield goal_scope_of(saddle, session_id);
  474. int64 task_id = body.has_member("task_id")
  475. && body.get_member("task_id").get_value_type() == typeof(int64)
  476. ? body.get_int_member("task_id") : 0;
  477. var detail = yield tasks.get_task(task_id);
  478. if (detail == null) {
  479. return SaddleWire.json_error(400,
  480. @"No task exists with id $(task_id)");
  481. }
  482. if (((!)detail).task.goal_id != goal_id) {
  483. return SaddleWire.json_error(400,
  484. @"Task $(task_id) does not belong to your goal — nothing was enqueued; `saddle tasks` lists this goal's tasks");
  485. }
  486. yield tasks.enqueue_task(task_id, CREATOR_GOAL);
  487. return SaddleWire.json_ok(new Json.Object());
  488. } catch (Error e) {
  489. return SaddleWire.json_error(400, e.message);
  490. }
  491. }
  492. }
  493. /**
  494. * `POST /api/saddle/task/create` — the endpoint wrapper around
  495. * {@link SaddleTaskCallbacks.create} (per-saddle-token
  496. * authenticated).
  497. */
  498. public class SaddleTaskCreateEndpoint : Object, Endpoint {
  499. private SaddleService saddles = inject<SaddleService>();
  500. private TaskService tasks = inject<TaskService>();
  501. private GoalService goals = inject<GoalService>();
  502. private CapabilityService capabilities = inject<CapabilityService>();
  503. private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
  504. public virtual async HttpResult handle_request(HttpContext http_context,
  505. RouteContext route_context) throws Error {
  506. var saddle = yield resolve_bearer(http_context, saddles);
  507. if (saddle == null) {
  508. return SaddleWire.json_error(401, "Missing or invalid saddle token");
  509. }
  510. var callbacks = new SaddleTaskCallbacks(saddles, tasks, goals, capabilities, db);
  511. return yield callbacks.create(http_context, (!)saddle);
  512. }
  513. }
  514. /**
  515. * `POST /api/saddle/task/delete` — the endpoint wrapper around
  516. * {@link SaddleTaskCallbacks.delete}.
  517. */
  518. public class SaddleTaskDeleteEndpoint : Object, Endpoint {
  519. private SaddleService saddles = inject<SaddleService>();
  520. private TaskService tasks = inject<TaskService>();
  521. private GoalService goals = inject<GoalService>();
  522. private CapabilityService capabilities = inject<CapabilityService>();
  523. private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
  524. public virtual async HttpResult handle_request(HttpContext http_context,
  525. RouteContext route_context) throws Error {
  526. var saddle = yield resolve_bearer(http_context, saddles);
  527. if (saddle == null) {
  528. return SaddleWire.json_error(401, "Missing or invalid saddle token");
  529. }
  530. var callbacks = new SaddleTaskCallbacks(saddles, tasks, goals, capabilities, db);
  531. return yield callbacks.delete(http_context, (!)saddle);
  532. }
  533. }
  534. /**
  535. * `POST /api/saddle/task/enqueue` — the endpoint wrapper around
  536. * {@link SaddleTaskCallbacks.enqueue}.
  537. */
  538. public class SaddleTaskEnqueueEndpoint : Object, Endpoint {
  539. private SaddleService saddles = inject<SaddleService>();
  540. private TaskService tasks = inject<TaskService>();
  541. private GoalService goals = inject<GoalService>();
  542. private CapabilityService capabilities = inject<CapabilityService>();
  543. private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
  544. public virtual async HttpResult handle_request(HttpContext http_context,
  545. RouteContext route_context) throws Error {
  546. var saddle = yield resolve_bearer(http_context, saddles);
  547. if (saddle == null) {
  548. return SaddleWire.json_error(401, "Missing or invalid saddle token");
  549. }
  550. var callbacks = new SaddleTaskCallbacks(saddles, tasks, goals, capabilities, db);
  551. return yield callbacks.enqueue(http_context, (!)saddle);
  552. }
  553. }
  554. /**
  555. * `POST /api/saddle/next-task` (plan 10) — a saddle daemon asks for
  556. * work, authorised by its per-saddle bearer token. The allocation
  557. * is a single write transaction with two filters: workspace
  558. * affinity (a task parked/blocked/suspended on this saddle is
  559. * offered only to this saddle) and capability match (the saddle's
  560. * tags must cover the task's required tags). Concurrent asks race
  561. * to exactly one winner.
  562. *
  563. * - **200** + the full assignment payload (today's `/start` shape:
  564. * `task` — with pending block answers — `rules`,
  565. * `project_notes`, `callback`, `llm_config`, `git_identity`
  566. * from the saddle row, the plan-12 `kind`/`mode`/`creator`/
  567. * `goal`/`ephemeral_branch` context, `session_id`, and for
  568. * resumed workspaces `resume_notice`/`branch_advanced`).
  569. * - **204** — nothing is offerable; the daemon sleeps until woken.
  570. */
  571. public class SaddleNextTaskEndpoint : Object, Endpoint {
  572. private SaddleService saddles = inject<SaddleService>();
  573. private AllocationService allocation = inject<AllocationService>();
  574. private ProjectService projects = inject<ProjectService>();
  575. private SaddlePollState poll_state = inject<SaddlePollState>();
  576. private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
  577. private StatePulse pulse = inject<StatePulse>();
  578. public virtual async HttpResult handle_request(HttpContext http_context,
  579. RouteContext route_context) throws Error {
  580. var saddle = yield resolve_bearer(http_context, saddles);
  581. if (saddle == null) {
  582. return SaddleWire.json_error(401, "Missing or invalid saddle token");
  583. }
  584. // An ask from an offline saddle is proof of life: the daemon
  585. // recovered (its 15-minute safety self-ask, or it came back
  586. // after the probe/guard marked it offline) and no recovery
  587. // pass exists to flip the row — the ask itself does.
  588. if (((!)saddle).status == SADDLE_OFFLINE) {
  589. ((!)saddle).status = SADDLE_IDLE;
  590. yield db.update_async<Saddle>(((!)saddle));
  591. yield pulse.bump(0, "saddle asked (offline -> idle)");
  592. print(@"[ClankerSaddle] Saddle '$(((!)saddle).name)' asked while offline; marking idle\n");
  593. }
  594. // The ask's arrival answers any pending wake (the lost-wake
  595. // guard must not fire for a saddle that just asked); the
  596. // outcome is recorded once the allocation resolves.
  597. poll_state.mark_ask(((!)saddle).id);
  598. Json.Object? assignment = null;
  599. string error_text = "";
  600. try {
  601. assignment = yield allocation.allocate_next(saddles, projects, (!)saddle);
  602. } catch (Error e) {
  603. error_text = e.message;
  604. }
  605. poll_state.mark_ask_result(((!)saddle).id, assignment != null);
  606. if (error_text.length > 0) {
  607. return SaddleWire.json_error(500,
  608. @"Allocation failed: $(error_text)");
  609. }
  610. if (assignment == null) {
  611. var empty = new HttpDataResult(new ByteBuffer.from_byte_array(new uint8[0]),
  612. (StatusCode) 204);
  613. return empty;
  614. }
  615. var generator = new Json.Generator();
  616. generator.set_root(new Json.Node(Json.NodeType.OBJECT).init_object(assignment));
  617. var result = new HttpDataResult(new ByteBuffer.from_byte_array(generator.to_data(null).data));
  618. result.set_header("Content-Type", "application/json");
  619. return result;
  620. }
  621. }
  622. /**
  623. * `POST /api/saddle/chunks` (plan 10 phase 2; plan 17 §7.2) — the
  624. * daemon's event push channel, authorised by the pushing saddle's
  625. * bearer token:
  626. *
  627. * ```json
  628. * {"task_id": 20, "session_id": 34,
  629. * "events": [ { "seq": 61, "kind": "tool_call", "actor": "model",
  630. * "ts": "…ISO8601 UTC…",
  631. * "payload": { "turn": 12, "tool": "run_command",
  632. * "args": { "command": "…" } } } ]}
  633. * ```
  634. *
  635. * `seq` is monotonic per session across all kinds; the batch stores
  636. * idempotently on the events ledger's UNIQUE(session_id, seq) — a
  637. * redelivered seq is consumed, not an error. Kinds: turn_text,
  638. * tool_call, tool_result, progress, lifecycle, usage, state_change;
  639. * actors model|harness; the daemon's `ts` is authoritative. Any
  640. * push implies the daemon is alive (the staleness clock resets).
  641. * Response `{"ok": true, "stored": <newly stored count>}`.
  642. */
  643. public class SaddleChunksEndpoint : Object, Endpoint {
  644. private SaddleService saddles = inject<SaddleService>();
  645. private SaddlePollState poll_state = inject<SaddlePollState>();
  646. private EventService events = inject<EventService>();
  647. private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
  648. public virtual async HttpResult handle_request(HttpContext http_context,
  649. RouteContext route_context) throws Error {
  650. var saddle = yield resolve_bearer(http_context, saddles);
  651. if (saddle == null) {
  652. return SaddleWire.json_error(401, "Missing or invalid saddle token");
  653. }
  654. Json.Object body;
  655. try {
  656. body = yield SaddleWire.read_json_body(http_context);
  657. } catch (Error e) {
  658. return SaddleWire.json_error(400, e.message);
  659. }
  660. int64 session_id = body.has_member("session_id")
  661. && body.get_member("session_id").get_value_type() == typeof(int64)
  662. ? body.get_int_member("session_id") : 0;
  663. var session = yield session_owned_by(db, session_id, (!)saddle);
  664. if (session == null) {
  665. return SaddleWire.json_error(400,
  666. @"No session $(session_id) exists on saddle $(((!)saddle).id)");
  667. }
  668. if (!(body.has_member("events")
  669. && body.get_member("events").get_node_type() == Json.NodeType.ARRAY)) {
  670. return SaddleWire.json_error(400,
  671. "An events array is required");
  672. }
  673. poll_state.note_push(session_id);
  674. try {
  675. int64 stored = yield events.store_pushed_events(session_id,
  676. ((!)session).task_id, body.get_array_member("events"));
  677. var response = new Json.Object();
  678. response.set_int_member("stored", stored);
  679. return SaddleWire.json_ok(response);
  680. } catch (Error e) {
  681. return SaddleWire.json_error(400, e.message);
  682. }
  683. }
  684. internal static async ClankerSaddle.Data.Session? session_owned_by(
  685. InvercargillSql.Orm.OrmSession db, int64 session_id, Saddle saddle) throws Error {
  686. if (session_id <= 0) {
  687. return null;
  688. }
  689. var session = yield db.query<ClankerSaddle.Data.Session>()
  690. .where(Invercargill.Expressions.expr("id == $0",
  691. Invercargill.Expressions.elem<int64?>(session_id)))
  692. .first_async();
  693. if (session == null || ((!)session).saddle_id != saddle.id) {
  694. return null;
  695. }
  696. return session;
  697. }
  698. }
  699. /**
  700. * `POST /api/saddle/results` (plan 10 phase 2; plan 17 §7.2 — the
  701. * state transitions are unchanged, the entries array is gone)
  702. * — the round-results push channel, authorised by the pushing
  703. * saddle's bearer token:
  704. *
  705. * ```json
  706. * {"session_id": 12, "state": "blocked",
  707. * "block": {"question": "…", "type": "question", "options": […],
  708. * "body": "…"},
  709. * "summary": "…"}
  710. * ```
  711. *
  712. * The state transitions drive the same bookkeeping the /status
  713. * poll switch ran: `blocked` raises the block payload as an
  714. * answerable `task_blocks` row (the pulse bump makes the question
  715. * appear on live pages) and parks the saddle; `suspended` parks
  716. * for the task-terminal-event resume; `done`/`failed` complete or
  717. * fail the task with the pushed summary; `working` flips the task
  718. * to working. Round entries travel as ledger events on the chunks
  719. * channel now — an `entries` member is consumed silently (the
  720. * daemon workstream stops sending it).
  721. */
  722. public class SaddleResultsEndpoint : Object, Endpoint {
  723. private SaddleService saddles = inject<SaddleService>();
  724. private TaskService tasks = inject<TaskService>();
  725. private SaddlePollState poll_state = inject<SaddlePollState>();
  726. private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
  727. public virtual async HttpResult handle_request(HttpContext http_context,
  728. RouteContext route_context) throws Error {
  729. var saddle = yield resolve_bearer(http_context, saddles);
  730. if (saddle == null) {
  731. return SaddleWire.json_error(401, "Missing or invalid saddle token");
  732. }
  733. Json.Object body;
  734. try {
  735. body = yield SaddleWire.read_json_body(http_context);
  736. } catch (Error e) {
  737. return SaddleWire.json_error(400, e.message);
  738. }
  739. int64 session_id = body.has_member("session_id")
  740. && body.get_member("session_id").get_value_type() == typeof(int64)
  741. ? body.get_int_member("session_id") : 0;
  742. var session = yield SaddleChunksEndpoint.session_owned_by(db, session_id, (!)saddle);
  743. if (session == null) {
  744. return SaddleWire.json_error(400,
  745. @"No session $(session_id) exists on saddle $(((!)saddle).id)");
  746. }
  747. string state = EntryJson.get_string_or(body, "state", "");
  748. if (state.length == 0) {
  749. return SaddleWire.json_error(400, "A state is required");
  750. }
  751. Json.Object? block = body.has_member("block")
  752. && body.get_member("block").get_node_type() == Json.NodeType.OBJECT
  753. ? body.get_object_member("block") : null;
  754. string? summary = EntryJson.get_string_or(body, "summary", null);
  755. poll_state.note_push(session_id);
  756. try {
  757. yield saddles.apply_result_state((!)session, state, block,
  758. summary, tasks);
  759. return SaddleWire.json_ok(new Json.Object());
  760. } catch (Error e) {
  761. return SaddleWire.json_error(400, e.message);
  762. }
  763. }
  764. }
  765. /**
  766. * The request's bearer token: the `Authorization` header minus its
  767. * `Bearer ` prefix, or "" when the header is missing or shaped
  768. * differently.
  769. */
  770. private static string bearer_token(HttpContext http_context) {
  771. string? header = http_context.request.get_header("Authorization");
  772. if (header == null || !((!)header).has_prefix("Bearer ")) {
  773. return "";
  774. }
  775. return ((!)header).substring("Bearer ".length).strip();
  776. }
  777. /**
  778. * The ONE bearer resolution for the `/api/saddle/*` wire (plan 16
  779. * R2, arch item 26): the `Authorization: Bearer` credential
  780. * resolved to its saddle — null fails the call. The {id}-anchored
  781. * poll-era routes (update, poll) died with arch 19, so every
  782. * remaining route is id-less and accepts whichever registered
  783. * saddle owns the token ({@link SaddleService.saddle_for_token};
  784. * removing the saddle revokes it).
  785. */
  786. private static async Saddle? resolve_bearer(HttpContext http_context,
  787. SaddleService saddles) throws Error {
  788. return yield saddles.saddle_for_token(bearer_token(http_context));
  789. }
  790. }