SaddleApi.vala 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715
  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. * `POST /api/saddle/{id}/update` — the ingest channel for a saddle
  11. * daemon, authorised by its `Authorization: Bearer <token>` header
  12. * (see {@link SaddleService.validate_token}). The body carries a type
  13. * discriminator, the session id and a type-specific `data` object:
  14. *
  15. * ```json
  16. * {"type": "output", "session_id": 12, "data": {
  17. * "command": "ls -la", "output": "…", "exit_code": 0, "type": "command",
  18. * "started_at": "…", "completed_at": "…", "duration_ms": 41}}
  19. * ```
  20. *
  21. * Handled types: `output` (a single entry or an `entries` array; both
  22. * `data` shapes store `session_commands` rows), `comment`, `block`
  23. * (question/rfc/review with optional `options` and markdown `body`;
  24. * returns the new `block_id`), `subtask`, `plan`, `progress`,
  25. * `review`, `done` (completes the task), `failed` (fails the task
  26. * with the explanation) and `status` (working|blocked).
  27. * Conversation-level types also append a typed entry to the session's
  28. * terminal log.
  29. */
  30. public class SaddleUpdateEndpoint : Object, Endpoint {
  31. protected TaskService tasks = inject<TaskService>();
  32. protected SaddleService saddles = inject<SaddleService>();
  33. protected InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
  34. public virtual async HttpResult handle_request(HttpContext http_context,
  35. RouteContext route_context) throws Error {
  36. string id_text = "";
  37. route_context.mapped_parameters.try_get("id", out id_text);
  38. int64 saddle_id = int64.parse(id_text);
  39. if (!(yield saddles.validate_token(saddle_id, bearer_token(http_context)))) {
  40. return json_error(401, "Missing or invalid saddle token");
  41. }
  42. Json.Object body;
  43. try {
  44. body = yield read_json_body(http_context);
  45. } catch (Error e) {
  46. return json_error(400, e.message);
  47. }
  48. string type = body.has_member("type") ? body.get_string_member_with_default("type", "") : "";
  49. int64 session_id = body.has_member("session_id") ? body.get_int_member("session_id") : 0;
  50. var data = body.has_member("data") && body.get_member("data").get_node_type() == Json.NodeType.OBJECT
  51. ? body.get_object_member("data") : new Json.Object();
  52. try {
  53. var response = yield handle_update(saddle_id, session_id, type, data);
  54. return json_ok(response);
  55. } catch (Error e) {
  56. return json_error(400, e.message);
  57. }
  58. }
  59. private async Json.Object handle_update(int64 saddle_id, int64 session_id,
  60. string type, Json.Object data) throws Error {
  61. var task_id = yield task_id_for_session(session_id);
  62. switch (type) {
  63. case "output":
  64. if (data.has_member("entries") && data.get_member("entries").get_node_type() == Json.NodeType.ARRAY) {
  65. foreach (var element in data.get_array_member("entries").get_elements()) {
  66. if (element.get_node_type() == Json.NodeType.OBJECT) {
  67. yield store_output(session_id, element.get_object());
  68. }
  69. }
  70. } else {
  71. yield store_output(session_id, data);
  72. }
  73. break;
  74. case "comment":
  75. string body_text = get_string_or(data, "body", get_string_or(data, "text", ""));
  76. yield tasks.add_comment(task_id, "bot", body_text);
  77. yield tasks.log_session_entry(task_id, "comment", "saddle comment", body_text);
  78. break;
  79. case "block":
  80. var block = yield tasks.add_block(task_id,
  81. get_string_or(data, "question", ""),
  82. get_string_or(data, "type", "question"),
  83. string_options(data),
  84. get_string_or(data, "body", null));
  85. yield tasks.log_session_entry(task_id, "block",
  86. @"block: $(block.question)", block.body);
  87. var block_response = new Json.Object();
  88. block_response.set_int_member("block_id", block.id);
  89. return block_response;
  90. case "subtask":
  91. // Dedup against the synchronous callback: a subtask
  92. // row already created for this parent/title (the
  93. // callback path) is not duplicated — the entry log
  94. // is the fallback, not a second source of truth.
  95. string subtask_title = get_string_or(data, "title", "Untitled subtask");
  96. var existing_subtask = yield tasks.find_subtask_by_title(task_id, subtask_title);
  97. if (existing_subtask == null) {
  98. yield tasks.create_subtask(task_id, subtask_title,
  99. get_string_or(data, "description", null));
  100. }
  101. break;
  102. case "plan":
  103. yield tasks.update_plan(task_id, get_string_or(data, "plan",
  104. get_string_or(data, "markdown", "")));
  105. yield tasks.log_session_entry(task_id, "plan", "saddle plan",
  106. get_string_or(data, "plan", get_string_or(data, "markdown", "")));
  107. break;
  108. case "progress":
  109. string summary = get_string_or(data, "summary",
  110. get_string_or(data, "text", ""));
  111. yield tasks.add_progress(task_id, summary);
  112. yield tasks.log_session_entry(task_id, "progress", "saddle progress-update", summary);
  113. break;
  114. case "review":
  115. string review_summary = get_string_or(data, "summary", "");
  116. var review_block = yield tasks.request_review(task_id, review_summary);
  117. yield tasks.log_session_entry(task_id, "review", "saddle review", review_summary);
  118. yield tasks.auto_answer_if_covered(review_block);
  119. break;
  120. case "done":
  121. yield tasks.complete_task(task_id,
  122. get_string_or(data, "summary", null),
  123. get_string_or(data, "working_branch", null), "done");
  124. break;
  125. case "failed":
  126. string failure = get_string_or(data, "summary",
  127. get_string_or(data, "text", ""));
  128. yield tasks.fail_task(task_id, failure.length > 0 ? failure : null);
  129. yield tasks.log_session_entry(task_id, "failed", "saddle failure", failure);
  130. break;
  131. case "status":
  132. string state = get_string_or(data, "state", "");
  133. if (state == "working" || state == "blocked") {
  134. yield tasks.update_task(task_id, null, null, null, state);
  135. }
  136. break;
  137. default:
  138. throw new Error(Quark.from_string("clanker-saddle"), 20,
  139. @"Unknown update type '$(type)'");
  140. }
  141. return new Json.Object();
  142. }
  143. private async void store_output(int64 session_id, Json.Object entry) throws Error {
  144. var command = new SessionCommand() {
  145. session_id = session_id,
  146. command_text = get_string_or(entry, "command", ""),
  147. output_text = get_string_or(entry, "output", null),
  148. exit_code = entry.has_member("exit_code")
  149. && entry.get_member("exit_code").get_value_type() == typeof(int64)
  150. ? entry.get_int_member("exit_code") : 0,
  151. entry_type = get_string_or(entry, "type", "command"),
  152. piped_input = get_string_or(entry, "input", null) != null
  153. && get_string_or(entry, "input", "").length > 0
  154. ? get_string_or(entry, "input", null) : null,
  155. started_at = parse_iso(entry, "started_at"),
  156. completed_at = parse_iso(entry, "completed_at"),
  157. duration_ms = entry.has_member("duration_ms")
  158. && entry.get_member("duration_ms").get_value_type() == typeof(int64)
  159. ? entry.get_int_member("duration_ms") : 0
  160. };
  161. yield db.insert_async<SessionCommand>(command);
  162. }
  163. /** The `options` member as a string array (native or JSON-encoded). */
  164. private static string[] string_options(Json.Object data) {
  165. if (!data.has_member("options")) {
  166. return new string[0];
  167. }
  168. var member = data.get_member("options");
  169. if (member.get_node_type() == Json.NodeType.ARRAY) {
  170. var values = new Vector<string>();
  171. foreach (var element in member.get_array().get_elements()) {
  172. if (element.get_node_type() == Json.NodeType.VALUE
  173. && element.get_string() != null) {
  174. values.add(element.get_string());
  175. }
  176. }
  177. return values.to_array();
  178. }
  179. string raw = get_string_or(data, "options", "");
  180. if (raw.length == 0) {
  181. return new string[0];
  182. }
  183. try {
  184. var parser = new Json.Parser();
  185. parser.load_from_data(raw);
  186. var root = parser.get_root();
  187. if (root != null && root.get_node_type() == Json.NodeType.ARRAY) {
  188. var values = new Vector<string>();
  189. foreach (var element in root.get_array().get_elements()) {
  190. if (element.get_node_type() == Json.NodeType.VALUE
  191. && element.get_string() != null) {
  192. values.add(element.get_string());
  193. }
  194. }
  195. return values.to_array();
  196. }
  197. } catch (Error e) {
  198. warning("[ClankerSaddle] Ignoring malformed block options: %s", e.message);
  199. }
  200. return new string[0];
  201. }
  202. private async int64 task_id_for_session(int64 session_id) throws Error {
  203. var session = yield db.query<ClankerSaddle.Data.Session>()
  204. .where(Invercargill.Expressions.expr("id == $0",
  205. Invercargill.Expressions.elem<int64?>(session_id)))
  206. .first_async();
  207. if (session == null) {
  208. throw new Error(Quark.from_string("clanker-saddle"), 21,
  209. @"No session exists with id $(session_id)");
  210. }
  211. return ((!)session).task_id;
  212. }
  213. /**
  214. * Reads and parses the request body as a JSON object; an empty body
  215. * parses as an empty object.
  216. */
  217. internal static async Json.Object read_json_body(HttpContext http_context) throws Error {
  218. var bytes = yield http_context.request.request_body.read_all();
  219. // to_raw_string on an empty body is not safe; guard the count.
  220. string text = "";
  221. if (bytes != null && (bytes.peek_count() ?? 0) > 0) {
  222. text = bytes.to_raw_string();
  223. }
  224. if (text.strip().length == 0) {
  225. text = "{}";
  226. }
  227. var parser = new Json.Parser();
  228. try {
  229. parser.load_from_data(text);
  230. } catch (Error e) {
  231. throw new Error(Quark.from_string("clanker-saddle"), 22,
  232. @"Request body is not valid JSON: $(e.message)");
  233. }
  234. var root = parser.get_root();
  235. if (root == null || root.get_node_type() != Json.NodeType.OBJECT) {
  236. throw new Error(Quark.from_string("clanker-saddle"), 23,
  237. "Request body must be a JSON object");
  238. }
  239. return root.get_object();
  240. }
  241. internal static HttpResult json_ok(Json.Object body) {
  242. body.set_boolean_member("ok", true);
  243. var generator = new Json.Generator();
  244. generator.set_root(new Json.Node(Json.NodeType.OBJECT).init_object(body));
  245. var result = new HttpDataResult(new ByteBuffer.from_byte_array(generator.to_data(null).data));
  246. result.set_header("Content-Type", "application/json");
  247. return result;
  248. }
  249. internal static HttpResult json_error(uint16 status_code, string message) {
  250. var body = new Json.Object();
  251. body.set_boolean_member("ok", false);
  252. body.set_string_member("error", message);
  253. var generator = new Json.Generator();
  254. generator.set_root(new Json.Node(Json.NodeType.OBJECT).init_object(body));
  255. var result = new HttpDataResult(new ByteBuffer.from_byte_array(generator.to_data(null).data),
  256. (StatusCode) status_code);
  257. result.set_header("Content-Type", "application/json");
  258. return result;
  259. }
  260. internal static string get_string_or(Json.Object obj, string name, string? fallback) {
  261. if (obj.has_member(name) && obj.get_member(name).get_string() != null) {
  262. return obj.get_string_member(name);
  263. }
  264. return fallback;
  265. }
  266. internal static DateTime? parse_iso(Json.Object obj, string name) {
  267. if (!obj.has_member(name)) {
  268. return null;
  269. }
  270. var value = obj.get_member(name);
  271. if (value.get_node_type() != Json.NodeType.VALUE || value.get_string() == null) {
  272. return null;
  273. }
  274. return new DateTime.from_iso8601(value.get_string(), new TimeZone.utc());
  275. }
  276. }
  277. /**
  278. * `GET /api/saddle/{id}/poll` — the outbound channel for a saddle
  279. * daemon, authorised by its `Authorization: Bearer <token>` header:
  280. * answered blocks and undelivered human comments for the saddle's
  281. * current task, each handed over exactly once.
  282. *
  283. * ```json
  284. * {"answers": [{"block_id": 4, "answer": "Use the flange tool"}],
  285. * "comments": [{"comment_id": 9, "author": "user:admin",
  286. * "body": "…", "quote": "…"}]}
  287. * ```
  288. */
  289. public class SaddlePollEndpoint : Object, Endpoint {
  290. private SaddleService saddles = inject<SaddleService>();
  291. private TaskService tasks = inject<TaskService>();
  292. public virtual async HttpResult handle_request(HttpContext http_context,
  293. RouteContext route_context) throws Error {
  294. string id_text = "";
  295. route_context.mapped_parameters.try_get("id", out id_text);
  296. int64 saddle_id = int64.parse(id_text);
  297. if (!(yield saddles.validate_token(saddle_id, bearer_token(http_context)))) {
  298. return SaddleUpdateEndpoint.json_error(401, "Missing or invalid saddle token");
  299. }
  300. var answers = new Json.Array();
  301. var comments = new Json.Array();
  302. try {
  303. bool advanced = false;
  304. bool advanced_known = false;
  305. foreach (var pending in yield saddles.take_pending_answers(saddle_id)) {
  306. if (!advanced_known) {
  307. advanced_known = true;
  308. var saddle_row = yield saddles.get_saddle(saddle_id);
  309. advanced = saddle_row != null
  310. && yield tasks.branch_advanced_since(((!)saddle_row).current_task_id);
  311. }
  312. var element = new Json.Object();
  313. element.set_int_member("block_id", pending.block_id);
  314. element.set_string_member("answer", pending.answer);
  315. element.set_boolean_member("branch_advanced", advanced);
  316. answers.add_object_element(element);
  317. }
  318. foreach (var pending in yield saddles.take_pending_comments(saddle_id)) {
  319. var element = new Json.Object();
  320. element.set_int_member("comment_id", pending.comment_id);
  321. element.set_string_member("author", pending.author);
  322. element.set_string_member("body", pending.body);
  323. element.set_string_member("quote", pending.quote);
  324. comments.add_object_element(element);
  325. }
  326. } catch (Error e) {
  327. return SaddleUpdateEndpoint.json_error(400, e.message);
  328. }
  329. var body = new Json.Object();
  330. body.set_array_member("answers", answers);
  331. body.set_array_member("comments", comments);
  332. return SaddleUpdateEndpoint.json_ok(body);
  333. }
  334. }
  335. /**
  336. * `GET /api/saddle/tasks?project_id=N&q=free-text` — the task-reading
  337. * API behind the daemon's `saddle tasks` CLI (wire contract):
  338. * authenticated by the per-saddle bearer token (plan 10 — any
  339. * registered saddle's credential authorises the read-only view).
  340. * With `project_id` only that project's tasks; `q` matches title,
  341. * description or plan (case-insensitive substring). Compact rows —
  342. * id, title, status, parent_task_id.
  343. */
  344. public class SaddleTaskListEndpoint : Object, Endpoint {
  345. private SaddleService saddles = inject<SaddleService>();
  346. private TaskService tasks = inject<TaskService>();
  347. public virtual async HttpResult handle_request(HttpContext http_context,
  348. RouteContext route_context) throws Error {
  349. if (!(yield validate_saddle_bearer(http_context, saddles))) {
  350. return SaddleUpdateEndpoint.json_error(401, "Missing or invalid saddle token");
  351. }
  352. string project_text = "";
  353. int64? project_id = null;
  354. if (http_context.request.query_params.try_get_any("project_id", out project_text)
  355. && project_text != null && project_text.length > 0) {
  356. project_id = int64.parse(project_text);
  357. }
  358. string q = "";
  359. if (!http_context.request.query_params.try_get_any("q", out q) || q == null) {
  360. // try_get_any leaves the out parameter null when absent.
  361. q = "";
  362. }
  363. q = q.strip().down();
  364. try {
  365. var rows = yield tasks.list_tasks(null, project_id);
  366. var array = new Json.Array();
  367. foreach (var task in rows) {
  368. if (q.length > 0) {
  369. string haystack = (task.title + "\n"
  370. + (task.description ?? "") + "\n"
  371. + (task.plan ?? "")).down();
  372. if (!(q in haystack)) {
  373. continue;
  374. }
  375. }
  376. var element = new Json.Object();
  377. element.set_int_member("id", task.id);
  378. element.set_string_member("title", task.title);
  379. element.set_string_member("status", task.status);
  380. element.set_int_member("parent_task_id", task.parent_task_id);
  381. array.add_object_element(element);
  382. }
  383. var body = new Json.Object();
  384. body.set_array_member("tasks", array);
  385. return SaddleUpdateEndpoint.json_ok(body);
  386. } catch (Error e) {
  387. return SaddleUpdateEndpoint.json_error(400, e.message);
  388. }
  389. }
  390. }
  391. /**
  392. * `GET /api/saddle/task/{id}` — the single-task view behind the
  393. * daemon's `saddle task` CLI (wire contract): per-saddle-token
  394. * authenticated (plan 10), joining the parent's title for lineage
  395. * context. 404 when the task does not exist.
  396. */
  397. public class SaddleTaskDetailEndpoint : Object, Endpoint {
  398. private SaddleService saddles = inject<SaddleService>();
  399. private TaskService tasks = inject<TaskService>();
  400. public virtual async HttpResult handle_request(HttpContext http_context,
  401. RouteContext route_context) throws Error {
  402. if (!(yield validate_saddle_bearer(http_context, saddles))) {
  403. return SaddleUpdateEndpoint.json_error(401, "Missing or invalid saddle token");
  404. }
  405. string id_text = "";
  406. route_context.mapped_parameters.try_get("id", out id_text);
  407. int64 task_id = int64.parse(id_text);
  408. try {
  409. var detail = yield tasks.get_task(task_id);
  410. if (detail == null) {
  411. return SaddleUpdateEndpoint.json_error(404,
  412. @"No task exists with id $(task_id)");
  413. }
  414. var task = ((!)detail).task;
  415. string parent_title = "";
  416. if (task.parent_task_id > 0) {
  417. var parent = yield tasks.get_task(task.parent_task_id);
  418. parent_title = parent != null ? ((!)parent).task.title : "";
  419. }
  420. var element = new Json.Object();
  421. element.set_int_member("id", task.id);
  422. element.set_string_member("title", task.title);
  423. element.set_string_member("description", task.description ?? "");
  424. element.set_string_member("status", task.status);
  425. element.set_string_member("plan", task.plan ?? "");
  426. element.set_string_member("summary", task.summary ?? "");
  427. element.set_int_member("parent_task_id", task.parent_task_id);
  428. element.set_string_member("parent_title", parent_title);
  429. var body = new Json.Object();
  430. body.set_object_member("task", element);
  431. return SaddleUpdateEndpoint.json_ok(body);
  432. } catch (Error e) {
  433. return SaddleUpdateEndpoint.json_error(400, e.message);
  434. }
  435. }
  436. }
  437. /**
  438. * `POST /api/saddle/subtask` — the synchronous subtask-creation
  439. * callback a daemon's `saddle subtask` CLI posts to (wire contract):
  440. * authenticated by the per-saddle bearer token (plan 10), creating
  441. * the task row immediately — same project, branch and priority as
  442. * the parent — with `task_dependencies` rows for every `depends_on`
  443. * id and the timeline entry on the parent's session. Idempotent on
  444. * (parent, title): a retried callback or the later entry-poll
  445. * fallback returns the existing task's id instead of duplicating.
  446. *
  447. * ```json
  448. * {"parent_task_id": 1, "title": "…", "description": "…",
  449. * "depends_on": [3, 4], "session_id": 2}
  450. * ```
  451. *
  452. * → `{"ok": true, "task_id": 7}`.
  453. */
  454. public class SaddleSubtaskEndpoint : Object, Endpoint {
  455. private SaddleService saddles = inject<SaddleService>();
  456. private ProjectService projects = inject<ProjectService>();
  457. private TaskService tasks = inject<TaskService>();
  458. public virtual async HttpResult handle_request(HttpContext http_context,
  459. RouteContext route_context) throws Error {
  460. if (!(yield validate_saddle_bearer(http_context, saddles))) {
  461. return SaddleUpdateEndpoint.json_error(401, "Missing or invalid saddle token");
  462. }
  463. Json.Object body;
  464. try {
  465. body = yield SaddleUpdateEndpoint.read_json_body(http_context);
  466. } catch (Error e) {
  467. return SaddleUpdateEndpoint.json_error(400, e.message);
  468. }
  469. int64 parent_task_id = body.has_member("parent_task_id")
  470. && body.get_member("parent_task_id").get_value_type() == typeof(int64)
  471. ? body.get_int_member("parent_task_id") : 0;
  472. string title = SaddleUpdateEndpoint.get_string_or(body, "title", "");
  473. if (parent_task_id <= 0 || title.length == 0) {
  474. return SaddleUpdateEndpoint.json_error(400,
  475. "parent_task_id and title are required");
  476. }
  477. var parent = yield tasks.get_task(parent_task_id);
  478. if (parent == null) {
  479. return SaddleUpdateEndpoint.json_error(400,
  480. @"No task exists with id $(parent_task_id)");
  481. }
  482. int64[] depends_on = {};
  483. if (body.has_member("depends_on")
  484. && body.get_member("depends_on").get_node_type() == Json.NodeType.ARRAY) {
  485. foreach (var element in body.get_array_member("depends_on").get_elements()) {
  486. if (element.get_node_type() == Json.NodeType.VALUE) {
  487. int64 parsed = 0;
  488. if (element.get_value_type() == typeof(int64)) {
  489. parsed = element.get_int();
  490. } else {
  491. parsed = int64.parse(element.get_string());
  492. }
  493. if (parsed > 0) {
  494. depends_on += parsed;
  495. }
  496. }
  497. }
  498. }
  499. try {
  500. var existing = yield tasks.find_subtask_by_title(parent_task_id, title);
  501. TaskEntity task;
  502. bool created = false;
  503. if (existing != null) {
  504. task = (!)existing;
  505. } else {
  506. task = yield tasks.create_subtask_with_dependencies(parent_task_id,
  507. title, SaddleUpdateEndpoint.get_string_or(body, "description", null),
  508. depends_on);
  509. created = true;
  510. }
  511. if (created) {
  512. yield tasks.log_session_entry(parent_task_id, "subtask",
  513. @"saddle subtask: $(title)", @"created task #$(task.id)");
  514. }
  515. var response = new Json.Object();
  516. response.set_int_member("task_id", task.id);
  517. return SaddleUpdateEndpoint.json_ok(response);
  518. } catch (Error e) {
  519. return SaddleUpdateEndpoint.json_error(400, e.message);
  520. }
  521. }
  522. }
  523. /**
  524. * `POST /api/saddle/note` — the callback endpoint a daemon's
  525. * `saddle note` CLI posts to (wire contract): authenticated by the
  526. * per-saddle bearer token (plan 10 — the credential the daemon
  527. * persisted at registration), inserting
  528. *
  529. * ```json
  530. * {"task_id": 1, "project": "ZL4GQ", "body": "…"}
  531. * ```
  532. *
  533. * `task_id` is optional (stored as the note's source task); the
  534. * project is resolved by name and must exist. Response
  535. * `{"ok": true}`.
  536. */
  537. public class SaddleNoteEndpoint : Object, Endpoint {
  538. private SaddleService saddles = inject<SaddleService>();
  539. private ProjectService projects = inject<ProjectService>();
  540. public virtual async HttpResult handle_request(HttpContext http_context,
  541. RouteContext route_context) throws Error {
  542. if (!(yield validate_saddle_bearer(http_context, saddles))) {
  543. return SaddleUpdateEndpoint.json_error(401, "Missing or invalid saddle token");
  544. }
  545. Json.Object body;
  546. try {
  547. body = yield SaddleUpdateEndpoint.read_json_body(http_context);
  548. } catch (Error e) {
  549. return SaddleUpdateEndpoint.json_error(400, e.message);
  550. }
  551. string project_name = SaddleUpdateEndpoint.get_string_or(body, "project", "");
  552. string note_body = SaddleUpdateEndpoint.get_string_or(body, "body", "");
  553. int64 task_id = body.has_member("task_id")
  554. && body.get_member("task_id").get_value_type() == typeof(int64)
  555. ? body.get_int_member("task_id") : 0;
  556. if (note_body.strip().length == 0) {
  557. return SaddleUpdateEndpoint.json_error(400, "A note body is required");
  558. }
  559. try {
  560. var project = yield projects.get_project_by_name(project_name);
  561. if (project == null) {
  562. return SaddleUpdateEndpoint.json_error(400,
  563. @"No project exists named '$(project_name)'");
  564. }
  565. yield projects.add_note(((!)project).id, task_id, note_body);
  566. return SaddleUpdateEndpoint.json_ok(new Json.Object());
  567. } catch (Error e) {
  568. return SaddleUpdateEndpoint.json_error(400, e.message);
  569. }
  570. }
  571. }
  572. /**
  573. * `POST /api/saddle/next-task` (plan 10) — a saddle daemon asks for
  574. * work, authorised by its per-saddle bearer token. The allocation
  575. * is a single write transaction with two filters: workspace
  576. * affinity (a task parked/blocked/suspended on this saddle is
  577. * offered only to this saddle) and capability match (the saddle's
  578. * tags must cover the task's required tags). Concurrent asks race
  579. * to exactly one winner.
  580. *
  581. * - **200** + the full assignment payload (today's `/start` shape:
  582. * `task` — with pending block answers — `rules`, `subtasks`,
  583. * `parent` for subtasks, `project_notes`, `callback`,
  584. * `llm_config`, `git_identity` from the saddle row, `session_id`,
  585. * and for resumed workspaces `resume_notice`/`branch_advanced`).
  586. * - **204** — nothing is offerable; the daemon sleeps until woken.
  587. */
  588. public class SaddleNextTaskEndpoint : Object, Endpoint {
  589. private SaddleService saddles = inject<SaddleService>();
  590. private AllocationService allocation = inject<AllocationService>();
  591. private ProjectService projects = inject<ProjectService>();
  592. private SaddlePollState poll_state = inject<SaddlePollState>();
  593. public virtual async HttpResult handle_request(HttpContext http_context,
  594. RouteContext route_context) throws Error {
  595. var saddle = yield saddles.saddle_for_token(bearer_token(http_context));
  596. if (saddle == null) {
  597. return SaddleUpdateEndpoint.json_error(401, "Missing or invalid saddle token");
  598. }
  599. // The ask's arrival answers any pending wake (the lost-wake
  600. // guard must not fire for a saddle that just asked); the
  601. // outcome is recorded once the allocation resolves.
  602. poll_state.mark_ask(((!)saddle).id);
  603. Json.Object? assignment = null;
  604. string error_text = "";
  605. try {
  606. assignment = yield allocation.allocate_next(saddles, projects, (!)saddle);
  607. } catch (Error e) {
  608. error_text = e.message;
  609. }
  610. poll_state.mark_ask_result(((!)saddle).id, assignment != null);
  611. if (error_text.length > 0) {
  612. return SaddleUpdateEndpoint.json_error(500,
  613. @"Allocation failed: $(error_text)");
  614. }
  615. if (assignment == null) {
  616. var empty = new HttpDataResult(new ByteBuffer.from_byte_array(new uint8[0]),
  617. (StatusCode) 204);
  618. return empty;
  619. }
  620. var generator = new Json.Generator();
  621. generator.set_root(new Json.Node(Json.NodeType.OBJECT).init_object(assignment));
  622. var result = new HttpDataResult(new ByteBuffer.from_byte_array(generator.to_data(null).data));
  623. result.set_header("Content-Type", "application/json");
  624. return result;
  625. }
  626. }
  627. /**
  628. * The request's bearer token: the `Authorization` header minus its
  629. * `Bearer ` prefix, or "" when the header is missing or shaped
  630. * differently.
  631. */
  632. private static string bearer_token(HttpContext http_context) {
  633. string? header = http_context.request.get_header("Authorization");
  634. if (header == null || !((!)header).has_prefix("Bearer ")) {
  635. return "";
  636. }
  637. return ((!)header).substring("Bearer ".length).strip();
  638. }
  639. /**
  640. * The per-saddle credential check shared by the id-less
  641. * `/api/saddle/*` endpoints (plan 10): the bearer token must belong
  642. * to a registered saddle (its row token, or a legacy api_tokens row
  643. * mapped to one) — removing the saddle revokes it.
  644. */
  645. private static async bool validate_saddle_bearer(HttpContext http_context,
  646. SaddleService saddles) throws Error {
  647. var saddle = yield saddles.saddle_for_token(bearer_token(http_context));
  648. return saddle != null;
  649. }
  650. }