TaskDetailEntrypoint.vala 29 KB

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