TaskService.vala 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Invercargill.Expressions;
  4. using InvercargillSql.Orm;
  5. using Inversion;
  6. using ClankerSaddle.Data;
  7. namespace ClankerSaddle.Services {
  8. /** Valid task statuses (the `tasks.status` wire values). */
  9. public const string STATUS_QUEUED = "queued";
  10. public const string STATUS_ASSIGNED = "assigned";
  11. public const string STATUS_WORKING = "working";
  12. public const string STATUS_BLOCKED = "blocked";
  13. public const string STATUS_REVIEW_REQUESTED = "review_requested";
  14. public const string STATUS_SUSPENDED = "suspended";
  15. public const string STATUS_DONE = "done";
  16. public const string STATUS_FAILED = "failed";
  17. /** Valid {@link TaskBlock.block_type} values. */
  18. public const string BLOCK_QUESTION = "question";
  19. public const string BLOCK_RFC = "rfc";
  20. public const string BLOCK_REVIEW = "review";
  21. /**
  22. * A task's computed presentation state (stored status + open
  23. * subtasks + unanswered blocks): `review requested` while a review
  24. * block is unanswered (even when the stored status is terminal —
  25. * the post-mortem's `done`-with-open-review case), `delegating x/y`
  26. * while non-terminal subtasks exist, `waiting on subtasks` for the
  27. * parked suspended state, otherwise the stored status. Pure
  28. * presentation — the stored statuses and the dispatcher are
  29. * untouched.
  30. */
  31. public class TaskDisplay : Object {
  32. public string status { get; set; default = ""; }
  33. public bool terminal { get; set; }
  34. public bool failed { get; set; }
  35. public int64 subtask_total { get; set; }
  36. public int64 subtask_open { get; set; }
  37. public static TaskDisplay compute(TaskEntity task, bool review_pending,
  38. int64 subtask_total, int64 subtask_open) {
  39. var view = new TaskDisplay() {
  40. terminal = task.status == STATUS_DONE || task.status == STATUS_FAILED,
  41. failed = task.status == STATUS_FAILED,
  42. subtask_total = subtask_total,
  43. subtask_open = subtask_open
  44. };
  45. if (review_pending) {
  46. view.status = "review requested";
  47. } else if (subtask_open > 0) {
  48. view.status = @"delegating $(subtask_total - subtask_open)/$(subtask_total)";
  49. } else if (task.status == STATUS_SUSPENDED) {
  50. view.status = "waiting on subtasks";
  51. } else {
  52. view.status = task.status;
  53. }
  54. return view;
  55. }
  56. }
  57. /**
  58. * The full task aggregate loaded by {@link TaskService.get_task}: the
  59. * task itself plus its comments, blocks, progress history, child
  60. * tasks, dependencies and session history.
  61. */
  62. public class TaskDetail : Object {
  63. public TaskEntity task { get; set; }
  64. public ImmutableLot<TaskComment> comments { get; set; }
  65. public ImmutableLot<TaskBlock> blocks { get; set; }
  66. public ImmutableLot<TaskProgress> progress { get; set; }
  67. public ImmutableLot<TaskEntity> subtasks { get; set; }
  68. public ImmutableLot<TaskDependency> dependencies { get; set; }
  69. public ImmutableLot<Session> sessions { get; set; }
  70. }
  71. /**
  72. * Task storage and lifecycle: creation, dependency-aware readiness,
  73. * agent interaction primitives (comments, blocks, plans, progress,
  74. * reviews, subtasks) and completion. Saddle bookkeeping rows are kept
  75. * consistent on every terminal transition.
  76. */
  77. public class TaskService : Object {
  78. private OrmSession db = inject<OrmSession>();
  79. private StatePulse pulse = inject<StatePulse>();
  80. /**
  81. * Inserts a task (status 'queued'). `parent_task_id`,
  82. * `description`, `task_branch` and `branch_from` may be null;
  83. * `branch_from` prefills from the project's default branch and
  84. * `task_branch` is inherited from the parent task when unset;
  85. * priority defaults to 5 (1 is highest).
  86. */
  87. public async TaskEntity create_task(int64 project_id, string title, string? description,
  88. int64 priority, bool is_design_task, int64? parent_task_id,
  89. string? task_branch = null, string? branch_from = null) throws Error {
  90. string resolved_branch_from = branch_from ?? "";
  91. if (resolved_branch_from.length == 0) {
  92. var project = yield db.query<Project>()
  93. .where(expr("id == $0", elem<int64?>(project_id)))
  94. .first_async();
  95. if (project != null) {
  96. resolved_branch_from = ((!)project).default_branch;
  97. }
  98. }
  99. string? resolved_task_branch = task_branch != null && task_branch.length > 0 ? task_branch : null;
  100. if (resolved_task_branch == null && parent_task_id != null) {
  101. var parent = yield db.query<TaskEntity>()
  102. .where(expr("id == $0", elem<int64?>(parent_task_id)))
  103. .first_async();
  104. if (parent != null && ((!)parent).task_branch != null) {
  105. resolved_task_branch = ((!)parent).task_branch;
  106. }
  107. }
  108. var task = new TaskEntity() {
  109. parent_task_id = parent_task_id != null ? ((!)parent_task_id) : 0,
  110. project_id = project_id,
  111. title = title,
  112. description = description != null && description.length > 0 ? description : null,
  113. priority = priority.clamp(1, 9),
  114. is_design_task = is_design_task,
  115. status = STATUS_QUEUED,
  116. task_branch = resolved_task_branch,
  117. branch_from = resolved_branch_from.length > 0 ? resolved_branch_from : null,
  118. created_at = new DateTime.now_local()
  119. };
  120. yield db.insert_async<TaskEntity>(task);
  121. yield pulse.bump(task.id, "task created");
  122. return task;
  123. }
  124. /**
  125. * Loads the full aggregate for `id`: the task, its comments, blocks,
  126. * progress, child tasks, dependencies and sessions. Returns null
  127. * when the task does not exist.
  128. */
  129. public async TaskDetail? get_task(int64 id) throws Error {
  130. var task = yield db.query<TaskEntity>()
  131. .where(expr("id == $0", elem<int64?>(id)))
  132. .first_async();
  133. if (task == null) {
  134. return null;
  135. }
  136. return new TaskDetail() {
  137. task = (!)task,
  138. comments = yield list_comments(id),
  139. blocks = yield list_blocks(id),
  140. progress = yield list_progress(id),
  141. subtasks = yield db.query<TaskEntity>()
  142. .where(expr("parent_task_id == $0", elem<int64?>(id)))
  143. .order_by(expr("id"))
  144. .materialise_async(),
  145. dependencies = yield db.query<TaskDependency>()
  146. .where(expr("task_id == $0", elem<int64?>(id)))
  147. .materialise_async(),
  148. sessions = yield db.query<Session>()
  149. .where(expr("task_id == $0", elem<int64?>(id)))
  150. .order_by(expr("id"))
  151. .materialise_async()
  152. };
  153. }
  154. /**
  155. * Lists tasks, optionally filtered by status and/or project, ordered
  156. * by priority (1 first) then creation time.
  157. */
  158. /**
  159. * Computed display states for a batch of tasks (all lists share
  160. * this), keyed by task id string. Unanswered review blocks and
  161. * child-task states each load once, not per task.
  162. */
  163. public async Dictionary<string, TaskDisplay> display_map_for(
  164. Lot<TaskEntity> tasks) throws Error {
  165. var wanted = new Invercargill.DataStructures.HashSet<string>();
  166. foreach (var task in tasks) {
  167. wanted.add(task.id.to_string());
  168. }
  169. var review_pending = new Invercargill.DataStructures.HashSet<string>();
  170. foreach (var block in yield db.query<TaskBlock>()
  171. .where(expr("type == $0", elem(BLOCK_REVIEW)))
  172. .materialise_async()) {
  173. if (block.answered_at == null && wanted.has(block.task_id.to_string())) {
  174. review_pending.add(block.task_id.to_string());
  175. }
  176. }
  177. // Subtask counters per parent (stringly keyed: the Invercargill
  178. // collections cannot key on value types).
  179. var open_by_parent = new Dictionary<string, string>();
  180. var total_by_parent = new Dictionary<string, string>();
  181. foreach (var child in yield db.query<TaskEntity>()
  182. .where(expr("parent_task_id > 0"))
  183. .materialise_async()) {
  184. string parent = child.parent_task_id.to_string();
  185. if (!wanted.has(parent)) {
  186. continue;
  187. }
  188. total_by_parent.set(parent,
  189. (int64.parse(total_by_parent.get_or_default(parent) ?? "0") + 1).to_string());
  190. if (child.status != STATUS_DONE && child.status != STATUS_FAILED) {
  191. open_by_parent.set(parent,
  192. (int64.parse(open_by_parent.get_or_default(parent) ?? "0") + 1).to_string());
  193. }
  194. }
  195. var map = new Dictionary<string, TaskDisplay>();
  196. foreach (var task in tasks) {
  197. string key = task.id.to_string();
  198. map.set(key, TaskDisplay.compute(task,
  199. review_pending.has(key),
  200. int64.parse(total_by_parent.get_or_default(key) ?? "0"),
  201. int64.parse(open_by_parent.get_or_default(key) ?? "0")));
  202. }
  203. return map;
  204. }
  205. /**
  206. * The display state of a single task — the detail page's path,
  207. * computed from its already-loaded aggregate (no extra queries).
  208. */
  209. public static TaskDisplay display_of_detail(TaskDetail detail) {
  210. bool review_pending = false;
  211. int64 subtask_total = 0;
  212. int64 subtask_open = 0;
  213. foreach (var block in detail.blocks) {
  214. if (block.block_type == BLOCK_REVIEW && block.answered_at == null) {
  215. review_pending = true;
  216. }
  217. }
  218. foreach (var child in detail.subtasks) {
  219. subtask_total++;
  220. if (child.status != STATUS_DONE && child.status != STATUS_FAILED) {
  221. subtask_open++;
  222. }
  223. }
  224. return TaskDisplay.compute(detail.task, review_pending, subtask_total, subtask_open);
  225. }
  226. public async ImmutableLot<TaskEntity> list_tasks(string? status, int64? project_id) throws Error {
  227. var tasks = yield db.query<TaskEntity>().materialise_async();
  228. var rows = new Vector<TaskEntity>();
  229. foreach (var task in tasks) {
  230. if (status != null && status.length > 0 && task.status != status) {
  231. continue;
  232. }
  233. if (project_id != null && task.project_id != project_id) {
  234. continue;
  235. }
  236. rows.add(task);
  237. }
  238. var sorted = rows.sort((a, b) => {
  239. if (a.priority != b.priority) {
  240. return (int)(a.priority - b.priority);
  241. }
  242. int64 a_created = a.created_at != null ? ((!)a.created_at).to_unix() : 0;
  243. int64 b_created = b.created_at != null ? ((!)b.created_at).to_unix() : 0;
  244. return (int)(a_created - b_created);
  245. });
  246. return sorted.to_immutable_buffer();
  247. }
  248. /**
  249. * Saves the editable fields of the task identified by `id`; null
  250. * arguments keep their current values. Returns null when the task
  251. * does not exist. The pulse only fires when a value actually
  252. * moved, so periodic status refreshes do not spam live pages.
  253. */ public async TaskEntity? update_task(int64 id, string? title, string? description,
  254. int64? priority, string? status) throws Error {
  255. var task = yield db.query<TaskEntity>()
  256. .where(expr("id == $0", elem<int64?>(id)))
  257. .first_async();
  258. if (task == null) {
  259. return null;
  260. }
  261. bool changed = false;
  262. if (title != null && title.length > 0 && ((!)task).title != title) {
  263. ((!)task).title = title;
  264. changed = true;
  265. }
  266. if (description != null) {
  267. string? next_description = description.length > 0 ? description : null;
  268. if (((!)task).description != next_description) {
  269. ((!)task).description = next_description;
  270. changed = true;
  271. }
  272. }
  273. if (priority != null) {
  274. int64 clamped = ((!)priority).clamp(1, 9);
  275. if (((!)task).priority != clamped) {
  276. ((!)task).priority = clamped;
  277. changed = true;
  278. }
  279. }
  280. if (status != null && status.length > 0 && ((!)task).status != status) {
  281. ((!)task).status = status;
  282. if (status == STATUS_DONE && ((!)task).completed_at == null) {
  283. ((!)task).completed_at = new DateTime.now_local();
  284. }
  285. changed = true;
  286. }
  287. yield db.update_async<TaskEntity>(task);
  288. if (changed) {
  289. yield pulse.bump(id, @"task $(status ?? "saved")");
  290. }
  291. return task;
  292. }
  293. /**
  294. * Saves the task's markdown plan (`saddle plan` or the web UI's
  295. * plan editor). An empty markdown clears the plan. Returns null
  296. * when the task does not exist.
  297. */
  298. public async TaskEntity? update_plan(int64 id, string markdown) throws Error {
  299. var task = yield db.query<TaskEntity>()
  300. .where(expr("id == $0", elem<int64?>(id)))
  301. .first_async();
  302. if (task == null) {
  303. return null;
  304. }
  305. ((!)task).plan = markdown.strip().length > 0 ? markdown.strip() : null;
  306. yield db.update_async<TaskEntity>((!)task);
  307. yield pulse.bump(id, "plan updated");
  308. return task;
  309. }
  310. /**
  311. * Appends a progress line (`saddle progress-update`) to the task's
  312. * history and returns the new row.
  313. */
  314. public async TaskProgress add_progress(int64 task_id, string summary) throws Error {
  315. var row = new TaskProgress() {
  316. task_id = task_id,
  317. summary = summary,
  318. created_at = new DateTime.now_local()
  319. };
  320. yield db.insert_async<TaskProgress>(row);
  321. yield pulse.bump(task_id, "progress");
  322. return row;
  323. }
  324. /** The task's progress history, newest first. */
  325. public async ImmutableLot<TaskProgress> list_progress(int64 task_id) throws Error {
  326. return yield db.query<TaskProgress>()
  327. .where(expr("task_id == $0", elem<int64?>(task_id)))
  328. .order_by_desc(expr("id"))
  329. .materialise_async();
  330. }
  331. /**
  332. * Saves the task's branch fields: `task_branch` is the branch the
  333. * agent works on, `branch_from` the base it is cut from. Null
  334. * arguments keep their current values. Returns null when the task
  335. * does not exist.
  336. */
  337. public async TaskEntity? set_branch_fields(int64 id, string? task_branch,
  338. string? branch_from) throws Error {
  339. var task = yield db.query<TaskEntity>()
  340. .where(expr("id == $0", elem<int64?>(id)))
  341. .first_async();
  342. if (task == null) {
  343. return null;
  344. }
  345. if (task_branch != null && task_branch.length > 0) {
  346. ((!)task).task_branch = task_branch;
  347. }
  348. if (branch_from != null && branch_from.length > 0) {
  349. ((!)task).branch_from = branch_from;
  350. }
  351. yield db.update_async<TaskEntity>((!)task);
  352. yield pulse.bump(id, "branch fields saved");
  353. return task;
  354. }
  355. /** Appends a comment; `author` is 'bot' or 'user:<name>'. */
  356. public async TaskComment add_comment(int64 task_id, string author, string body,
  357. string? quote = null) throws Error {
  358. var comment = new TaskComment() {
  359. task_id = task_id,
  360. author = author,
  361. body = body,
  362. quote = quote != null && quote.strip().length > 0 ? quote.strip() : null,
  363. created_at = new DateTime.now_local()
  364. };
  365. yield db.insert_async<TaskComment>(comment);
  366. yield pulse.bump(task_id, "comment");
  367. return comment;
  368. }
  369. /**
  370. * Appends a terminal entry to the task's open session so
  371. * conversation-level events (blocks, plans, progress, reviews,
  372. * human comments) appear in the streamed log. A no-op when the
  373. * task has no open session.
  374. */
  375. public async void log_session_entry(int64 task_id, string entry_type,
  376. string command, string? output) throws Error {
  377. var session = yield open_session_for_task(task_id);
  378. if (session == null) {
  379. return;
  380. }
  381. var entry = new SessionCommand() {
  382. session_id = ((!)session).id,
  383. command_text = command,
  384. output_text = output,
  385. entry_type = entry_type,
  386. started_at = new DateTime.now_local()
  387. };
  388. yield db.insert_async<SessionCommand>(entry);
  389. }
  390. /**
  391. * Raises a blocking prompt on a task — `question` (optionally with
  392. * multiple-choice `options` and a markdown `body`), `rfc` or
  393. * `review` — and flips it to 'blocked' (or 'review_requested')
  394. * while it waits for a human answer. Returns the new block.
  395. */
  396. public async TaskBlock add_block(int64 task_id, string question, string block_type = BLOCK_QUESTION,
  397. string[]? options = null, string? body = null) throws Error {
  398. string options_json = null;
  399. if (options != null && options.length > 0) {
  400. var array = new Json.Array();
  401. foreach (var option in options) {
  402. array.add_string_element(option);
  403. }
  404. options_json = Json.to_string(new Json.Node(Json.NodeType.ARRAY).init_array(array), false);
  405. }
  406. var block = new TaskBlock() {
  407. task_id = task_id,
  408. question = question,
  409. block_type = block_type.length > 0 ? block_type : BLOCK_QUESTION,
  410. options = options_json,
  411. body = body != null && body.length > 0 ? body : null,
  412. created_at = new DateTime.now_local()
  413. };
  414. yield db.insert_async<TaskBlock>(block);
  415. var task = yield db.query<TaskEntity>()
  416. .where(expr("id == $0", elem<int64?>(task_id)))
  417. .first_async();
  418. if (task != null && (((!)task).status == STATUS_ASSIGNED || ((!)task).status == STATUS_WORKING
  419. || ((!)task).status == STATUS_BLOCKED || ((!)task).status == STATUS_REVIEW_REQUESTED)) {
  420. ((!)task).status = block.block_type == BLOCK_REVIEW
  421. ? STATUS_REVIEW_REQUESTED : STATUS_BLOCKED;
  422. yield db.update_async<TaskEntity>((!)task);
  423. }
  424. yield pulse.bump(task_id, "block raised");
  425. return block;
  426. }
  427. /**
  428. * Records a review request (`saddle review`): a review-type block
  429. * carrying the summary markdown, with the task flipped to
  430. * 'review_requested' while it waits for the human verdict.
  431. */
  432. public async TaskBlock request_review(int64 task_id, string summary) throws Error {
  433. return yield add_block(task_id, "Review requested", BLOCK_REVIEW, null,
  434. summary.length > 0 ? summary : null);
  435. }
  436. /**
  437. * Records a human's answer to a block. With an open session the
  438. * task stays 'blocked'/'review_requested' — the dispatcher resumes
  439. * the paused workspace and hands the answer over; without one the
  440. * task flips straight back to 'working'.
  441. */
  442. public async void answer_block(int64 block_id, string answer) throws Error {
  443. var block = yield db.query<TaskBlock>()
  444. .where(expr("id == $0", elem<int64?>(block_id)))
  445. .first_async();
  446. if (block == null) {
  447. throw new Error(Quark.from_string("clanker-saddle"), 5,
  448. @"No block exists with id $(block_id)");
  449. }
  450. ((!)block).answer = answer;
  451. ((!)block).answered_at = new DateTime.now_local();
  452. yield db.update_async<TaskBlock>((!)block);
  453. var task = yield db.query<TaskEntity>()
  454. .where(expr("id == $0", elem<int64?>(((!)block).task_id)))
  455. .first_async();
  456. if (task != null && (((!)task).status == STATUS_BLOCKED
  457. || ((!)task).status == STATUS_REVIEW_REQUESTED)) {
  458. var session = yield open_session_for_task(((!)block).task_id);
  459. if (session == null) {
  460. ((!)task).status = STATUS_WORKING;
  461. yield db.update_async<TaskEntity>((!)task);
  462. }
  463. }
  464. yield pulse.bump(((!)block).task_id, "block answered");
  465. }
  466. /**
  467. * The git-host compare URL for the task's branches —
  468. * `{git_url}/compare/{branch_from}...{task_branch}` — or "" when
  469. * the task has no working branch.
  470. */
  471. public async string diff_url_for_task(int64 task_id) throws Error {
  472. var task = yield db.query<TaskEntity>()
  473. .where(expr("id == $0", elem<int64?>(task_id)))
  474. .first_async();
  475. if (task == null || ((!)task).task_branch == null
  476. || ((!)task).task_branch.length == 0) {
  477. return "";
  478. }
  479. var project = yield db.query<Project>()
  480. .where(expr("id == $0", elem<int64?>(((!)task).project_id)))
  481. .first_async();
  482. if (project == null) {
  483. return "";
  484. }
  485. string base_branch = ((!)task).branch_from ?? ((!)project).default_branch;
  486. return @"$(https_git_url(((!)project).git_url))/compare/$(base_branch)...$(((!)task).task_branch)";
  487. }
  488. /**
  489. * Translates a project's git remote into its browsable HTTPS form
  490. * so compare links open in the web UI instead of an SSH checkout:
  491. * `ssh://git@host:port/owner/repo.git` and `git@host:owner/repo(.git)`
  492. * both become `https://host/owner/repo` (the port is dropped —
  493. * SSH-only ports like fabrica.unitatem.net:222 have no HTTPS
  494. * meaning). Anything already http(s) (or unrecognised) is kept
  495. * as-is, minus a trailing `.git`.
  496. */
  497. public static string https_git_url(string git_url) {
  498. string url = git_url.strip();
  499. if (url.has_prefix("http://") || url.has_prefix("https://")) {
  500. return url.has_suffix(".git") ? url.substring(0, url.length - 4) : url;
  501. }
  502. // ssh://git@host[:port]/owner/repo.git
  503. if (url.has_prefix("ssh://")) {
  504. string rest = url.substring("ssh://".length);
  505. if (rest.has_prefix("git@")) {
  506. rest = rest.substring("git@".length);
  507. }
  508. int slash = rest.index_of("/");
  509. if (slash < 0) {
  510. return url;
  511. }
  512. string host_port = rest.substring(0, slash);
  513. string path = rest.substring(slash);
  514. if (host_port.contains(":")) {
  515. // SSH-only ports (fabrica.unitatem.net:222) carry no
  516. // HTTPS meaning — the browse host is plain.
  517. host_port = host_port.split(":")[0];
  518. }
  519. if (path.has_suffix(".git")) {
  520. path = path.substring(0, path.length - 4);
  521. }
  522. return @"https://$(host_port)$(path)";
  523. }
  524. // git@host:owner/repo(.git) — scp-style (the colon sits before
  525. // any path slash)
  526. int at = url.index_of("@");
  527. int colon = url.index_of(":");
  528. int slash = url.index_of("/");
  529. if (at >= 0 && colon > at && (slash < 0 || colon < slash)) {
  530. string host = url.substring(at + 1, colon - at - 1);
  531. string path = url.substring(colon + 1);
  532. if (path.has_suffix(".git")) {
  533. path = path.substring(0, path.length - 4);
  534. }
  535. return @"https://$(host)/$(path)";
  536. }
  537. return url;
  538. }
  539. /** Records that `task_id` must not start until `depends_on_task_id` is done. */
  540. public async void add_dependency(int64 task_id, int64 depends_on_task_id) throws Error {
  541. if (task_id == depends_on_task_id) {
  542. throw new Error(Quark.from_string("clanker-saddle"), 6,
  543. "A task cannot depend on itself");
  544. }
  545. var existing = yield db.query<TaskDependency>()
  546. .where(expr("task_id == $0 && depends_on_task_id == $1",
  547. elem<int64?>(task_id), elem<int64?>(depends_on_task_id)))
  548. .materialise_async();
  549. if (existing.length > 0) {
  550. return;
  551. }
  552. var dependency = new TaskDependency() {
  553. task_id = task_id,
  554. depends_on_task_id = depends_on_task_id
  555. };
  556. yield db.insert_async<TaskDependency>(dependency);
  557. yield pulse.bump(task_id, "dependency added");
  558. }
  559. /** Creates a child task under `parent_task_id` (status 'queued'). */
  560. public async TaskEntity create_subtask(int64 parent_task_id, string title,
  561. string? description) throws Error {
  562. var parent = yield db.query<TaskEntity>()
  563. .where(expr("id == $0", elem<int64?>(parent_task_id)))
  564. .first_async();
  565. if (parent == null) {
  566. throw new Error(Quark.from_string("clanker-saddle"), 7,
  567. @"No task exists with id $(parent_task_id)");
  568. }
  569. return yield create_task(((!)parent).project_id, title, description,
  570. ((!)parent).priority, false, parent_task_id);
  571. }
  572. /**
  573. * Marks a task done: records `summary` and `working_branch`, stamps
  574. * `completed_at`, closes any open session (outcome `outcome`) and
  575. * frees the saddle that was running it.
  576. */
  577. public async void complete_task(int64 task_id, string? summary,
  578. string? working_branch, string outcome) throws Error {
  579. var task = yield db.query<TaskEntity>()
  580. .where(expr("id == $0", elem<int64?>(task_id)))
  581. .first_async();
  582. if (task == null) {
  583. throw new Error(Quark.from_string("clanker-saddle"), 8,
  584. @"No task exists with id $(task_id)");
  585. }
  586. ((!)task).status = STATUS_DONE;
  587. ((!)task).completed_at = new DateTime.now_local();
  588. if (summary != null && summary.length > 0) {
  589. ((!)task).summary = summary;
  590. }
  591. if (working_branch != null && working_branch.length > 0) {
  592. ((!)task).working_branch = working_branch;
  593. }
  594. yield db.update_async<TaskEntity>((!)task);
  595. yield close_open_sessions(task_id, outcome);
  596. yield free_saddles_running(task_id);
  597. yield pulse.bump(task_id, "task completed");
  598. }
  599. /**
  600. * Cancels a task: status 'failed' with a cancellation summary. Any
  601. * open session is closed with outcome 'stopped' and its saddle
  602. * freed.
  603. */
  604. public async void cancel_task(int64 task_id) throws Error {
  605. var task = yield db.query<TaskEntity>()
  606. .where(expr("id == $0", elem<int64?>(task_id)))
  607. .first_async();
  608. if (task == null) {
  609. throw new Error(Quark.from_string("clanker-saddle"), 9,
  610. @"No task exists with id $(task_id)");
  611. }
  612. ((!)task).status = STATUS_FAILED;
  613. ((!)task).completed_at = new DateTime.now_local();
  614. if (((!)task).summary == null || ((!)task).summary.length == 0) {
  615. ((!)task).summary = "Cancelled by user";
  616. }
  617. yield db.update_async<TaskEntity>((!)task);
  618. yield close_open_sessions(task_id, "stopped");
  619. yield free_saddles_running(task_id);
  620. yield pulse.bump(task_id, "task cancelled");
  621. }
  622. /**
  623. * Records a saddle-reported failure: status 'failed' with the
  624. * daemon's message as summary, the explanation stored again as a
  625. * bot comment (so the thread carries it), the open session
  626. * closed with outcome 'failed' and its saddle freed.
  627. */
  628. public async void fail_task(int64 task_id, string? summary) throws Error {
  629. var task = yield db.query<TaskEntity>()
  630. .where(expr("id == $0", elem<int64?>(task_id)))
  631. .first_async();
  632. if (task == null) {
  633. throw new Error(Quark.from_string("clanker-saddle"), 25,
  634. @"No task exists with id $(task_id)");
  635. }
  636. ((!)task).status = STATUS_FAILED;
  637. ((!)task).completed_at = new DateTime.now_local();
  638. if (summary != null && summary.length > 0) {
  639. ((!)task).summary = summary;
  640. yield add_comment(task_id, "bot", summary);
  641. } else if (((!)task).summary == null || ((!)task).summary.length == 0) {
  642. ((!)task).summary = "Saddle reported a failure";
  643. }
  644. yield db.update_async<TaskEntity>((!)task);
  645. yield close_open_sessions(task_id, "failed");
  646. yield free_saddles_running(task_id);
  647. yield pulse.bump(task_id, "task failed");
  648. }
  649. /**
  650. * Returns a non-terminal task to the queue: closes its open
  651. * sessions (outcome 'stopped') and frees the saddle that was
  652. * running it.
  653. */
  654. public async void requeue_task(int64 task_id) throws Error {
  655. var task = yield db.query<TaskEntity>()
  656. .where(expr("id == $0", elem<int64?>(task_id)))
  657. .first_async();
  658. if (task != null && (((!)task).status == STATUS_ASSIGNED
  659. || ((!)task).status == STATUS_WORKING || ((!)task).status == STATUS_BLOCKED
  660. || ((!)task).status == STATUS_REVIEW_REQUESTED)) {
  661. ((!)task).status = STATUS_QUEUED;
  662. yield db.update_async<TaskEntity>((!)task);
  663. }
  664. yield close_open_sessions(task_id, "stopped");
  665. yield free_saddles_running(task_id);
  666. yield pulse.bump(task_id, "task requeued");
  667. }
  668. /**
  669. * Returns a failed task to the queue for another attempt. The
  670. * failure summary moves into a bot comment (so the history keeps
  671. * it) before being cleared from the task, `completed_at` is
  672. * withdrawn, any open session closes and its saddle frees — the
  673. * same bookkeeping as {@link requeue_task}, plus the audit
  674. * comment "Task retried by user".
  675. */
  676. public async void retry_task(int64 task_id) throws Error {
  677. var task = yield db.query<TaskEntity>()
  678. .where(expr("id == $0", elem<int64?>(task_id)))
  679. .first_async();
  680. if (task == null) {
  681. throw new Error(Quark.from_string("clanker-saddle"), 8,
  682. @"No task exists with id $(task_id)");
  683. }
  684. if (((!)task).summary != null && ((!)task).summary.length > 0) {
  685. yield add_comment(task_id, "bot", (!)(((!)task).summary));
  686. }
  687. ((!)task).status = STATUS_QUEUED;
  688. ((!)task).summary = null;
  689. ((!)task).completed_at = null;
  690. yield db.update_async<TaskEntity>((!)task);
  691. yield close_open_sessions(task_id, "stopped");
  692. yield free_saddles_running(task_id);
  693. yield add_comment(task_id, "bot", "Task retried by user");
  694. yield pulse.bump(task_id, "task retried");
  695. }
  696. /**
  697. * Queued tasks whose dependencies are all done, ordered by priority
  698. * (1 first) then creation time — the dispatcher's candidate list.
  699. */
  700. public async ImmutableLot<TaskEntity> get_ready_tasks() throws Error {
  701. var queued = yield db.query<TaskEntity>()
  702. .where(expr("status == $0", elem(STATUS_QUEUED)))
  703. .materialise_async();
  704. var all_tasks = yield db.query<TaskEntity>().materialise_async();
  705. var dependencies = yield db.query<TaskDependency>().materialise_async();
  706. var rows = new Vector<TaskEntity>();
  707. foreach (var task in queued) {
  708. bool ready = true;
  709. foreach (var dep in dependencies) {
  710. if (dep.task_id != task.id) {
  711. continue;
  712. }
  713. bool dep_done = false;
  714. foreach (var other in all_tasks) {
  715. if (other.id == dep.depends_on_task_id && other.status == STATUS_DONE) {
  716. dep_done = true;
  717. break;
  718. }
  719. }
  720. if (!dep_done) {
  721. ready = false;
  722. break;
  723. }
  724. }
  725. if (ready) {
  726. rows.add(task);
  727. }
  728. }
  729. var sorted = rows.sort((a, b) => {
  730. if (a.priority != b.priority) {
  731. return (int)(a.priority - b.priority);
  732. }
  733. int64 a_created = a.created_at != null ? ((!)a.created_at).to_unix() : 0;
  734. int64 b_created = b.created_at != null ? ((!)b.created_at).to_unix() : 0;
  735. return (int)(a_created - b_created);
  736. });
  737. return sorted.to_immutable_buffer();
  738. }
  739. /** Comments on a task, oldest first. */
  740. public async ImmutableLot<TaskComment> list_comments(int64 task_id) throws Error {
  741. return yield db.query<TaskComment>()
  742. .where(expr("task_id == $0", elem<int64?>(task_id)))
  743. .order_by(expr("id"))
  744. .materialise_async();
  745. }
  746. /** Blocks on a task, oldest first. */
  747. public async ImmutableLot<TaskBlock> list_blocks(int64 task_id) throws Error {
  748. return yield db.query<TaskBlock>()
  749. .where(expr("task_id == $0", elem<int64?>(task_id)))
  750. .order_by(expr("id"))
  751. .materialise_async();
  752. }
  753. /** Every dependency edge in the graph (for queue annotations). */
  754. public async ImmutableLot<TaskDependency> list_all_dependencies() throws Error {
  755. return yield db.query<TaskDependency>().materialise_async();
  756. }
  757. /** All commands recorded against a session, oldest first. */
  758. public async ImmutableLot<SessionCommand> list_commands(int64 session_id) throws Error {
  759. return yield db.query<SessionCommand>()
  760. .where(expr("session_id == $0", elem<int64?>(session_id)))
  761. .order_by(expr("id"))
  762. .materialise_async();
  763. }
  764. /**
  765. * True when the session carries raw PTY log rows — the terminal
  766. * view then renders the concatenated raw log instead of the
  767. * typed entry stream.
  768. */
  769. public async bool has_raw_log(int64 session_id) throws Error {
  770. var probe = yield db.query<SessionRawLog>()
  771. .where(expr("session_id == $0", elem<int64?>(session_id)))
  772. .first_async();
  773. return probe != null;
  774. }
  775. /**
  776. * The session still open for `task_id` (no `ended_at`), or null.
  777. * The null check runs in Vala: the expression layer cannot compare
  778. * columns against null.
  779. */
  780. public async Session? open_session_for_task(int64 task_id) throws Error {
  781. var sessions = yield db.query<Session>()
  782. .where(expr("task_id == $0", elem<int64?>(task_id)))
  783. .order_by(expr("id"))
  784. .materialise_async();
  785. foreach (var session in sessions) {
  786. if (session.ended_at == null) {
  787. return session;
  788. }
  789. }
  790. return null;
  791. }
  792. private async void close_open_sessions(int64 task_id, string outcome) throws Error {
  793. var sessions = yield db.query<Session>()
  794. .where(expr("task_id == $0", elem<int64?>(task_id)))
  795. .materialise_async();
  796. foreach (var session in sessions) {
  797. if (session.ended_at != null) {
  798. continue;
  799. }
  800. session.ended_at = new DateTime.now_local();
  801. session.outcome = outcome;
  802. yield db.update_async<Session>(session);
  803. }
  804. }
  805. private async void free_saddles_running(int64 task_id) throws Error {
  806. var saddles = yield db.query<Saddle>()
  807. .where(expr("current_task_id == $0", elem<int64?>(task_id)))
  808. .materialise_async();
  809. foreach (var saddle in saddles) {
  810. saddle.status = "idle";
  811. saddle.current_task_id = 0;
  812. saddle.allocated_at = null;
  813. yield db.update_async<Saddle>(saddle);
  814. }
  815. }
  816. }
  817. }