TaskActions.vala 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682
  1. using Inversion;
  2. using Spry;
  3. using Spry.Authentication;
  4. using Statum;
  5. using ClankerSaddle.Services;
  6. namespace ClankerSaddle {
  7. /** Sealed task id carried by the task row actions. */
  8. public class TaskActionPrivate : Object {
  9. public int64 task_id { get; set; }
  10. }
  11. /** Sealed block identity carried by {@link AnswerBlockAction}. */
  12. public class BlockAnswerPrivate : Object {
  13. public int64 task_id { get; set; }
  14. public int64 block_id { get; set; }
  15. }
  16. /**
  17. * Common base for actions sealed against a task id: materialises the
  18. * typed private blob and exposes the resolved id (falling back to the
  19. * hidden `task_id` form field).
  20. */
  21. public abstract class SealedTaskAction : TypedStatumAction<TaskActionPrivate> {
  22. protected override Type private_type { get { return typeof(TaskActionPrivate); } }
  23. /** The sealed task id, or 0 when neither blob nor form field carried one. */
  24. protected int64 sealed_task_id() {
  25. var ctx = request_private;
  26. return ctx != null ? ((!)ctx).task_id : Forms.integer(request, "task_id", 0);
  27. }
  28. }
  29. /**
  30. * Creates a task from the queue form (project and title required).
  31. * The task lands in DRAFT — the human Enqueue action queues it;
  32. * there is no agent path. Priority clamps to 1–9; the optional
  33. * parent id supports manual subtasks. Branch fields are optional —
  34. * `branch_from` prefills from the project's default branch and
  35. * `task_branch` is inherited from the parent task when left blank.
  36. * The plan-auto-approve and auto-enqueue-subtasks options store
  37. * the per-task plan-11 levers.
  38. */
  39. public class CreateTaskAction : StatumAction {
  40. private TaskService tasks = inject<TaskService>();
  41. private CapabilityService capabilities = inject<CapabilityService>();
  42. private UserService users = inject<UserService>();
  43. public override async DirectiveBuilder handle() throws GLib.Error {
  44. var guard = yield SpryAuth.require_login(directives(), request, users);
  45. if (guard != null) {
  46. return (!)guard;
  47. }
  48. int64 project_id = Forms.integer(request, "project_id", 0);
  49. string title = Forms.field(request, "title");
  50. if (project_id <= 0 || title.length == 0) {
  51. return directives().notify("error", "Project and title are required").navigate("/tasks");
  52. }
  53. int64? parent_id = null;
  54. int64 parsed_parent = Forms.integer(request, "parent_task_id", 0);
  55. if (parsed_parent > 0) {
  56. parent_id = parsed_parent;
  57. }
  58. // Required capabilities: the create form's multi-select posts
  59. // several capability_id fields; every id must resolve before
  60. // the task is created. (A plain array — the Invercargill
  61. // collections mishandle boxed value types in foreach.)
  62. int64[] required = {};
  63. if (request.form != null) {
  64. foreach (var value in request.form.get_field_all("capability_id")) {
  65. int64 parsed = int64.parse(value.strip());
  66. if (parsed <= 0) {
  67. continue;
  68. }
  69. var capability = yield capabilities.get_capability(parsed);
  70. if (capability == null) {
  71. return directives().notify("error",
  72. @"No capability exists with id $(parsed)").navigate("/tasks");
  73. }
  74. required += parsed;
  75. }
  76. }
  77. try {
  78. string mode = Forms.field(request, "mode");
  79. if (mode.length == 0) {
  80. mode = "implement";
  81. }
  82. bool mode_valid = false;
  83. foreach (string candidate in TASK_MODES) {
  84. if (mode == candidate) {
  85. mode_valid = true;
  86. break;
  87. }
  88. }
  89. if (!mode_valid) {
  90. return directives().notify("error",
  91. @"Unknown mode '$(mode)'").navigate("/tasks");
  92. }
  93. int64 enqueue_after = Forms.integer(request, "enqueue_after_task_id", 0);
  94. if (enqueue_after > 0) {
  95. yield tasks.validate_enqueue_after_cycle(-1, enqueue_after);
  96. }
  97. var task = yield tasks.create_task(project_id, title,
  98. Forms.field_raw(request, "description"),
  99. Forms.integer(request, "priority", 5), parent_id,
  100. Forms.optional(request, "task_branch"),
  101. Forms.optional(request, "branch_from"),
  102. Forms.field(request, "plan_auto_approve") == "on",
  103. Forms.field(request, "auto_enqueue_subtasks") == "on",
  104. Forms.field(request, "auto_approve") == "on",
  105. STATUS_DRAFT, TASK_KIND_TASK, mode, CREATOR_HUMAN, 0,
  106. enqueue_after);
  107. if (Forms.field(request, "plan").length > 0) {
  108. yield tasks.update_plan(task.id, Forms.field_raw(request, "plan"));
  109. }
  110. foreach (int64 capability_id in required) {
  111. yield capabilities.attach_to_task(capability_id, task.id);
  112. }
  113. return directives().notify("success", @"Task '$(task.title)' created in draft")
  114. .navigate(@"/tasks/$(task.id)");
  115. } catch (Error e) {
  116. return directives().notify("error", @"Could not create the task: $(e.message)").navigate("/tasks");
  117. }
  118. }
  119. }
  120. /**
  121. * Saves the human-editable fields of a task under the draft
  122. * lifecycle's edit matrix (see {@link TaskService.edit_task}): in
  123. * draft every field except status, in non-draft non-terminal
  124. * states priority only, in terminal states nothing. Status is not
  125. * part of the form — Enqueue/Cancel/bot transitions own it.
  126. */
  127. public class UpdateTaskAction : SealedTaskAction {
  128. private TaskService tasks = inject<TaskService>();
  129. private UserService users = inject<UserService>();
  130. public override async DirectiveBuilder handle() throws GLib.Error {
  131. var guard = yield SpryAuth.require_login(directives(), request, users);
  132. if (guard != null) {
  133. return (!)guard;
  134. }
  135. int64 task_id = sealed_task_id();
  136. if (task_id <= 0) {
  137. return directives().navigate("/tasks");
  138. }
  139. try {
  140. int64? priority = null;
  141. string priority_field = Forms.field(request, "priority");
  142. if (priority_field.length > 0) {
  143. priority = Forms.integer(request, "priority", 5);
  144. }
  145. // The plan-12 mode select and the auto-enqueue
  146. // predecessor (draft-only fields; -1 clears).
  147. string mode = "";
  148. if (request.form != null && request.form.get_field("mode") != null) {
  149. mode = Forms.field(request, "mode");
  150. }
  151. int64? enqueue_after = null;
  152. string enqueue_field = Forms.field(request, "enqueue_after_task_id");
  153. if (enqueue_field.length > 0) {
  154. int64 parsed = int64.parse(enqueue_field);
  155. enqueue_after = parsed > 0 ? parsed : -1;
  156. }
  157. yield tasks.edit_task(task_id,
  158. Forms.optional(request, "title"),
  159. request.form != null ? Forms.field(request, "description") : null,
  160. priority,
  161. request.form != null && request.form.get_field("task_branch") != null
  162. ? Forms.field(request, "task_branch") : null,
  163. request.form != null && request.form.get_field("branch_from") != null
  164. ? Forms.field(request, "branch_from") : null,
  165. Forms.field(request, "plan_auto_approve") == "on",
  166. Forms.field(request, "auto_enqueue_subtasks") == "on",
  167. Forms.field(request, "auto_approve") == "on",
  168. mode.length > 0 ? mode : null,
  169. enqueue_after);
  170. return directives().notify("success", "Task saved").navigate(@"/tasks/$(task_id)");
  171. } catch (Error e) {
  172. return directives().notify("error", @"Could not save the task: $(e.message)")
  173. .navigate(@"/tasks/$(task_id)");
  174. }
  175. }
  176. }
  177. /**
  178. * Appends a comment to a task as the signed-in user. An optional
  179. * `quote` field carries text the user highlighted in the terminal log
  180. * or an RFC body; the quote is stored with the comment and delivered
  181. * to the saddle as a quote block.
  182. */
  183. public class AddCommentAction : SealedTaskAction {
  184. private TaskService tasks = inject<TaskService>();
  185. private UserService users = inject<UserService>();
  186. public override async DirectiveBuilder handle() throws GLib.Error {
  187. var guard = yield SpryAuth.require_login(directives(), request, users);
  188. if (guard != null) {
  189. return (!)guard;
  190. }
  191. int64 task_id = sealed_task_id();
  192. string body = Forms.field_raw(request, "body");
  193. string quote = Forms.field_raw(request, "quote");
  194. if (task_id <= 0 || body.length == 0) {
  195. return directives().notify("error", "A comment body is required").navigate(@"/tasks/$(task_id)");
  196. }
  197. var me = SpryAuth.current(request);
  198. try {
  199. yield tasks.add_comment(task_id, @"user:$(me.username)", body, quote);
  200. yield tasks.log_session_entry(task_id, "user-comment",
  201. @"comment by user:$(me.username)", quote.length > 0 ? "> " + quote.replace("\n", "\n> ") + "\n\n" + body : body);
  202. // Terminals post with a return_to so the live view survives
  203. // the comment; every other caller lands on the task page.
  204. string return_to = Forms.field(request, "return_to");
  205. string destination = return_to.length > 0 && return_to.has_prefix("/")
  206. ? return_to : @"/tasks/$(task_id)";
  207. return directives().notify("success", "Comment added").navigate(destination);
  208. } catch (Error e) {
  209. return directives().notify("error", @"Could not add the comment: $(e.message)")
  210. .navigate(@"/tasks/$(task_id)");
  211. }
  212. }
  213. }
  214. /**
  215. * Saves the task's markdown plan from the web UI's plan editor (the
  216. * human counterpart of the agent's `saddle plan`).
  217. */
  218. public class UpdatePlanAction : SealedTaskAction {
  219. private TaskService tasks = inject<TaskService>();
  220. private UserService users = inject<UserService>();
  221. public override async DirectiveBuilder handle() throws GLib.Error {
  222. var guard = yield SpryAuth.require_login(directives(), request, users);
  223. if (guard != null) {
  224. return (!)guard;
  225. }
  226. int64 task_id = sealed_task_id();
  227. if (task_id <= 0) {
  228. return directives().navigate("/tasks");
  229. }
  230. try {
  231. yield tasks.update_plan(task_id, Forms.field_raw(request, "plan"));
  232. return directives().notify("success", "Plan saved").navigate(@"/tasks/$(task_id)");
  233. } catch (Error e) {
  234. return directives().notify("error", @"Could not save the plan: $(e.message)")
  235. .navigate(@"/tasks/$(task_id)");
  236. }
  237. }
  238. }
  239. /**
  240. * Records the human's answer to a block; the task and block ids come
  241. * from the sealed {@link BlockAnswerPrivate} blob. Question blocks
  242. * submit either a chosen `option` or the free-text `other` field.
  243. * Review and plan blocks submit a `feedback` reply which is stored
  244. * wrapped in the stable verdict wording ("Review feedback
  245. * received:\n…" / "Plan feedback received:\n…") — the answer is the
  246. * resume_notice the daemon reads. Accepting a plan (the stable
  247. * approval wording) also applies the submitted plan markdown to
  248. * task.plan. Question/RFC answers are stored verbatim — the
  249. * resume-notice builder applies the "Human selected: …" formatting.
  250. */
  251. public class AnswerBlockAction : TypedStatumAction<BlockAnswerPrivate> {
  252. private TaskService tasks = inject<TaskService>();
  253. private UserService users = inject<UserService>();
  254. protected override Type private_type { get { return typeof(BlockAnswerPrivate); } }
  255. public override async DirectiveBuilder handle() throws GLib.Error {
  256. var guard = yield SpryAuth.require_login(directives(), request, users);
  257. if (guard != null) {
  258. return (!)guard;
  259. }
  260. var ctx = request_private;
  261. if (ctx == null) {
  262. return directives().navigate("/tasks");
  263. }
  264. int64 task_id = ((!)ctx).task_id;
  265. string option = Forms.field(request, "option");
  266. string other = Forms.field_raw(request, "other");
  267. string answer = Forms.field_raw(request, "answer");
  268. string feedback = Forms.field_raw(request, "feedback");
  269. if (option == "__other__") {
  270. answer = other.strip().length > 0 ? other.strip() : answer;
  271. } else if (option.length > 0) {
  272. answer = option;
  273. } else if (feedback.strip().length > 0) {
  274. var block = yield tasks.get_task(task_id);
  275. string type = "";
  276. foreach (var candidate in ((!)block).blocks) {
  277. if (candidate.id == ((!)ctx).block_id) {
  278. type = candidate.block_type;
  279. break;
  280. }
  281. }
  282. answer = type == BLOCK_PLAN
  283. ? @"$(NOTICE_PLAN_FEEDBACK)\n$(feedback.strip())"
  284. : @"$(NOTICE_REVIEW_FEEDBACK)\n$(feedback.strip())";
  285. }
  286. if (answer.length == 0) {
  287. return directives().notify("error", "An answer is required").navigate(@"/tasks/$(task_id)");
  288. }
  289. try {
  290. // Accepting a plan applies the submitted markdown to the
  291. // task's plan field (the approved text IS the plan).
  292. if (answer == NOTICE_PLAN_APPROVED) {
  293. foreach (var candidate in (yield tasks.get_task(task_id)).blocks) {
  294. if (candidate.id == ((!)ctx).block_id
  295. && candidate.block_type == BLOCK_PLAN
  296. && candidate.body != null
  297. && ((!)candidate.body).length > 0) {
  298. yield tasks.update_plan(task_id, (!)candidate.body);
  299. break;
  300. }
  301. }
  302. }
  303. yield tasks.answer_block(((!)ctx).block_id, answer);
  304. return directives().notify("success", "Answer recorded; the saddle will collect it")
  305. .navigate(@"/tasks/$(task_id)");
  306. } catch (Error e) {
  307. return directives().notify("error", @"Could not answer the block: $(e.message)")
  308. .navigate(@"/tasks/$(task_id)");
  309. }
  310. }
  311. }
  312. /** Creates a child task under the sealed parent task id. */
  313. public class CreateSubtaskAction : SealedTaskAction {
  314. private TaskService tasks = inject<TaskService>();
  315. private UserService users = inject<UserService>();
  316. public override async DirectiveBuilder handle() throws GLib.Error {
  317. var guard = yield SpryAuth.require_login(directives(), request, users);
  318. if (guard != null) {
  319. return (!)guard;
  320. }
  321. int64 task_id = sealed_task_id();
  322. string title = Forms.field(request, "title");
  323. if (task_id <= 0 || title.length == 0) {
  324. return directives().notify("error", "A subtask title is required").navigate(@"/tasks/$(task_id)");
  325. }
  326. try {
  327. yield tasks.create_subtask(task_id, title, Forms.field_raw(request, "description"));
  328. return directives().notify("success", "Subtask created").navigate(@"/tasks/$(task_id)");
  329. } catch (Error e) {
  330. return directives().notify("error", @"Could not create the subtask: $(e.message)")
  331. .navigate(@"/tasks/$(task_id)");
  332. }
  333. }
  334. }
  335. /** Adds a dependency edge from the sealed task to the form's target. */
  336. public class AddDependencyAction : SealedTaskAction {
  337. private TaskService tasks = inject<TaskService>();
  338. private UserService users = inject<UserService>();
  339. public override async DirectiveBuilder handle() throws GLib.Error {
  340. var guard = yield SpryAuth.require_login(directives(), request, users);
  341. if (guard != null) {
  342. return (!)guard;
  343. }
  344. int64 task_id = sealed_task_id();
  345. int64 depends_on = Forms.integer(request, "depends_on_task_id", 0);
  346. if (task_id <= 0 || depends_on <= 0) {
  347. return directives().notify("error", "Pick a task to depend on").navigate(@"/tasks/$(task_id)");
  348. }
  349. try {
  350. yield tasks.add_dependency(task_id, depends_on);
  351. return directives().notify("success", "Dependency added").navigate(@"/tasks/$(task_id)");
  352. } catch (Error e) {
  353. return directives().notify("error", @"Could not add the dependency: $(e.message)")
  354. .navigate(@"/tasks/$(task_id)");
  355. }
  356. }
  357. }
  358. /**
  359. * The human-only Enqueue action: moves a draft task into the
  360. * queue. Refused with a shown error while any dependency is still
  361. * in draft; there is no agent path to enqueueing whatsoever.
  362. */
  363. public class EnqueueTaskAction : SealedTaskAction {
  364. private TaskService tasks = inject<TaskService>();
  365. private UserService users = inject<UserService>();
  366. public override async DirectiveBuilder handle() throws GLib.Error {
  367. var guard = yield SpryAuth.require_login(directives(), request, users);
  368. if (guard != null) {
  369. return (!)guard;
  370. }
  371. int64 task_id = sealed_task_id();
  372. if (task_id <= 0) {
  373. return directives().navigate("/tasks");
  374. }
  375. try {
  376. yield tasks.enqueue_task(task_id, CREATOR_HUMAN);
  377. return directives().notify("success", "Task enqueued").navigate(@"/tasks/$(task_id)");
  378. } catch (Error e) {
  379. return directives().notify("error", @"Could not enqueue the task: $(e.message)")
  380. .navigate(@"/tasks/$(task_id)");
  381. }
  382. }
  383. }
  384. /**
  385. * The human-only Delete action: removes a draft task that nothing
  386. * depends on (an error names the dependents otherwise — they must
  387. * be deleted first). Non-draft tasks are refused.
  388. */
  389. public class DeleteTaskAction : SealedTaskAction {
  390. private TaskService tasks = inject<TaskService>();
  391. private UserService users = inject<UserService>();
  392. public override async DirectiveBuilder handle() throws GLib.Error {
  393. var guard = yield SpryAuth.require_login(directives(), request, users);
  394. if (guard != null) {
  395. return (!)guard;
  396. }
  397. int64 task_id = sealed_task_id();
  398. if (task_id <= 0) {
  399. return directives().navigate("/tasks");
  400. }
  401. // Plan 12: goal tasks are the goal agent's to delete (the
  402. // task CLI callback under its goal) — humans cancel goals
  403. // instead; the cascade closes their tasks.
  404. var detail = yield tasks.get_task(task_id);
  405. if (detail != null && (((!)detail).task.creator != CREATOR_HUMAN
  406. || ((!)detail).task.kind == TASK_KIND_GOAL_RUN)) {
  407. return directives().notify("error",
  408. "Goal tasks are managed by their goal agent — cancel the goal instead")
  409. .navigate(@"/tasks/$(task_id)");
  410. }
  411. try {
  412. yield tasks.delete_task(task_id);
  413. return directives().notify("success", "Draft task deleted").navigate("/tasks");
  414. } catch (Error e) {
  415. return directives().notify("error", @"Could not delete the task: $(e.message)")
  416. .navigate(@"/tasks/$(task_id)");
  417. }
  418. }
  419. }
  420. /**
  421. * Cancels a task (status 'failed'); the sealed id comes from the task
  422. * page's cancel button. Only non-draft non-terminal tasks offer the
  423. * action — drafts are deleted, terminal tasks are already finished,
  424. * and completion itself is bot-only.
  425. */
  426. public class CancelTaskAction : SealedTaskAction {
  427. private TaskService tasks = inject<TaskService>();
  428. private SaddleService saddles = inject<SaddleService>();
  429. private UserService users = inject<UserService>();
  430. public override async DirectiveBuilder handle() throws GLib.Error {
  431. var guard = yield SpryAuth.require_login(directives(), request, users);
  432. if (guard != null) {
  433. return (!)guard;
  434. }
  435. int64 task_id = sealed_task_id();
  436. if (task_id <= 0) {
  437. return directives().navigate("/tasks");
  438. }
  439. var detail = yield tasks.get_task(task_id);
  440. if (detail == null) {
  441. return directives().navigate("/tasks");
  442. }
  443. string status = ((!)detail).task.status;
  444. if (status == STATUS_DRAFT || status == STATUS_DONE || status == STATUS_FAILED) {
  445. return directives().notify("error",
  446. "Only non-draft, non-terminal tasks can be cancelled")
  447. .navigate(@"/tasks/$(task_id)");
  448. }
  449. try {
  450. yield saddles.abort_live_session(tasks, task_id, "cancelled");
  451. yield tasks.cancel_task(task_id);
  452. return directives().notify("success", "Task cancelled").navigate(@"/tasks/$(task_id)");
  453. } catch (Error e) {
  454. return directives().notify("error", @"Could not cancel the task: $(e.message)")
  455. .navigate(@"/tasks/$(task_id)");
  456. }
  457. }
  458. }
  459. /**
  460. * Retries a failed task: the requeue path with the failure summary
  461. * moved into a comment (and cleared), all history kept. The next
  462. * dispatch resumes the daemon-side workspace when it still exists.
  463. */
  464. public class RetryTaskAction : SealedTaskAction {
  465. private TaskService tasks = inject<TaskService>();
  466. private SaddleService saddles = inject<SaddleService>();
  467. private UserService users = inject<UserService>();
  468. public override async DirectiveBuilder handle() throws GLib.Error {
  469. var guard = yield SpryAuth.require_login(directives(), request, users);
  470. if (guard != null) {
  471. return (!)guard;
  472. }
  473. int64 task_id = sealed_task_id();
  474. if (task_id <= 0) {
  475. return directives().navigate("/tasks");
  476. }
  477. var detail = yield tasks.get_task(task_id);
  478. if (detail == null) {
  479. return directives().navigate("/tasks");
  480. }
  481. if (((!)detail).task.status != STATUS_FAILED) {
  482. return directives().notify("error", "Only failed tasks can be retried")
  483. .navigate(@"/tasks/$(task_id)");
  484. }
  485. try {
  486. yield saddles.abort_live_session(tasks, task_id, "retried");
  487. yield tasks.retry_task(task_id);
  488. return directives().notify("success", "Task requeued for another attempt")
  489. .navigate(@"/tasks/$(task_id)");
  490. } catch (Error e) {
  491. return directives().notify("error", @"Could not retry the task: $(e.message)")
  492. .navigate(@"/tasks/$(task_id)");
  493. }
  494. }
  495. }
  496. /**
  497. * Manually requeues a task stuck working/blocked on a saddle that
  498. * went offline (the automatic three-strike path may never fire when
  499. * the saddle row was refreshed to offline first). The same path as
  500. * the dispatcher's abandon: requeue + bot comment + saddle offline.
  501. */
  502. public class ResumeTaskAction : SealedTaskAction {
  503. private TaskService task_service = inject<TaskService>();
  504. private SaddleService saddle_service = inject<SaddleService>();
  505. private UserService users = inject<UserService>();
  506. public override async DirectiveBuilder handle() throws GLib.Error {
  507. var guard = yield SpryAuth.require_login(directives(), request, users);
  508. if (guard != null) {
  509. return (!)guard;
  510. }
  511. int64 task_id = sealed_task_id();
  512. if (task_id <= 0) {
  513. return directives().navigate("/tasks");
  514. }
  515. try {
  516. var session = yield task_service.open_session_for_task(task_id);
  517. if (session != null) {
  518. yield saddle_service.abort_live_session(task_service, task_id, "stopped");
  519. yield saddle_service.mark_saddle_unreachable(((!)session).saddle_id, task_service);
  520. } else {
  521. yield task_service.requeue_task(task_id);
  522. }
  523. return directives().notify("success",
  524. "Task requeued; the daemon restores its context on the next dispatch")
  525. .navigate(@"/tasks/$(task_id)");
  526. } catch (Error e) {
  527. return directives().notify("error", @"Could not requeue the task: $(e.message)")
  528. .navigate(@"/tasks/$(task_id)");
  529. }
  530. }
  531. }
  532. /**
  533. * Flips a task's subtask auto-approve toggle: when on, review blocks
  534. * raised by this task's descendants are answered automatically —
  535. * the task's own review stays a human gate.
  536. */
  537. public class ToggleAutoApproveAction : SealedTaskAction {
  538. private TaskService task_service = inject<TaskService>();
  539. private UserService users = inject<UserService>();
  540. public override async DirectiveBuilder handle() throws GLib.Error {
  541. var guard = yield SpryAuth.require_login(directives(), request, users);
  542. if (guard != null) {
  543. return (!)guard;
  544. }
  545. int64 task_id = sealed_task_id();
  546. if (task_id <= 0) {
  547. return directives().navigate("/tasks");
  548. }
  549. try {
  550. var detail = yield task_service.get_task(task_id);
  551. if (detail == null) {
  552. return directives().navigate("/tasks");
  553. }
  554. bool enabled = ((!)detail).task.auto_approve == 0;
  555. yield task_service.save_auto_approve(task_id, enabled);
  556. string state = enabled ? "on" : "off";
  557. return directives().notify("success",
  558. @"Subtask auto-approve is now $(state)")
  559. .navigate(@"/tasks/$(task_id)");
  560. } catch (Error e) {
  561. return directives().notify("error", @"Could not toggle auto-approve: $(e.message)")
  562. .navigate(@"/tasks/$(task_id)");
  563. }
  564. }
  565. }
  566. /**
  567. * The task detail page's polling action: rebuilds the `task` slot from
  568. * current data so the three-second client poll shows live comments,
  569. * blocks, output-driven status changes and completions. Navigates
  570. * nowhere; a failed reload leaves the client's state untouched.
  571. */
  572. public class RefreshTaskDataAction : SealedTaskAction {
  573. private TaskService task_service = inject<TaskService>();
  574. private ProjectService project_service = inject<ProjectService>();
  575. private SaddleService saddle_service = inject<SaddleService>();
  576. private RuleService rule_service = inject<RuleService>();
  577. private CapabilityService capability_service = inject<CapabilityService>();
  578. private SaddlePollState poll_state = inject<SaddlePollState>();
  579. private PushService push_service = inject<PushService>();
  580. private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
  581. private UserService users = inject<UserService>();
  582. public override async DirectiveBuilder handle() throws GLib.Error {
  583. var guard = yield SpryAuth.require_login(directives(), request, users);
  584. if (guard != null) {
  585. return (!)guard;
  586. }
  587. int64 task_id = sealed_task_id();
  588. if (task_id <= 0) {
  589. return directives();
  590. }
  591. var me = SpryAuth.current(request);
  592. var state = yield TaskDetailState.build(state_service, task_service,
  593. project_service, saddle_service, rule_service, capability_service,
  594. action_registry, poll_state, push_service, db,
  595. me != null ? ((!)me).user_id : 0, task_id);
  596. return directives().set(state);
  597. }
  598. }
  599. }