SaddleApi.vala 53 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155
  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. * `GET /api/saddle/goals?project=<name>&goal_id=<id>` (plan-17
  218. * parity sweep) — the read-only goals context behind the daemon's
  219. * `list_goals` tool (wire contract): authenticated by the
  220. * per-saddle bearer token (plan 10 — any registered saddle's
  221. * credential authorises the read-only view). With `goal_id` the
  222. * single goal's detail (brief included — Saddle clamps again);
  223. * otherwise the goals whose primary OR linked project carries the
  224. * named project, newest first, as compact rows. An unknown
  225. * project name yields an empty list, not an error — the agent's
  226. * context gathering must not break on a typo.
  227. *
  228. * ```json
  229. * {"ok": true, "goals": [{"id": 7, "title": "…", "status": "running",
  230. * "branch": "goal/…", "updated_at": "…"}]}
  231. * {"ok": true, "goal": {"id": 7, "title": "…", "status": "running",
  232. * "branch": "goal/…", "brief": "…",
  233. * "has_plan": true, "has_report": false}}
  234. * ```
  235. */
  236. public class SaddleGoalsEndpoint : Object, Endpoint {
  237. private SaddleService saddles = inject<SaddleService>();
  238. private ProjectService projects = inject<ProjectService>();
  239. private GoalService goals = inject<GoalService>();
  240. public virtual async HttpResult handle_request(HttpContext http_context,
  241. RouteContext route_context) throws Error {
  242. if ((yield resolve_bearer(http_context, saddles)) == null) {
  243. return SaddleWire.json_error(401, "Missing or invalid saddle token");
  244. }
  245. string goal_text = "";
  246. if (http_context.request.query_params.try_get_any("goal_id", out goal_text)
  247. && goal_text != null && goal_text.length > 0) {
  248. int64 goal_id = int64.parse(goal_text);
  249. try {
  250. var goal = goal_id > 0 ? yield goals.get_goal(goal_id) : null;
  251. if (goal == null) {
  252. return SaddleWire.json_error(404,
  253. @"No goal exists with id $(goal_id)");
  254. }
  255. var element = new Json.Object();
  256. element.set_int_member("id", ((!)goal).id);
  257. element.set_string_member("title", ((!)goal).title);
  258. element.set_string_member("status", ((!)goal).status);
  259. element.set_string_member("branch", ((!)goal).branch_name);
  260. element.set_string_member("brief", ((!)goal).brief ?? "");
  261. element.set_boolean_member("has_plan",
  262. (((!)goal).plan ?? "").length > 0);
  263. element.set_boolean_member("has_report",
  264. (((!)goal).completion_report ?? "").length > 0);
  265. var body = new Json.Object();
  266. body.set_object_member("goal", element);
  267. return SaddleWire.json_ok(body);
  268. } catch (Error e) {
  269. return SaddleWire.json_error(400, e.message);
  270. }
  271. }
  272. string project_name = "";
  273. if (!http_context.request.query_params.try_get_any("project", out project_name)
  274. || project_name == null) {
  275. project_name = "";
  276. }
  277. project_name = project_name.strip();
  278. try {
  279. var array = new Json.Array();
  280. if (project_name.length > 0) {
  281. var project = yield projects.get_project_by_name(project_name);
  282. if (project != null) {
  283. string project_key = ((!)project).id.to_string();
  284. // list_goals is newest-first (id descending), so
  285. // the filtered rows keep that order.
  286. foreach (var goal in yield goals.list_goals()) {
  287. var involved = yield goals.involved_project_ids(goal);
  288. if (!involved.contains(project_key)) {
  289. continue;
  290. }
  291. array.add_object_element(goal_row(goal));
  292. }
  293. }
  294. // an unknown project name yields an empty list, not
  295. // an error (see the class doc)
  296. } else {
  297. // No filter: every goal, newest first. The daemon's
  298. // list_goals tool defaults to the task's primary
  299. // project but a standalone task (project_name
  300. // "standalone") sends no filter — the recent-goals
  301. // context must still answer, not read as "no goals".
  302. foreach (var goal in yield goals.list_goals()) {
  303. array.add_object_element(goal_row(goal));
  304. }
  305. }
  306. var body = new Json.Object();
  307. body.set_array_member("goals", array);
  308. return SaddleWire.json_ok(body);
  309. } catch (Error e) {
  310. return SaddleWire.json_error(400, e.message);
  311. }
  312. }
  313. /**
  314. * One compact list row — id, title, status, branch and the
  315. * most recent lifecycle stamp as ISO-8601 (`updated_at`,
  316. * read by Saddle's list_goals tool).
  317. */
  318. private static Json.Object goal_row(Goal goal) {
  319. var element = new Json.Object();
  320. element.set_int_member("id", goal.id);
  321. element.set_string_member("title", goal.title);
  322. element.set_string_member("status", goal.status);
  323. element.set_string_member("branch", goal.branch_name);
  324. DateTime? updated = goal.completed_at ?? goal.started_at ?? goal.created_at;
  325. if (updated != null) {
  326. element.set_string_member("updated_at",
  327. ((!)updated).to_timezone(new TimeZone.utc())
  328. .format("%Y-%m-%dT%H:%M:%SZ"));
  329. }
  330. return element;
  331. }
  332. }
  333. /**
  334. * `POST /api/saddle/note` — the callback endpoint a daemon's
  335. * `saddle note` CLI posts to (wire contract): authenticated by the
  336. * per-saddle bearer token (plan 10 — the credential the daemon
  337. * persisted at registration), inserting
  338. *
  339. * ```json
  340. * {"task_id": 1, "project": "ZL4GQ", "body": "…"}
  341. * ```
  342. *
  343. * `task_id` is optional (stored as the note's source task); the
  344. * project is resolved by name and must exist. Response
  345. * `{"ok": true}`.
  346. */
  347. public class SaddleNoteEndpoint : Object, Endpoint {
  348. private SaddleService saddles = inject<SaddleService>();
  349. private ProjectService projects = inject<ProjectService>();
  350. public virtual async HttpResult handle_request(HttpContext http_context,
  351. RouteContext route_context) throws Error {
  352. if ((yield resolve_bearer(http_context, saddles)) == null) {
  353. return SaddleWire.json_error(401, "Missing or invalid saddle token");
  354. }
  355. Json.Object body;
  356. try {
  357. body = yield SaddleWire.read_json_body(http_context);
  358. } catch (Error e) {
  359. return SaddleWire.json_error(400, e.message);
  360. }
  361. string project_name = EntryJson.get_string_or(body, "project", "");
  362. string note_body = EntryJson.get_string_or(body, "body", "");
  363. int64 task_id = body.has_member("task_id")
  364. && body.get_member("task_id").get_value_type() == typeof(int64)
  365. ? body.get_int_member("task_id") : 0;
  366. if (note_body.strip().length == 0) {
  367. return SaddleWire.json_error(400, "A note body is required");
  368. }
  369. try {
  370. var project = yield projects.get_project_by_name(project_name);
  371. if (project == null) {
  372. return SaddleWire.json_error(400,
  373. @"No project exists named '$(project_name)'");
  374. }
  375. yield projects.add_note(((!)project).id, task_id, note_body);
  376. return SaddleWire.json_ok(new Json.Object());
  377. } catch (Error e) {
  378. return SaddleWire.json_error(400, e.message);
  379. }
  380. }
  381. }
  382. /**
  383. * The understanding callback pair (goal 24):
  384. *
  385. * `POST /api/saddle/understanding` — what an understanding task's
  386. * daemon POSTs after exploring a project (wire contract),
  387. * authenticated by the per-saddle bearer token like
  388. * {@link SaddleNoteEndpoint}:
  389. *
  390. * ```json
  391. * {"task_id": 7, "project": "ZL4GQ", "body": "…",
  392. * "commit_hash": "abc1234"}
  393. * ```
  394. *
  395. * `task_id` and `commit_hash` are optional (stored as the row's
  396. * source task and stamp); the project is resolved by name and
  397. * must exist, the body must be non-empty. The insert APPENDS a
  398. * history row — the project's current understanding is its
  399. * newest. Response `{"ok": true, "understanding_id": N}`.
  400. *
  401. * `GET /api/saddle/understanding?project=NAME` — reads the
  402. * project's CURRENT understanding back:
  403. * `{"ok": true, "understanding": {"body": "…", "commit_hash":
  404. * "…", "created": "…"} }`, or `"understanding": null` when the
  405. * project has none (the daemon's read_linked_understanding tool
  406. * consumes this for linked projects).
  407. */
  408. public class SaddleUnderstandingEndpoint : Object, Endpoint {
  409. private SaddleService saddles = inject<SaddleService>();
  410. private ProjectService projects = inject<ProjectService>();
  411. private UnderstandingService understandings = inject<UnderstandingService>();
  412. public virtual async HttpResult handle_request(HttpContext http_context,
  413. RouteContext route_context) throws Error {
  414. if ((yield resolve_bearer(http_context, saddles)) == null) {
  415. return SaddleWire.json_error(401, "Missing or invalid saddle token");
  416. }
  417. if (http_context.request.method == Method.GET) {
  418. return yield handle_read(http_context);
  419. }
  420. return yield handle_write(http_context);
  421. }
  422. /** The GET branch: the project's current understanding, or null. */
  423. private async HttpResult handle_read(HttpContext http_context) throws Error {
  424. string project_name = "";
  425. if (!http_context.request.query_params.try_get_any("project", out project_name)
  426. || project_name == null) {
  427. project_name = "";
  428. }
  429. project_name = project_name.strip();
  430. if (project_name.length == 0) {
  431. return SaddleWire.json_error(400,
  432. "A project query parameter is required");
  433. }
  434. try {
  435. var project = yield projects.get_project_by_name(project_name);
  436. if (project == null) {
  437. return SaddleWire.json_error(400,
  438. @"No project exists named '$(project_name)'");
  439. }
  440. var current = yield understandings.current_understanding(
  441. ((!)project).id);
  442. var body = new Json.Object();
  443. if (current != null) {
  444. var element = new Json.Object();
  445. element.set_string_member("body", ((!)current).body);
  446. element.set_string_member("commit_hash", ((!)current).commit_hash);
  447. if (((!)current).created_at != null) {
  448. element.set_string_member("created",
  449. (((!)current).created_at).to_timezone(new TimeZone.utc())
  450. .format("%Y-%m-%dT%H:%M:%SZ"));
  451. }
  452. body.set_object_member("understanding", element);
  453. } else {
  454. body.set_null_member("understanding");
  455. }
  456. return SaddleWire.json_ok(body);
  457. } catch (Error e) {
  458. return SaddleWire.json_error(400, e.message);
  459. }
  460. }
  461. /** The POST branch: append a history row. */
  462. private async HttpResult handle_write(HttpContext http_context) 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. string project_name = EntryJson.get_string_or(body, "project", "");
  470. string understanding_body = EntryJson.get_string_or(body, "body", "");
  471. string commit_hash = EntryJson.get_string_or(body, "commit_hash", "");
  472. int64 task_id = body.has_member("task_id")
  473. && body.get_member("task_id").get_value_type() == typeof(int64)
  474. ? body.get_int_member("task_id") : 0;
  475. if (understanding_body.strip().length == 0) {
  476. return SaddleWire.json_error(400, "An understanding body is required");
  477. }
  478. try {
  479. var project = yield projects.get_project_by_name(project_name);
  480. if (project == null) {
  481. return SaddleWire.json_error(400,
  482. @"No project exists named '$(project_name)'");
  483. }
  484. var row = yield understandings.insert(((!)project).id,
  485. understanding_body, commit_hash, task_id);
  486. var response = new Json.Object();
  487. response.set_int_member("understanding_id", row.id);
  488. return SaddleWire.json_ok(response);
  489. } catch (Error e) {
  490. return SaddleWire.json_error(400, e.message);
  491. }
  492. }
  493. }
  494. /**
  495. * The plan-12 task CLI callbacks (`task create/delete/enqueue`):
  496. * the goal agent's synchronous task operations against its own
  497. * goal. The goal agent's identity is its goal-run session — the
  498. * `session_id` in the body must resolve to a kind 'goal_run' task
  499. * hosted on the calling saddle; ownership then means "under that
  500. * goal". Errors surface to the agent verbatim (the CLI prints the
  501. * `error` member).
  502. */
  503. public class SaddleTaskCallbacks : Object {
  504. private SaddleService saddles;
  505. private TaskService tasks;
  506. private GoalService goals;
  507. private CapabilityService capabilities;
  508. private InvercargillSql.Orm.OrmSession db;
  509. public SaddleTaskCallbacks(SaddleService saddles, TaskService tasks,
  510. GoalService goals, CapabilityService capabilities,
  511. InvercargillSql.Orm.OrmSession db) {
  512. this.saddles = saddles;
  513. this.tasks = tasks;
  514. this.goals = goals;
  515. this.capabilities = capabilities;
  516. this.db = db;
  517. }
  518. /**
  519. * Resolves the caller's goal scope: the session must exist,
  520. * belong to the calling saddle, and be a goal-run session.
  521. * Returns the goal id, or an error naming the problem
  522. * verbatim.
  523. */
  524. private async int64 goal_scope_of(Saddle saddle, int64 session_id) throws Error {
  525. if (session_id <= 0) {
  526. throw new Error(Quark.from_string("clanker-saddle"), 80,
  527. "A session_id is required (the goal-run session identifies the goal agent)");
  528. }
  529. var session = yield SaddleChunksEndpoint.session_owned_by(db, session_id, saddle);
  530. if (session == null) {
  531. throw new Error(Quark.from_string("clanker-saddle"), 81,
  532. @"No session $(session_id) exists on this saddle");
  533. }
  534. var task = yield db.query<TaskEntity>()
  535. .where(Invercargill.Expressions.expr("id == $0",
  536. Invercargill.Expressions.elem<int64?>(((!)session).task_id)))
  537. .first_async();
  538. if (task == null || ((!)task).kind != TASK_KIND_GOAL_RUN
  539. || ((!)task).goal_id <= 0) {
  540. throw new Error(Quark.from_string("clanker-saddle"), 82,
  541. "task commands are only available to a goal agent (its goal-run session)");
  542. }
  543. var goal = yield goals.get_goal(((!)task).goal_id);
  544. if (goal == null) {
  545. throw new Error(Quark.from_string("clanker-saddle"), 83,
  546. @"The goal of session $(session_id) no longer exists");
  547. }
  548. return ((!)task).goal_id;
  549. }
  550. /**
  551. * `POST /api/saddle/task/create` — `task create` (heredoc
  552. * mandate + flags): creates a draft task under the goal with
  553. * the given mode and explicit capabilities. The goal agent
  554. * sequences its own tasks, so the row carries NO
  555. * auto-enqueue predecessor and lands in draft until the agent
  556. * enqueues it.
  557. *
  558. * ```json
  559. * {"session_id": 12, "title": "…", "mandate": "…",
  560. * "mode": "implement", "capabilities": ["gpu"]}
  561. * ```
  562. *
  563. * → `{"ok": true, "task_id": 7}`.
  564. */
  565. public async HttpResult create(HttpContext http_context, Saddle saddle) throws Error {
  566. Json.Object body;
  567. try {
  568. body = yield SaddleWire.read_json_body(http_context);
  569. } catch (Error e) {
  570. return SaddleWire.json_error(400, e.message);
  571. }
  572. try {
  573. int64 session_id = body.has_member("session_id")
  574. && body.get_member("session_id").get_value_type() == typeof(int64)
  575. ? body.get_int_member("session_id") : 0;
  576. int64 goal_id = yield goal_scope_of(saddle, session_id);
  577. string title = EntryJson.get_string_or(body, "title", "");
  578. string mandate = EntryJson.get_string_or(body, "mandate", "");
  579. if (title.length == 0) {
  580. return SaddleWire.json_error(400, "A title is required");
  581. }
  582. if (mandate.length == 0) {
  583. return SaddleWire.json_error(400,
  584. "A mandate is required (heredoc body of `task create`)");
  585. }
  586. string mode = EntryJson.get_string_or(body, "mode", "implement");
  587. if (!is_valid_mode(mode)) {
  588. return SaddleWire.json_error(400,
  589. @"Unknown mode '$(mode)' — valid modes: "
  590. + string.joinv(", ", TASK_MODES));
  591. }
  592. var goal = yield goals.get_goal(goal_id);
  593. string[] requested_tags = {};
  594. if (body.has_member("capabilities")) {
  595. var member = body.get_member("capabilities");
  596. if (member.get_node_type() != Json.NodeType.ARRAY) {
  597. return SaddleWire.json_error(400,
  598. "capabilities must be a JSON array of tag names");
  599. }
  600. foreach (var element in member.get_array().get_elements()) {
  601. if (element.get_node_type() == Json.NodeType.VALUE
  602. && element.get_string() != null) {
  603. requested_tags += element.get_string();
  604. }
  605. }
  606. }
  607. var resolved = new Vector<Capability>();
  608. if (requested_tags.length > 0) {
  609. var unknown = new Vector<string>();
  610. foreach (string tag in requested_tags) {
  611. string trimmed = tag.strip();
  612. if (trimmed.length == 0) {
  613. continue;
  614. }
  615. var capability = yield capabilities.find_by_name(trimmed);
  616. if (capability == null) {
  617. unknown.add(trimmed);
  618. } else {
  619. resolved.add((!)capability);
  620. }
  621. }
  622. if (unknown.length > 0) {
  623. var valid = new Vector<string>();
  624. foreach (var capability in yield capabilities.list_capabilities()) {
  625. valid.add(capability.name);
  626. }
  627. return SaddleWire.json_error(400,
  628. @"Unknown capability tag(s): $(string.joinv(", ", unknown.to_array()))"
  629. + @" — valid tags: $(string.joinv(", ", valid.to_array()))");
  630. }
  631. }
  632. var task = yield tasks.create_task(((!)goal).project_id, title, mandate,
  633. 5, null, null, STATUS_DRAFT,
  634. TASK_KIND_TASK, mode, CREATOR_GOAL, goal_id, 0);
  635. foreach (var capability in resolved) {
  636. yield capabilities.attach_to_task(capability.id, task.id);
  637. }
  638. var response = new Json.Object();
  639. response.set_int_member("task_id", task.id);
  640. return SaddleWire.json_ok(response);
  641. } catch (Error e) {
  642. return SaddleWire.json_error(400, e.message);
  643. }
  644. }
  645. /**
  646. * `POST /api/saddle/task/delete` — `task delete <id>`: removes
  647. * one of the goal's DRAFT tasks (ownership: the task must be
  648. * under the caller's goal). → `{"ok": true}`.
  649. */
  650. public async HttpResult delete(HttpContext http_context, Saddle saddle) throws Error {
  651. Json.Object body;
  652. try {
  653. body = yield SaddleWire.read_json_body(http_context);
  654. } catch (Error e) {
  655. return SaddleWire.json_error(400, e.message);
  656. }
  657. try {
  658. int64 session_id = body.has_member("session_id")
  659. && body.get_member("session_id").get_value_type() == typeof(int64)
  660. ? body.get_int_member("session_id") : 0;
  661. int64 goal_id = yield goal_scope_of(saddle, session_id);
  662. int64 task_id = body.has_member("task_id")
  663. && body.get_member("task_id").get_value_type() == typeof(int64)
  664. ? body.get_int_member("task_id") : 0;
  665. var detail = yield tasks.get_task(task_id);
  666. if (detail == null) {
  667. return SaddleWire.json_error(400,
  668. @"No task exists with id $(task_id)");
  669. }
  670. if (((!)detail).task.goal_id != goal_id) {
  671. return SaddleWire.json_error(400,
  672. @"Task $(task_id) does not belong to your goal — nothing was deleted; `saddle tasks` lists this goal's tasks");
  673. }
  674. if (((!)detail).task.status != STATUS_DRAFT) {
  675. return SaddleWire.json_error(400,
  676. "Only draft tasks can be deleted (cancel handles the rest)");
  677. }
  678. yield tasks.delete_task(task_id);
  679. return SaddleWire.json_ok(new Json.Object());
  680. } catch (Error e) {
  681. return SaddleWire.json_error(400, e.message);
  682. }
  683. }
  684. /**
  685. * `POST /api/saddle/task/enqueue` — `task enqueue <id>`: moves
  686. * one of the goal's DRAFT tasks into the queue. The
  687. * creator-only rule is honoured by construction: goal tasks
  688. * are created by 'goal', and only a goal agent may act as
  689. * 'goal'. → `{"ok": true}`.
  690. */
  691. public async HttpResult enqueue(HttpContext http_context, Saddle saddle) throws Error {
  692. Json.Object body;
  693. try {
  694. body = yield SaddleWire.read_json_body(http_context);
  695. } catch (Error e) {
  696. return SaddleWire.json_error(400, e.message);
  697. }
  698. try {
  699. int64 session_id = body.has_member("session_id")
  700. && body.get_member("session_id").get_value_type() == typeof(int64)
  701. ? body.get_int_member("session_id") : 0;
  702. int64 goal_id = yield goal_scope_of(saddle, session_id);
  703. int64 task_id = body.has_member("task_id")
  704. && body.get_member("task_id").get_value_type() == typeof(int64)
  705. ? body.get_int_member("task_id") : 0;
  706. var detail = yield tasks.get_task(task_id);
  707. if (detail == null) {
  708. return SaddleWire.json_error(400,
  709. @"No task exists with id $(task_id)");
  710. }
  711. if (((!)detail).task.goal_id != goal_id) {
  712. return SaddleWire.json_error(400,
  713. @"Task $(task_id) does not belong to your goal — nothing was enqueued; `saddle tasks` lists this goal's tasks");
  714. }
  715. yield tasks.enqueue_task(task_id, CREATOR_GOAL);
  716. return SaddleWire.json_ok(new Json.Object());
  717. } catch (Error e) {
  718. return SaddleWire.json_error(400, e.message);
  719. }
  720. }
  721. }
  722. /**
  723. * `POST /api/saddle/task/create` — the endpoint wrapper around
  724. * {@link SaddleTaskCallbacks.create} (per-saddle-token
  725. * authenticated).
  726. */
  727. public class SaddleTaskCreateEndpoint : Object, Endpoint {
  728. private SaddleService saddles = inject<SaddleService>();
  729. private TaskService tasks = inject<TaskService>();
  730. private GoalService goals = inject<GoalService>();
  731. private CapabilityService capabilities = inject<CapabilityService>();
  732. private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
  733. public virtual async HttpResult handle_request(HttpContext http_context,
  734. RouteContext route_context) throws Error {
  735. var saddle = yield resolve_bearer(http_context, saddles);
  736. if (saddle == null) {
  737. return SaddleWire.json_error(401, "Missing or invalid saddle token");
  738. }
  739. var callbacks = new SaddleTaskCallbacks(saddles, tasks, goals, capabilities, db);
  740. return yield callbacks.create(http_context, (!)saddle);
  741. }
  742. }
  743. /**
  744. * `POST /api/saddle/task/delete` — the endpoint wrapper around
  745. * {@link SaddleTaskCallbacks.delete}.
  746. */
  747. public class SaddleTaskDeleteEndpoint : Object, Endpoint {
  748. private SaddleService saddles = inject<SaddleService>();
  749. private TaskService tasks = inject<TaskService>();
  750. private GoalService goals = inject<GoalService>();
  751. private CapabilityService capabilities = inject<CapabilityService>();
  752. private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
  753. public virtual async HttpResult handle_request(HttpContext http_context,
  754. RouteContext route_context) throws Error {
  755. var saddle = yield resolve_bearer(http_context, saddles);
  756. if (saddle == null) {
  757. return SaddleWire.json_error(401, "Missing or invalid saddle token");
  758. }
  759. var callbacks = new SaddleTaskCallbacks(saddles, tasks, goals, capabilities, db);
  760. return yield callbacks.delete(http_context, (!)saddle);
  761. }
  762. }
  763. /**
  764. * `POST /api/saddle/task/enqueue` — the endpoint wrapper around
  765. * {@link SaddleTaskCallbacks.enqueue}.
  766. */
  767. public class SaddleTaskEnqueueEndpoint : Object, Endpoint {
  768. private SaddleService saddles = inject<SaddleService>();
  769. private TaskService tasks = inject<TaskService>();
  770. private GoalService goals = inject<GoalService>();
  771. private CapabilityService capabilities = inject<CapabilityService>();
  772. private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
  773. public virtual async HttpResult handle_request(HttpContext http_context,
  774. RouteContext route_context) throws Error {
  775. var saddle = yield resolve_bearer(http_context, saddles);
  776. if (saddle == null) {
  777. return SaddleWire.json_error(401, "Missing or invalid saddle token");
  778. }
  779. var callbacks = new SaddleTaskCallbacks(saddles, tasks, goals, capabilities, db);
  780. return yield callbacks.enqueue(http_context, (!)saddle);
  781. }
  782. }
  783. /**
  784. * `POST /api/saddle/next-task` (plan 10) — a saddle daemon asks for
  785. * work, authorised by its per-saddle bearer token. The allocation
  786. * is a single write transaction with two filters: workspace
  787. * affinity (a task parked/blocked/suspended on this saddle is
  788. * offered only to this saddle) and capability match (the saddle's
  789. * tags must cover the task's required tags). Concurrent asks race
  790. * to exactly one winner.
  791. *
  792. * - **200** + the full assignment payload (today's `/start` shape:
  793. * `task` — with pending block answers — `rules`,
  794. * `project_notes`, `callback`, `llm_config`, `git_identity`
  795. * from the saddle row, the plan-12 `kind`/`mode`/`creator`/
  796. * `goal`/`ephemeral_branch` context, `session_id`, and for
  797. * resumed workspaces `resume_notice`/`branch_advanced`).
  798. * - **204** — nothing is offerable; the daemon sleeps until woken.
  799. */
  800. public class SaddleNextTaskEndpoint : Object, Endpoint {
  801. private SaddleService saddles = inject<SaddleService>();
  802. private AllocationService allocation = inject<AllocationService>();
  803. private ProjectService projects = inject<ProjectService>();
  804. private TaskService tasks = inject<TaskService>();
  805. private SaddlePollState poll_state = inject<SaddlePollState>();
  806. private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
  807. private StatePulse pulse = inject<StatePulse>();
  808. public virtual async HttpResult handle_request(HttpContext http_context,
  809. RouteContext route_context) throws Error {
  810. var saddle = yield resolve_bearer(http_context, saddles);
  811. if (saddle == null) {
  812. return SaddleWire.json_error(401, "Missing or invalid saddle token");
  813. }
  814. // An ask from an offline saddle is proof of life: the daemon
  815. // recovered (its 15-minute safety self-ask, or it came back
  816. // after the probe/guard marked it offline) and no recovery
  817. // pass exists to flip the row — the ask itself does.
  818. if (((!)saddle).status == SADDLE_OFFLINE) {
  819. ((!)saddle).status = SADDLE_IDLE;
  820. yield db.update_async<Saddle>(((!)saddle));
  821. yield pulse.bump(0, "saddle asked (offline -> idle)");
  822. print(@"[ClankerSaddle] Saddle '$(((!)saddle).name)' asked while offline; marking idle\n");
  823. }
  824. // Crash recovery: an asking daemon is provably idle, so any
  825. // open session on an assigned/working task is a crash orphan
  826. // wedging the busy record. Requeue it — the very ask that
  827. // found it can win the task straight back.
  828. var recovered = yield tasks.requeue_interrupted_sessions((!)saddle);
  829. if (recovered > 0) {
  830. print(@"[ClankerSaddle] Recovered $(recovered) interrupted session(s) on saddle '$(((!)saddle).name)'\n");
  831. yield pulse.bump(0, "crash-orphaned session(s) requeued");
  832. }
  833. // The ask's arrival answers any pending wake (the lost-wake
  834. // guard must not fire for a saddle that just asked); the
  835. // outcome is recorded once the allocation resolves.
  836. poll_state.mark_ask(((!)saddle).id);
  837. Json.Object? assignment = null;
  838. string error_text = "";
  839. try {
  840. assignment = yield allocation.allocate_next(saddles, projects, (!)saddle);
  841. } catch (Error e) {
  842. error_text = e.message;
  843. }
  844. poll_state.mark_ask_result(((!)saddle).id, assignment != null);
  845. if (error_text.length > 0) {
  846. return SaddleWire.json_error(500,
  847. @"Allocation failed: $(error_text)");
  848. }
  849. if (assignment == null) {
  850. var empty = new HttpDataResult(new ByteBuffer.from_byte_array(new uint8[0]),
  851. (StatusCode) 204);
  852. return empty;
  853. }
  854. var generator = new Json.Generator();
  855. generator.set_root(new Json.Node(Json.NodeType.OBJECT).init_object(assignment));
  856. var result = new HttpDataResult(new ByteBuffer.from_byte_array(generator.to_data(null).data));
  857. result.set_header("Content-Type", "application/json");
  858. return result;
  859. }
  860. }
  861. /**
  862. * `POST /api/saddle/chunks` (plan 10 phase 2; plan 17 §7.2) — the
  863. * daemon's event push channel, authorised by the pushing saddle's
  864. * bearer token:
  865. *
  866. * ```json
  867. * {"task_id": 20, "session_id": 34,
  868. * "events": [ { "seq": 61, "kind": "tool_call", "actor": "model",
  869. * "ts": "…ISO8601 UTC…",
  870. * "payload": { "turn": 12, "tool": "run_command",
  871. * "args": { "command": "…" } } } ]}
  872. * ```
  873. *
  874. * `seq` is monotonic per session across all kinds; the batch stores
  875. * idempotently on the events ledger's UNIQUE(session_id, seq) — a
  876. * redelivered seq is consumed, not an error. Kinds: turn_text,
  877. * tool_call, tool_result, progress, lifecycle, usage, state_change;
  878. * actors model|harness; the daemon's `ts` is authoritative. Any
  879. * push implies the daemon is alive (the staleness clock resets).
  880. * Response `{"ok": true, "stored": <newly stored count>}`.
  881. */
  882. public class SaddleChunksEndpoint : Object, Endpoint {
  883. private SaddleService saddles = inject<SaddleService>();
  884. private TaskService tasks = inject<TaskService>();
  885. private SaddlePollState poll_state = inject<SaddlePollState>();
  886. private EventService events = inject<EventService>();
  887. private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
  888. public virtual async HttpResult handle_request(HttpContext http_context,
  889. RouteContext route_context) throws Error {
  890. var saddle = yield resolve_bearer(http_context, saddles);
  891. if (saddle == null) {
  892. return SaddleWire.json_error(401, "Missing or invalid saddle token");
  893. }
  894. Json.Object body;
  895. try {
  896. body = yield SaddleWire.read_json_body(http_context);
  897. } catch (Error e) {
  898. return SaddleWire.json_error(400, e.message);
  899. }
  900. int64 session_id = body.has_member("session_id")
  901. && body.get_member("session_id").get_value_type() == typeof(int64)
  902. ? body.get_int_member("session_id") : 0;
  903. var session = yield session_owned_by(db, session_id, (!)saddle);
  904. if (session == null) {
  905. return SaddleWire.json_error(400,
  906. @"No session $(session_id) exists on saddle $(((!)saddle).id)");
  907. }
  908. if (!(body.has_member("events")
  909. && body.get_member("events").get_node_type() == Json.NodeType.ARRAY)) {
  910. return SaddleWire.json_error(400,
  911. "An events array is required");
  912. }
  913. poll_state.note_push(session_id);
  914. try {
  915. var fresh = yield events.store_pushed_events(session_id,
  916. ((!)session).task_id, body.get_array_member("events"));
  917. yield adopt_agent_comments((!)session, fresh);
  918. var response = new Json.Object();
  919. response.set_int_member("stored", (int64) fresh.count());
  920. return SaddleWire.json_ok(response);
  921. } catch (Error e) {
  922. return SaddleWire.json_error(400, e.message);
  923. }
  924. }
  925. /**
  926. * The plan-17 ledger carries agent remarks as comment events;
  927. * the comment SECTIONS (task and goal pages) read the
  928. * task_comments table. Every newly stored model-authored
  929. * comment event materialises its row here — exactly once,
  930. * because only fresh (session, seq) pairs leave the store.
  931. */
  932. private async void adopt_agent_comments(ClankerSaddle.Data.Session session,
  933. Invercargill.DataStructures.Vector<ClankerSaddle.Data.EventRecord> fresh) {
  934. foreach (var record in fresh) {
  935. if (record.kind != EVENT_COMMENT
  936. || record.actor != EVENT_ACTOR_MODEL) {
  937. continue;
  938. }
  939. string? body = null;
  940. try {
  941. var node = Json.from_string(record.payload);
  942. if (node != null && ((!)node).get_object() != null) {
  943. var payload = ((!)node).get_object();
  944. if (payload.has_member("body")
  945. && payload.get_member("body").get_value_type() == typeof(string)) {
  946. body = payload.get_string_member("body");
  947. }
  948. }
  949. } catch (Error parse_error) {
  950. body = null;
  951. }
  952. if (body == null || ((!)body).strip().length == 0) {
  953. continue;
  954. }
  955. try {
  956. yield tasks.adopt_agent_comment(session.task_id, (!)body);
  957. } catch (Error comment_error) {
  958. printerr("[ClankerSaddle] Agent comment materialisation failed: %s\n",
  959. comment_error.message);
  960. }
  961. }
  962. }
  963. internal static async ClankerSaddle.Data.Session? session_owned_by(
  964. InvercargillSql.Orm.OrmSession db, int64 session_id, Saddle saddle) throws Error {
  965. if (session_id <= 0) {
  966. return null;
  967. }
  968. var session = yield db.query<ClankerSaddle.Data.Session>()
  969. .where(Invercargill.Expressions.expr("id == $0",
  970. Invercargill.Expressions.elem<int64?>(session_id)))
  971. .first_async();
  972. if (session == null || ((!)session).saddle_id != saddle.id) {
  973. return null;
  974. }
  975. return session;
  976. }
  977. }
  978. /**
  979. * `POST /api/saddle/results` (plan 10 phase 2; plan 17 §7.2 — the
  980. * state transitions are unchanged, the entries array is gone)
  981. * — the round-results push channel, authorised by the pushing
  982. * saddle's bearer token:
  983. *
  984. * ```json
  985. * {"session_id": 12, "state": "blocked",
  986. * "block": {"question": "…", "type": "question", "options": […],
  987. * "body": "…"},
  988. * "summary": "…"}
  989. * ```
  990. *
  991. * The state transitions drive the same bookkeeping the /status
  992. * poll switch ran: `blocked` raises the block payload as an
  993. * answerable `task_blocks` row (the pulse bump makes the question
  994. * appear on live pages) and parks the saddle; `suspended` parks
  995. * for the task-terminal-event resume; `done`/`failed` complete or
  996. * fail the task with the pushed summary; `working` flips the task
  997. * to working. Round entries travel as ledger events on the chunks
  998. * channel now — an `entries` member is consumed silently (the
  999. * daemon workstream stops sending it).
  1000. */
  1001. public class SaddleResultsEndpoint : Object, Endpoint {
  1002. private SaddleService saddles = inject<SaddleService>();
  1003. private TaskService tasks = inject<TaskService>();
  1004. private SaddlePollState poll_state = inject<SaddlePollState>();
  1005. private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
  1006. public virtual async HttpResult handle_request(HttpContext http_context,
  1007. RouteContext route_context) throws Error {
  1008. var saddle = yield resolve_bearer(http_context, saddles);
  1009. if (saddle == null) {
  1010. return SaddleWire.json_error(401, "Missing or invalid saddle token");
  1011. }
  1012. Json.Object body;
  1013. try {
  1014. body = yield SaddleWire.read_json_body(http_context);
  1015. } catch (Error e) {
  1016. return SaddleWire.json_error(400, e.message);
  1017. }
  1018. int64 session_id = body.has_member("session_id")
  1019. && body.get_member("session_id").get_value_type() == typeof(int64)
  1020. ? body.get_int_member("session_id") : 0;
  1021. var session = yield SaddleChunksEndpoint.session_owned_by(db, session_id, (!)saddle);
  1022. if (session == null) {
  1023. return SaddleWire.json_error(400,
  1024. @"No session $(session_id) exists on saddle $(((!)saddle).id)");
  1025. }
  1026. string state = EntryJson.get_string_or(body, "state", "");
  1027. if (state.length == 0) {
  1028. return SaddleWire.json_error(400, "A state is required");
  1029. }
  1030. Json.Object? block = body.has_member("block")
  1031. && body.get_member("block").get_node_type() == Json.NodeType.OBJECT
  1032. ? body.get_object_member("block") : null;
  1033. string? summary = EntryJson.get_string_or(body, "summary", null);
  1034. poll_state.note_push(session_id);
  1035. try {
  1036. yield saddles.apply_result_state((!)session, state, block,
  1037. summary, tasks);
  1038. return SaddleWire.json_ok(new Json.Object());
  1039. } catch (Error e) {
  1040. return SaddleWire.json_error(400, e.message);
  1041. }
  1042. }
  1043. }
  1044. /**
  1045. * The request's bearer token: the `Authorization` header minus its
  1046. * `Bearer ` prefix, or "" when the header is missing or shaped
  1047. * differently.
  1048. */
  1049. private static string bearer_token(HttpContext http_context) {
  1050. string? header = http_context.request.get_header("Authorization");
  1051. if (header == null || !((!)header).has_prefix("Bearer ")) {
  1052. return "";
  1053. }
  1054. return ((!)header).substring("Bearer ".length).strip();
  1055. }
  1056. /**
  1057. * The ONE bearer resolution for the `/api/saddle/*` wire (plan 16
  1058. * R2, arch item 26): the `Authorization: Bearer` credential
  1059. * resolved to its saddle — null fails the call. The {id}-anchored
  1060. * poll-era routes (update, poll) died with arch 19, so every
  1061. * remaining route is id-less and accepts whichever registered
  1062. * saddle owns the token ({@link SaddleService.saddle_for_token};
  1063. * removing the saddle revokes it).
  1064. */
  1065. private static async Saddle? resolve_bearer(HttpContext http_context,
  1066. SaddleService saddles) throws Error {
  1067. return yield saddles.saddle_for_token(bearer_token(http_context));
  1068. }
  1069. }