TaskActions.vala 30 KB

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