TaskDetailEntrypoint.vala 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Invercargill.Expressions;
  4. using Spry;
  5. using Spry.Authentication;
  6. using Statum;
  7. using ClankerSaddle.Data;
  8. using ClankerSaddle.Services;
  9. namespace ClankerSaddle {
  10. /**
  11. * A comment row in the task detail thread, with its quoted
  12. * reference. {@link is_system} marks the harness-authored
  13. * bookkeeping rows (plan 15 C2) — rendered with the distinct
  14. * muted system styling, never an agent row.
  15. */
  16. public class CommentView : Object {
  17. public string author { get; set; default = ""; }
  18. public string body { get; set; default = ""; }
  19. public string quote { get; set; default = ""; }
  20. public string when_display { get; set; default = ""; }
  21. public bool from_agent { get; set; }
  22. public bool is_system { get; set; }
  23. }
  24. /** One progress line in the task's history. */
  25. public class ProgressView : Object {
  26. public int64 id { get; set; }
  27. public string summary { get; set; default = ""; }
  28. public string when_display { get; set; default = ""; }
  29. }
  30. /**
  31. * One agent celebration (`saddle yeehaw`) on the task page: the
  32. * message plus when it landed, rendered inside the festive card.
  33. */
  34. public class YeehawView : Object {
  35. public string message { get; set; default = ""; }
  36. public string when_display { get; set; default = ""; }
  37. }
  38. /**
  39. * A block (question, RFC or review) with its answer state and
  40. * answer action. A pending review hides its body preview
  41. * ({@link show_body}) because the review banner above it renders
  42. * the same markdown; every other typed block (e.g. a question
  43. * with a markdown body) keeps its preview.
  44. */
  45. public class BlockView : Object {
  46. public int64 id { get; set; }
  47. public string question { get; set; default = ""; }
  48. public string answer { get; set; default = ""; }
  49. public string block_type { get; set; default = "question"; }
  50. public string body { get; set; default = ""; }
  51. public string[] options { get; set; }
  52. public bool answered { get; set; }
  53. public bool show_body { get; set; default = true; }
  54. public string answered_by { get; set; default = ""; }
  55. public string created_display { get; set; default = ""; }
  56. public string answered_display { get; set; default = ""; }
  57. public Statum.Model.ActionDto? answer_action { get; set; }
  58. }
  59. /** A task option row (the auto-enqueue-after dropdown's entries). */
  60. public class TaskOptionView : Object {
  61. public int64 id { get; set; }
  62. public string title { get; set; default = ""; }
  63. public string status { get; set; default = ""; }
  64. }
  65. /** A rule attached to the task, with its detach action. */
  66. public class TaskRuleRowView : Object {
  67. public int64 link_id { get; set; }
  68. public int64 rule_id { get; set; }
  69. public string name { get; set; default = ""; }
  70. public string body { get; set; default = ""; }
  71. public Statum.Model.ActionDto? detach { get; set; }
  72. }
  73. /** A capability required by the task, with its detach action. */
  74. public class TaskCapabilityRowView : Object {
  75. public int64 link_id { get; set; }
  76. public string name { get; set; default = ""; }
  77. public Statum.Model.ActionDto? detach { get; set; }
  78. }
  79. /** A session row in the task's history, linking to the terminal view. */
  80. public class SessionView : Object {
  81. public int64 id { get; set; }
  82. public string saddle_name { get; set; default = ""; }
  83. public string started_display { get; set; default = ""; }
  84. public string ended_display { get; set; default = ""; }
  85. public string outcome { get; set; default = ""; }
  86. public bool open { get; set; }
  87. }
  88. /** Public data of the `task` slot. */
  89. public class TaskDetailPublic : Object {
  90. public bool exists { get; set; }
  91. public int64 id { get; set; }
  92. public string title { get; set; default = ""; }
  93. public string description { get; set; default = ""; }
  94. public string status { get; set; default = ""; }
  95. public int64 priority { get; set; default = 5; }
  96. public string summary { get; set; default = ""; }
  97. public string task_branch { get; set; default = ""; }
  98. public string branch_from { get; set; default = ""; }
  99. public bool review_pending { get; set; }
  100. public string review_summary { get; set; default = ""; }
  101. public string review_diff_url { get; set; default = ""; }
  102. public bool resume_available { get; set; }
  103. /**
  104. * Goal 4 task 1 (Force Wake): the human recovery lever for a
  105. * parked task — authored only while the task is suspended
  106. * (any kind; a goal_run routes to the goal's wake). Pressing
  107. * it sets the task (or goal) force-wake flag.
  108. */
  109. public Statum.Model.ActionDto? force_wake { get; set; }
  110. public string saddle_health { get; set; default = ""; }
  111. public string display { get; set; default = ""; }
  112. public bool terminal { get; set; }
  113. public bool failed { get; set; }
  114. public bool draft { get; set; }
  115. /** Draft: every field except status is editable. */
  116. public bool editable_all { get; set; }
  117. /** Non-draft non-terminal: priority (only) is editable. */
  118. public bool editable_priority { get; set; }
  119. /** Capabilities editable while draft or queued (no worker yet). */
  120. public bool editable_capabilities { get; set; }
  121. public int64 project_id { get; set; }
  122. public string project_name { get; set; default = ""; }
  123. public string git_url { get; set; default = ""; }
  124. /** Plan 12: the goal this task belongs to (0 = standalone). */
  125. public int64 goal_id { get; set; }
  126. public string goal_title { get; set; default = ""; }
  127. public string goal_status { get; set; default = ""; }
  128. public string goal_branch { get; set; default = ""; }
  129. public string mode { get; set; default = "implement"; }
  130. public string creator { get; set; default = "human"; }
  131. /** True when the task is the goal agent's own run (goal page owns it). */
  132. public bool is_goal_run { get; set; }
  133. /** The auto-enqueue predecessor id (0 = none), human tasks only. */
  134. public int64 enqueue_after_task_id { get; set; }
  135. public string enqueue_after_title { get; set; default = ""; }
  136. public string created_display { get; set; default = ""; }
  137. public string started_display { get; set; default = ""; }
  138. public string completed_display { get; set; default = ""; }
  139. /**
  140. * The completion-report card heading — "Completion report" for
  141. * done, "Failure summary" for failed; empty when the card is
  142. * absent (non-terminal task or no stored summary).
  143. */
  144. public string report_title { get; set; default = ""; }
  145. public Statum.Model.ActionDto? update { get; set; }
  146. public Statum.Model.ActionDto? comment { get; set; }
  147. public Statum.Model.ActionDto? enqueue { get; set; }
  148. public Statum.Model.ActionDto? delete { get; set; }
  149. public Statum.Model.ActionDto? cancel { get; set; }
  150. public Statum.Model.ActionDto? retry { get; set; }
  151. public Statum.Model.ActionDto? resume { get; set; }
  152. public Statum.Model.ActionDto? refresh { get; set; }
  153. public Statum.Model.ActionDto? attach_rule { get; set; }
  154. public Statum.Model.ActionDto? attach_capability { get; set; }
  155. /** The signed-in user holds a "Notify me" pref for this task. */
  156. public bool notify_me { get; set; }
  157. public Statum.Model.ActionDto? toggle_notify { get; set; }
  158. /**
  159. * The task has at least one `saddle yeehaw` entry — the 🤠
  160. * badge's presence, derived from the existing
  161. * session_commands rows (nothing new is stored).
  162. */
  163. public bool yeehaw { get; set; }
  164. /** The task's uncapped yeehaw count (the card's "+N more" hint). */
  165. public int64 yeehaw_total { get; set; }
  166. /** Yeehaw entries beyond the card cap (0 within it). */
  167. public int64 yeehaw_more { get; set; }
  168. }
  169. /**
  170. * Builds the `task` slot state shared by the task detail entrypoint and
  171. * the {@link RefreshTaskDataAction} pulse refresh, so both hydrate
  172. * identical state: the full task with branch fields and pending
  173. * review, plus view rows for comments (with quotes), progress,
  174. * blocks (typed, with options) and sessions.
  175. */
  176. public class TaskDetailState : Object {
  177. /**
  178. * Loads task `task_id` and shapes its PAGE slot (full view rows
  179. * plus every authored action reference). The state's `exists` flag
  180. * is false when the task is missing.
  181. */
  182. public static async Slot build(StateService state_service, TaskService tasks,
  183. ProjectService projects, SaddleService saddles, RuleService rules,
  184. CapabilityService capabilities, ActionRegistry actions,
  185. SaddlePollState polls, PushService push,
  186. InvercargillSql.Orm.OrmSession db, int64 viewer_id,
  187. int64 task_id) throws GLib.Error {
  188. var detail = yield tasks.get_task(task_id);
  189. if (detail == null) {
  190. return slot_for(state_service, new TaskDetailPublic() { exists = false },
  191. new PropertyDictionary());
  192. }
  193. var task = ((!)detail).task;
  194. var project = yield projects.get_project(task.project_id);
  195. var pub = new TaskDetailPublic() {
  196. exists = true,
  197. id = task.id,
  198. title = task.title,
  199. description = task.description ?? "",
  200. status = task.status,
  201. priority = task.priority,
  202. summary = task.summary ?? "",
  203. task_branch = task.task_branch ?? "",
  204. branch_from = task.branch_from ?? "",
  205. project_id = task.project_id,
  206. project_name = project != null ? ((!)project).name : "",
  207. git_url = project != null ? ((!)project).git_url : "",
  208. goal_id = task.goal_id,
  209. mode = task.mode,
  210. creator = task.creator,
  211. is_goal_run = task.kind == TASK_KIND_GOAL_RUN,
  212. enqueue_after_task_id = task.enqueue_after_task_id,
  213. created_display = format_stamp(task.created_at),
  214. started_display = format_stamp(task.started_at),
  215. completed_display = format_stamp(task.completed_at)
  216. };
  217. var display = TaskService.display_of_detail(((!)detail));
  218. pub.display = display.status;
  219. pub.terminal = display.terminal;
  220. pub.failed = display.failed;
  221. pub.draft = task.status == STATUS_DRAFT;
  222. // The edit matrix (plan 11): draft edits everything but
  223. // status; non-draft non-terminal edits priority; terminal
  224. // edits nothing; capabilities stay editable until a worker
  225. // has the job (draft or queued).
  226. pub.editable_all = pub.draft;
  227. pub.editable_priority = !pub.terminal;
  228. pub.editable_capabilities = pub.draft || task.status == STATUS_QUEUED;
  229. // The heredoc completion report (`cat << 'EOF' | saddle
  230. // done` / the failure path's stored summary): terminal
  231. // tasks render tasks.summary as markdown in the
  232. // completion-report card below the status/description
  233. // grid; an empty summary renders no card at all.
  234. if (pub.terminal && pub.summary.length > 0) {
  235. pub.report_title = pub.failed ? "Failure summary" : "Completion report";
  236. }
  237. var sealed = new TaskActionPrivate() { task_id = task.id };
  238. pub.update = actions.author_private<UpdateTaskAction, TaskActionPrivate>(sealed);
  239. pub.comment = actions.author_private<AddCommentAction, TaskActionPrivate>(sealed);
  240. // Plan 12: enqueue/delete are creator-only — the human's
  241. // actions exist just for human-created worker tasks; goal
  242. // tasks (and the goal agent's own run) are sequenced by
  243. // their goal agent.
  244. if (task.creator == CREATOR_HUMAN && task.kind == TASK_KIND_TASK) {
  245. pub.enqueue = actions.author_private<EnqueueTaskAction, TaskActionPrivate>(sealed);
  246. pub.delete = actions.author_private<DeleteTaskAction, TaskActionPrivate>(sealed);
  247. }
  248. pub.cancel = actions.author_private<CancelTaskAction, TaskActionPrivate>(sealed);
  249. pub.retry = actions.author_private<RetryTaskAction, TaskActionPrivate>(sealed);
  250. pub.resume = actions.author_private<ResumeTaskAction, TaskActionPrivate>(sealed);
  251. // Goal 4 task 1: Force wake — only a PARKED task offers
  252. // it; a suspended goal run's wake IS the goal's wake
  253. // (TaskForceWakeAction routes it — one path).
  254. if (task.status == STATUS_SUSPENDED) {
  255. pub.force_wake = actions.author_private<TaskForceWakeAction, TaskActionPrivate>(sealed);
  256. }
  257. pub.refresh = actions.author_private<RefreshTaskDataAction, TaskActionPrivate>(sealed, "GET");
  258. pub.attach_rule = actions.author_private<AttachTaskRuleAction, TaskActionPrivate>(sealed);
  259. pub.attach_capability = actions.author_private<AttachTaskCapabilityAction, TaskActionPrivate>(sealed);
  260. // Web Push notify-me state (plan 11 task E): the signed-in
  261. // user's per-task opt-in for start/failure/completion.
  262. if (viewer_id > 0) {
  263. pub.notify_me = yield push.has_notify_pref(db, viewer_id, task.id);
  264. }
  265. pub.toggle_notify = actions.author_private<ToggleTaskNotifyAction, TaskActionPrivate>(sealed);
  266. var comments = new Vector<CommentView>();
  267. foreach (var comment in ((!)detail).comments) {
  268. // Plan 16 S5 (U2): mechanical rows (restores, park
  269. // notices) never reach the user-facing thread — they
  270. // live in the raw terminal log only.
  271. if (comment.author == COMMENT_AUTHOR_SYSTEM
  272. && comment_is_mechanical(comment.body)) {
  273. continue;
  274. }
  275. comments.add(new CommentView() {
  276. author = comment.author,
  277. body = comment.body,
  278. quote = comment.quote ?? "",
  279. when_display = format_stamp(comment.created_at),
  280. from_agent = comment.author == "agent",
  281. is_system = comment.author == COMMENT_AUTHOR_SYSTEM
  282. });
  283. }
  284. var progress = new Vector<ProgressView>();
  285. foreach (var row in ((!)detail).progress) {
  286. progress.add(new ProgressView() {
  287. id = row.id,
  288. summary = row.summary,
  289. when_display = format_stamp(row.created_at)
  290. });
  291. }
  292. // The festive yeehaw card (saddle yeehaw): newest entries
  293. // first, capped at YEEHAW_CARD_MAX with a "+N more" hint;
  294. // pub.yeehaw is the status-card-corner badge's presence.
  295. var yeehaw_feed = yield tasks.yeehaw_feed(task.id);
  296. pub.yeehaw = yeehaw_feed.total > 0;
  297. pub.yeehaw_total = yeehaw_feed.total;
  298. pub.yeehaw_more = (yeehaw_feed.total - yeehaw_feed.latest.length).clamp(0, int64.MAX);
  299. var yeehaws = new Vector<YeehawView>();
  300. foreach (var row in yeehaw_feed.latest) {
  301. yeehaws.add(new YeehawView() {
  302. message = row.message,
  303. when_display = format_stamp(row.at)
  304. });
  305. }
  306. // Newest-first (the ordering sweep): list_blocks itself
  307. // returns newest-first now, so the rows flow straight
  308. // through — the blocking sections lead with the most
  309. // recent question, RFC or review.
  310. var blocks_newest = new Vector<BlockView>();
  311. foreach (var block in ((!)detail).blocks) {
  312. var block_private = new BlockAnswerPrivate() {
  313. task_id = task.id,
  314. block_id = block.id
  315. };
  316. var view = new BlockView() {
  317. id = block.id,
  318. question = block.question,
  319. answer = block.answer ?? "",
  320. block_type = block.block_type,
  321. body = block.body ?? "",
  322. options = EntryJson.parse_string_array(block.options),
  323. answered = block.answer != null,
  324. answered_by = block.answered_by ?? "",
  325. created_display = format_stamp(block.created_at),
  326. answered_display = format_stamp(block.answered_at),
  327. answer_action = actions.author_private<AnswerBlockAction, BlockAnswerPrivate>(block_private)
  328. };
  329. if (!view.answered && view.block_type == BLOCK_REVIEW) {
  330. pub.review_pending = true;
  331. pub.review_summary = view.body.length > 0 ? view.body : view.question;
  332. pub.review_diff_url = yield tasks.diff_url_for_task(task.id);
  333. }
  334. // The pending review banner renders the markdown itself
  335. // (the review summary), so the block's own body preview —
  336. // the same text — stays hidden until the block is answered.
  337. view.show_body = view.block_type != BLOCK_REVIEW || view.answered;
  338. blocks_newest.add(view);
  339. }
  340. var blocks = new Vector<BlockView>();
  341. var open_blocks = new Vector<BlockView>();
  342. var answered_blocks = new Vector<BlockView>();
  343. foreach (var view in blocks_newest) {
  344. blocks.add(view);
  345. // Plan 11 task D: the current (unanswered) blocks render
  346. // in full; the historical ones fold behind an expander —
  347. // both groups keep the newest-first order.
  348. if (view.answered) {
  349. answered_blocks.add(view);
  350. } else {
  351. open_blocks.add(view);
  352. }
  353. }
  354. // Plan 12: the auto-enqueue-after dropdown lists every
  355. // OTHER standalone task (any status eligible as
  356. // predecessor); goal tasks are excluded — the goal agent
  357. // sequences its own. The dropdown's own query loads
  358. // exactly those rows, not every task on every pulse.
  359. var enqueue_options = new Vector<TaskOptionView>();
  360. string enqueue_after_title = "";
  361. foreach (var row in yield tasks.list_standalone_tasks()) {
  362. if (row.id == task.id) {
  363. continue;
  364. }
  365. enqueue_options.add(new TaskOptionView() {
  366. id = row.id,
  367. title = row.title,
  368. status = row.status
  369. });
  370. if (row.id == task.enqueue_after_task_id) {
  371. enqueue_after_title = row.title;
  372. }
  373. }
  374. pub.enqueue_after_title = enqueue_after_title;
  375. // The goal reference section (goal work only).
  376. if (task.goal_id > 0) {
  377. var goal_row = yield db.query<Goal>()
  378. .where(expr("id == $0", elem<int64?>(task.goal_id)))
  379. .first_async();
  380. if (goal_row != null) {
  381. pub.goal_title = ((!)goal_row).title;
  382. pub.goal_status = ((!)goal_row).status;
  383. pub.goal_branch = ((!)goal_row).branch_name;
  384. }
  385. }
  386. var task_rules = new Vector<TaskRuleRowView>();
  387. foreach (var link in yield rules.list_task_attachments(task.id)) {
  388. var rule = yield rules.get_rule(link.rule_id);
  389. if (rule == null) {
  390. continue;
  391. }
  392. var rule_private = new TaskRuleActionPrivate() {
  393. task_id = task.id,
  394. link_id = link.id
  395. };
  396. task_rules.add(new TaskRuleRowView() {
  397. link_id = link.id,
  398. rule_id = link.rule_id,
  399. name = ((!)rule).name,
  400. body = ((!)rule).body,
  401. detach = actions.author_private<DetachTaskRuleAction, TaskRuleActionPrivate>(rule_private)
  402. });
  403. }
  404. var rule_options = new Vector<RuleOptionView>();
  405. foreach (var rule in yield rules.list_rules()) {
  406. rule_options.add(new RuleOptionView() {
  407. id = rule.id,
  408. name = rule.name
  409. });
  410. }
  411. var task_capabilities = new Vector<TaskCapabilityRowView>();
  412. foreach (var link in yield capabilities.list_task_attachments(task.id)) {
  413. var capability = yield capabilities.get_capability(link.capability_id);
  414. if (capability == null) {
  415. continue;
  416. }
  417. var capability_private = new TaskCapabilityActionPrivate() {
  418. task_id = task.id,
  419. link_id = link.id
  420. };
  421. task_capabilities.add(new TaskCapabilityRowView() {
  422. link_id = link.id,
  423. name = ((!)capability).name,
  424. detach = actions.author_private<DetachTaskCapabilityAction, TaskCapabilityActionPrivate>(capability_private)
  425. });
  426. }
  427. var capability_options = new Vector<CapabilityOptionView>();
  428. foreach (var capability in yield capabilities.list_capabilities()) {
  429. capability_options.add(new CapabilityOptionView() {
  430. id = capability.id,
  431. name = capability.name
  432. });
  433. }
  434. var sessions = new Vector<SessionView>();
  435. foreach (var session in ((!)detail).sessions) {
  436. var saddle = yield saddles.get_saddle(session.saddle_id);
  437. sessions.add(new SessionView() {
  438. id = session.id,
  439. saddle_name = saddle != null ? ((!)saddle).name : @"saddle #$(session.saddle_id)",
  440. started_display = format_stamp(session.started_at),
  441. ended_display = format_stamp(session.ended_at),
  442. outcome = session.outcome ?? "",
  443. open = session.ended_at == null
  444. });
  445. if (session.ended_at == null && saddle != null) {
  446. string daemon_state = "";
  447. int64 uptime = 0;
  448. int64 activity = 0;
  449. int64 contact_ms = 0;
  450. polls.health_for(session.saddle_id, out daemon_state,
  451. out uptime, out activity, out contact_ms);
  452. if (daemon_state.length > 0) {
  453. pub.saddle_health = @"saddle '$(((!)saddle).name)' — daemon $(daemon_state)";
  454. }
  455. }
  456. // A task stuck working/blocked whose open session sits on
  457. // an offline saddle offers the manual requeue-for-resume
  458. // path (the automatic three-strike abandon only fires
  459. // while the dispatcher still polls the saddle).
  460. if (session.ended_at == null && saddle != null
  461. && ((!)saddle).status == SADDLE_OFFLINE
  462. && (task.status == STATUS_WORKING || task.status == STATUS_BLOCKED
  463. || task.status == STATUS_REVIEW_REQUESTED)) {
  464. pub.resume_available = true;
  465. }
  466. }
  467. var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
  468. props["comments"] = StateJson.list<CommentView>(comments.to_immutable_buffer());
  469. props["progress"] = StateJson.list<ProgressView>(progress.to_immutable_buffer());
  470. props["yeehaws"] = StateJson.list<YeehawView>(yeehaws.to_immutable_buffer());
  471. props["blocks"] = block_views(blocks);
  472. props["open_blocks"] = block_views(open_blocks);
  473. props["answered_blocks"] = block_views(answered_blocks);
  474. props["enqueue_after_options"] = StateJson.list<TaskOptionView>(
  475. enqueue_options.to_immutable_buffer());
  476. props["rules"] = StateJson.list<TaskRuleRowView>(task_rules.to_immutable_buffer());
  477. props["rule_options"] = StateJson.list<RuleOptionView>(rule_options.to_immutable_buffer());
  478. props["capabilities"] = StateJson.list<TaskCapabilityRowView>(task_capabilities.to_immutable_buffer());
  479. props["capability_options"] = StateJson.list<CapabilityOptionView>(capability_options.to_immutable_buffer());
  480. props["sessions"] = StateJson.list<SessionView>(sessions.to_immutable_buffer());
  481. return slot_for(state_service, pub, props);
  482. }
  483. private static InvercargillJson.JsonElement block_views(Vector<BlockView> views)
  484. throws GLib.Error {
  485. return StateJson.list_rich<BlockView>(views.to_immutable_buffer(),
  486. (item, dict) => {
  487. dict["options"] = StateJson.strings(((BlockView) item).options ?? new string[0]);
  488. });
  489. }
  490. private static Slot slot_for(StateService state_service, TaskDetailPublic pub,
  491. PropertyDictionary props) {
  492. return state_service.new_slot(Scope.PAGE, new State() {
  493. type_name = "task",
  494. public_data = props,
  495. private_data = new PropertyDictionary()
  496. });
  497. }
  498. }
  499. /**
  500. * Entrypoint for /tasks/{task_id}: the task detail page — plan,
  501. * progress, blocks (pending ones carry the answer form), the review
  502. * banner, comments thread, goal context, dependencies, session
  503. * history links and the lifecycle actions. The page subscribes to the
  504. * global pulse and re-runs its sealed refresh action whenever task
  505. * state changes, so live updates arrive without polling.
  506. */
  507. public class TaskDetailEntrypoint : StatumEntrypoint {
  508. private TaskService task_service = Inversion.inject<TaskService>();
  509. private ProjectService project_service = Inversion.inject<ProjectService>();
  510. private SaddleService saddle_service = Inversion.inject<SaddleService>();
  511. private RuleService rule_service = Inversion.inject<RuleService>();
  512. private CapabilityService capability_service = Inversion.inject<CapabilityService>();
  513. private SaddlePollState poll_state = Inversion.inject<SaddlePollState>();
  514. private PushService push_service = Inversion.inject<PushService>();
  515. private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
  516. private UserService users = Inversion.inject<UserService>();
  517. public override async DirectiveBuilder handle() throws GLib.Error {
  518. var guard = yield SpryAuth.require_login(directives(), request, users);
  519. if (guard != null) {
  520. return (!)guard;
  521. }
  522. int64 task_id = int64.parse(request.route("task_id") ?? "0");
  523. var me = SpryAuth.current(request);
  524. var state = yield TaskDetailState.build(state_service, task_service,
  525. project_service, saddle_service, rule_service, capability_service,
  526. action_registry, poll_state, push_service, db,
  527. me != null ? ((!)me).user_id : 0, task_id);
  528. // Plan 14 B1: the goal page fully replaces the goal-run
  529. // task's page — deep links (dashboard, notifications,
  530. // timeline) redirect to the goal. The task state still
  531. // hydrates first (the sealed refresh and API readers keep
  532. // their slot); worker tasks render normally.
  533. var detail = yield task_service.get_task(task_id);
  534. if (detail != null && ((!)detail).task.kind == TASK_KIND_GOAL_RUN
  535. && ((!)detail).task.goal_id > 0) {
  536. return directives().set(state)
  537. .navigate(@"/goals/$(((!)detail).task.goal_id)")
  538. .subscribe_global(StatePulse.SLOT_TYPE);
  539. }
  540. return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
  541. }
  542. }
  543. }