GoalDetailEntrypoint.vala 54 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Invercargill.Expressions;
  4. using InvercargillJson;
  5. using Spry;
  6. using Spry.Authentication;
  7. using Statum;
  8. using ClankerSaddle.Data;
  9. using ClankerSaddle.Services;
  10. namespace ClankerSaddle {
  11. /** A project link row on the goal page. */
  12. public class GoalLinkView : Object {
  13. public int64 id { get; set; }
  14. public int64 project_id { get; set; }
  15. public string name { get; set; default = ""; }
  16. public string relationship { get; set; default = ""; }
  17. public Statum.Model.ActionDto? remove { get; set; }
  18. }
  19. /**
  20. * One goal block (question/RFC/plan revision/completion) as the
  21. * page renders it — plan 15 C1: the view over the goal-run
  22. * task's own `task_blocks` row (there is no separate goal-side
  23. * row).
  24. */
  25. public class GoalBlockView : Object {
  26. public int64 id { get; set; }
  27. public string question { get; set; default = ""; }
  28. public string block_type { get; set; default = BLOCK_QUESTION; }
  29. public string[] options { get; set; default = {}; }
  30. public string body { get; set; default = ""; }
  31. public bool answered { get; set; default = false; }
  32. public string answer { get; set; default = ""; }
  33. public string answered_at_display { get; set; default = ""; }
  34. public string answered_by { get; set; default = ""; }
  35. public string created_display { get; set; default = ""; }
  36. public Statum.Model.ActionDto? answer_action { get; set; }
  37. /** True while this is THE pending plan review (the plan section renders its body). */
  38. public bool is_plan_review { get; set; default = false; }
  39. /** True while this is THE pending completion review. */
  40. public bool is_completion_review { get; set; default = false; }
  41. /** Whether the block body renders as its own preview (not duplicated by a section). */
  42. public bool show_body { get; set; default = true; }
  43. /** Plan revisions: the answer was the stable approval verdict. */
  44. public bool approved { get; set; default = false; }
  45. }
  46. /**
  47. * One comment on the goal thread (plan 15 C2: the run task's own
  48. * `task_comments` row). {@link is_system} marks the
  49. * harness-authored bookkeeping rows — the distinct muted system
  50. * styling, never an agent row.
  51. */
  52. public class GoalCommentView : Object {
  53. public string author { get; set; default = ""; }
  54. public string body { get; set; default = ""; }
  55. public string quote { get; set; default = ""; }
  56. public string created_display { get; set; default = ""; }
  57. public bool from_agent { get; set; default = false; }
  58. public bool is_system { get; set; default = false; }
  59. }
  60. /** One progress line from the goal agent's runs (`saddle progress-update`). */
  61. public class GoalProgressView : Object {
  62. public int64 id { get; set; }
  63. public string summary { get; set; default = ""; }
  64. public string when_display { get; set; default = ""; }
  65. }
  66. /**
  67. * One supervision-narrative row (plan 15 C3: the DERIVED read-time
  68. * view over the goal's lifecycle stamps, the run task's blocks
  69. * and comments, and the goal's tasks' transitions — no
  70. * goal_events rows exist).
  71. */
  72. public class GoalEventView : Object {
  73. public string kind { get; set; default = ""; }
  74. public string text { get; set; default = ""; }
  75. public int64 task_id { get; set; }
  76. public string at_display { get; set; default = ""; }
  77. }
  78. /** One task row under the goal (the status strip's list). */
  79. public class GoalTaskView : Object {
  80. public int64 id { get; set; }
  81. public string title { get; set; default = ""; }
  82. public string status { get; set; default = "draft"; }
  83. public string display { get; set; default = "draft"; }
  84. public string mode { get; set; default = "implement"; }
  85. public string creator { get; set; default = "human"; }
  86. public string updated_display { get; set; default = ""; }
  87. /** The task's required capability tags (plan 14 B5 row badges). */
  88. public string[] capabilities { get; set; default = {}; }
  89. }
  90. /**
  91. * One effective rule of the goal (plan 14 B5): the rules that
  92. * govern the goal agent and every worker under it — the run
  93. * task's own set, the primary project's, and the co-developed
  94. * links' in link order.
  95. */
  96. public class GoalRuleView : Object {
  97. public int64 id { get; set; }
  98. public string name { get; set; default = ""; }
  99. public string body { get; set; default = ""; }
  100. }
  101. /** One goal-agent run (a session of the goal-run task). */
  102. public class GoalRunView : Object {
  103. public int64 id { get; set; }
  104. public string started_display { get; set; default = ""; }
  105. public string ended_display { get; set; default = ""; }
  106. public bool open { get; set; default = false; }
  107. public string outcome { get; set; default = ""; }
  108. public string terminal_url { get; set; default = ""; }
  109. /** The saddle that hosted the run ("" when unknown). */
  110. public string saddle_name { get; set; default = ""; }
  111. }
  112. /** One branch of the daemon-reported goal push state. */
  113. public class GoalPushBranchView : Object {
  114. public string branch { get; set; default = ""; }
  115. public bool pushed { get; set; default = false; }
  116. }
  117. /** A project option chip (the goal link form's select). */
  118. public class GoalProjectOptionView : Object {
  119. public int64 id { get; set; }
  120. public string name { get; set; default = ""; }
  121. }
  122. /** Public root of the `/goals` slot. */
  123. public class GoalsListPublic : Object {
  124. public Statum.Model.ActionDto create { get; set; }
  125. }
  126. /**
  127. * The scalar half of the goal page's public state (the lists ride
  128. * alongside as separate properties; see {@link GoalState.build}).
  129. */
  130. public class GoalPublic : Object {
  131. public bool exists { get; set; }
  132. public int64 id { get; set; }
  133. public int64 project_id { get; set; }
  134. public string project_name { get; set; default = ""; }
  135. public string title { get; set; default = ""; }
  136. public string brief { get; set; default = ""; }
  137. public string branch_name { get; set; default = ""; }
  138. public string base_branch { get; set; default = ""; }
  139. public string status { get; set; default = "draft"; }
  140. public string display { get; set; default = "draft"; }
  141. public int64 priority { get; set; default = 3; }
  142. public string plan { get; set; default = ""; }
  143. public string completion_report { get; set; default = ""; }
  144. public string compare_url { get; set; default = ""; }
  145. public string created_display { get; set; default = ""; }
  146. public string started_display { get; set; default = ""; }
  147. public string completed_display { get; set; default = ""; }
  148. public bool terminal { get; set; }
  149. public int64 draft_tasks { get; set; }
  150. public int64 queued_tasks { get; set; }
  151. public int64 running_tasks { get; set; }
  152. public int64 done_tasks { get; set; }
  153. public int64 failed_tasks { get; set; }
  154. public int64 run_task_id { get; set; }
  155. /** True while a completion report awaits review. */
  156. public bool completion_pending { get; set; }
  157. /** The saddle currently hosting the goal agent ("" while unassigned). */
  158. public string agent_saddle { get; set; default = ""; }
  159. /** Health line for the agent's saddle ("" when none). */
  160. public string saddle_health { get; set; default = ""; }
  161. /** The agent's newest recorded activity line ("" when none). */
  162. public string current_command { get; set; default = ""; }
  163. public string current_command_display { get; set; default = ""; }
  164. /** The current activity's entry has not completed yet. */
  165. public bool current_command_running { get; set; }
  166. /**
  167. * Plan 15 C4: the ONE muted activity line folded into the
  168. * status head (the Current-activity card is gone) —
  169. * "running: `cmd`" for the open round, "parked awaiting <a
  170. * review | a question | its tasks' terminal transitions>"
  171. * for the parked ones ("" when there is nothing to say).
  172. */
  173. public string current_activity { get; set; default = ""; }
  174. public Statum.Model.ActionDto? start { get; set; }
  175. /**
  176. * The draft brief-card editor's save (title/brief/priority):
  177. * authored ONLY while draft — its presence IS the page's
  178. * edit-draft gate, so a started goal renders no editor at all.
  179. */
  180. public Statum.Model.ActionDto? update { get; set; }
  181. public Statum.Model.ActionDto? pause { get; set; }
  182. public Statum.Model.ActionDto? resume { get; set; }
  183. public Statum.Model.ActionDto? cancel { get; set; }
  184. /**
  185. * Plan 14 B1 parity: re-offer a failed goal's supervision (the
  186. * task page's Retry, worded for the run).
  187. */
  188. public Statum.Model.ActionDto? retry_run { get; set; }
  189. /**
  190. * Plan 14 B1 parity: the run is stale/parked on a dead saddle
  191. * — requeue it; the next dispatch restores the workspace.
  192. */
  193. public Statum.Model.ActionDto? requeue_run { get; set; }
  194. /**
  195. * Goal 4 task 1 (Force Wake): the human recovery lever —
  196. * authored only while the goal is active and its run task is
  197. * parked (suspended); pressing it sets the goal force-wake
  198. * flag (GoalService.force_wake_goal holds the guards).
  199. */
  200. public Statum.Model.ActionDto? force_wake { get; set; }
  201. public Statum.Model.ActionDto? comment { get; set; }
  202. public Statum.Model.ActionDto? add_link { get; set; }
  203. /**
  204. * Goal 8 task 2 B: applies a stack's members as goal links.
  205. * Authored while the goal is not terminal (the involved set
  206. * is frozen once complete/failed/cancelled).
  207. */
  208. public Statum.Model.ActionDto? apply_stack { get; set; }
  209. public Vector<StackOptionView> stack_options { get; set; default = new Vector<StackOptionView>(); }
  210. /** The sealed GET refresh action (live pulse rehydration). */
  211. public Statum.Model.ActionDto? refresh { get; set; }
  212. }
  213. /**
  214. * The dependency bundle every goal-view build threads (plan 16
  215. * R2, arch item 12): the services and registries {@link GoalState}'s
  216. * builders need, constructed once per entrypoint or refresh —
  217. * one object where the 17-parameter populate signature (and
  218. * build's own 10-dependency thread) used to be.
  219. */
  220. public class GoalViewContext : Object {
  221. public GoalService goals { get; set; }
  222. public TaskService tasks { get; set; }
  223. public ProjectService projects { get; set; }
  224. public EventService events { get; set; }
  225. public InvercargillSql.Orm.OrmSession db { get; set; }
  226. public ActionRegistry? actions { get; set; default = null; }
  227. public SaddleService? saddles { get; set; default = null; }
  228. public SaddlePollState? polls { get; set; default = null; }
  229. public RuleService? rules { get; set; default = null; }
  230. public StackService? stacks { get; set; default = null; }
  231. public GoalViewContext(GoalService goals, TaskService tasks,
  232. ProjectService projects, EventService events,
  233. InvercargillSql.Orm.OrmSession db,
  234. ActionRegistry? actions = null, SaddleService? saddles = null,
  235. SaddlePollState? polls = null, RuleService? rules = null,
  236. StackService? stacks = null) {
  237. this.goals = goals;
  238. this.tasks = tasks;
  239. this.projects = projects;
  240. this.events = events;
  241. this.db = db;
  242. this.actions = actions;
  243. this.saddles = saddles;
  244. this.polls = polls;
  245. this.rules = rules;
  246. this.stacks = stacks;
  247. }
  248. }
  249. /**
  250. * A built goal view (plan 16 R2, arch item 12): the public scalar
  251. * half plus the section lists the goal page serialises —
  252. * populate's out-params became fields.
  253. */
  254. public class GoalView : Object {
  255. public GoalPublic pub { get; set; default = new GoalPublic(); }
  256. public Vector<GoalLinkView> links { get; set; default = new Vector<GoalLinkView>(); }
  257. public Vector<GoalBlockView> blocks { get; set; default = new Vector<GoalBlockView>(); }
  258. public Vector<GoalBlockView> blocks_open { get; set; default = new Vector<GoalBlockView>(); }
  259. public Vector<GoalBlockView> blocks_answered { get; set; default = new Vector<GoalBlockView>(); }
  260. public Vector<GoalTaskView> goal_tasks { get; set; default = new Vector<GoalTaskView>(); }
  261. public Vector<GoalEventView> events { get; set; default = new Vector<GoalEventView>(); }
  262. public Vector<GoalCommentView> comments { get; set; default = new Vector<GoalCommentView>(); }
  263. public Vector<GoalRunView> runs { get; set; default = new Vector<GoalRunView>(); }
  264. public Vector<GoalPushBranchView> push_branches { get; set; default = new Vector<GoalPushBranchView>(); }
  265. public Vector<GoalProgressView> progress { get; set; default = new Vector<GoalProgressView>(); }
  266. }
  267. /**
  268. * State builder for the goal surfaces: fills a {@link GoalView}
  269. * from a goal row — {@link GoalState.full_view} builds the goal
  270. * page's whole aggregate (narrative, runs, sections), {@link
  271. * GoalState.summary_view} the goals-list row's scalars (the old
  272. * `full` flag became the two methods).
  273. */
  274. public class GoalState : Object {
  275. /**
  276. * Builds the goal page's whole slot from `goal_id` — the
  277. * shape the entrypoint sets and the sealed refresh action
  278. * re-hydrates. `exists` is false when the goal is missing.
  279. */
  280. public static async Statum.Slot build(int64 goal_id, GoalViewContext ctx,
  281. StateService state_service) throws GLib.Error {
  282. var view = new GoalView();
  283. GoalBlockView? plan_pending = null;
  284. GoalBlockView? completion_pending = null;
  285. var plan_revisions = new Vector<GoalBlockView>();
  286. var project_options = new Vector<GoalProjectOptionView>();
  287. var effective_rules = new Vector<GoalRuleView>();
  288. var goal = yield ctx.goals.get_goal(goal_id);
  289. if (goal != null) {
  290. view = yield full_view((!)goal, ctx);
  291. foreach (var block in view.blocks) {
  292. if (block.is_plan_review && plan_pending == null) {
  293. plan_pending = block;
  294. }
  295. if (block.is_completion_review && completion_pending == null) {
  296. completion_pending = block;
  297. }
  298. if (block.block_type == BLOCK_PLAN) {
  299. plan_revisions.add(block);
  300. }
  301. }
  302. foreach (var project in yield ctx.projects.list_projects()) {
  303. if (project.id != ((!)goal).project_id) {
  304. project_options.add(new GoalProjectOptionView() {
  305. id = project.id,
  306. name = project.name
  307. });
  308. }
  309. }
  310. // Goal 8 task 2 B: the stacks appliable to this goal.
  311. if (ctx.stacks != null) {
  312. foreach (var stack in yield ((!)ctx.stacks).list_stacks()) {
  313. view.pub.stack_options.add(new StackOptionView() {
  314. id = stack.id,
  315. name = stack.name,
  316. member_count = (yield ((!)ctx.stacks).list_members(stack.id)).length
  317. });
  318. }
  319. }
  320. // Plan 14 B5: the goal's effective rules — the set a
  321. // dispatch ships for the run (and every worker):
  322. // run-task rules, the primary project's, then the
  323. // co-developed goal links' in link order.
  324. if (ctx.rules != null) {
  325. var scope_task = new TaskEntity() {
  326. id = view.pub.run_task_id,
  327. project_id = ((!)goal).project_id
  328. };
  329. foreach (var rule in yield ((!)ctx.rules).effective_rules_for(
  330. scope_task, ctx.projects, ((!)goal).id)) {
  331. effective_rules.add(new GoalRuleView() {
  332. id = rule.id,
  333. name = rule.name,
  334. body = rule.body
  335. });
  336. }
  337. }
  338. }
  339. if (ctx.actions != null && goal != null) {
  340. view.pub.refresh = ((!)ctx.actions)
  341. .author_private<RefreshGoalDataAction, GoalActionPrivate>(
  342. new GoalActionPrivate() { goal_id = goal_id }, "GET");
  343. }
  344. var pub_root = (PropertyDictionary) GObjectMapping.to_properties(view.pub);
  345. pub_root["links"] = StateJson.list<GoalLinkView>(view.links.to_immutable_buffer());
  346. pub_root["blocks"] = StateJson.list_rich<GoalBlockView>(view.blocks.to_immutable_buffer(),
  347. (item, dict) => {
  348. dict["options"] = StateJson.strings(
  349. ((GoalBlockView) item).options ?? new string[0]);
  350. });
  351. pub_root["open_blocks"] = StateJson.list_rich<GoalBlockView>(
  352. view.blocks_open.to_immutable_buffer(),
  353. (item, dict) => {
  354. dict["options"] = StateJson.strings(
  355. ((GoalBlockView) item).options ?? new string[0]);
  356. });
  357. pub_root["answered_blocks"] = StateJson.list_rich<GoalBlockView>(
  358. view.blocks_answered.to_immutable_buffer(),
  359. (item, dict) => {
  360. dict["options"] = StateJson.strings(
  361. ((GoalBlockView) item).options ?? new string[0]);
  362. });
  363. pub_root["goal_tasks"] = StateJson.list_rich<GoalTaskView>(
  364. view.goal_tasks.to_immutable_buffer(),
  365. (item, dict) => {
  366. dict["capabilities"] = StateJson.strings(
  367. ((GoalTaskView) item).capabilities ?? new string[0]);
  368. });
  369. pub_root["events"] = StateJson.list<GoalEventView>(view.events.to_immutable_buffer());
  370. pub_root["comments"] = StateJson.list<GoalCommentView>(view.comments.to_immutable_buffer());
  371. pub_root["runs"] = StateJson.list<GoalRunView>(view.runs.to_immutable_buffer());
  372. pub_root["progress"] = StateJson.list<GoalProgressView>(
  373. view.progress.to_immutable_buffer());
  374. pub_root["push_branches"] = StateJson.list<GoalPushBranchView>(
  375. view.push_branches.to_immutable_buffer());
  376. pub_root["plan_revisions"] = StateJson.list<GoalBlockView>(
  377. plan_revisions.to_immutable_buffer());
  378. pub_root["rules"] = StateJson.list<GoalRuleView>(
  379. effective_rules.to_immutable_buffer());
  380. pub_root["project_options"] = StateJson.list<GoalProjectOptionView>(
  381. project_options.to_immutable_buffer());
  382. pub_root["stack_options"] = StateJson.list<StackOptionView>(
  383. view.pub.stack_options.to_immutable_buffer());
  384. pub_root["plan_pending_block"] = plan_pending != null
  385. ? new JsonElement.from_properties(
  386. (PropertyDictionary) GObjectMapping.to_properties((!)plan_pending))
  387. : new JsonElement.from_element(new NativeElement<bool>(false));
  388. pub_root["completion_pending_block"] = completion_pending != null
  389. ? new JsonElement.from_properties(
  390. (PropertyDictionary) GObjectMapping.to_properties((!)completion_pending))
  391. : new JsonElement.from_element(new NativeElement<bool>(false));
  392. return state_service.new_slot(Scope.PAGE, new Statum.State() {
  393. type_name = "goal",
  394. public_data = pub_root,
  395. private_data = new PropertyDictionary()
  396. });
  397. }
  398. /**
  399. * The goals-list row's view (the old `full=false` half): the
  400. * {@link GoalPublic} scalars alone — display fields, the
  401. * action DTOs, the completion-pending flag, the run-task id
  402. * and the task counts. The section lists stay empty: the row
  403. * reads none of them, and the old call threaded thirteen
  404. * throwaway vectors (and their per-task display/capability
  405. * queries) through the full builder to fill views nobody
  406. * serialised.
  407. */
  408. public static async GoalView summary_view(Goal goal,
  409. GoalViewContext ctx) throws GLib.Error {
  410. var view = new GoalView();
  411. var pub = view.pub;
  412. pub.exists = true;
  413. pub.id = goal.id;
  414. pub.project_id = goal.project_id;
  415. pub.title = goal.title;
  416. pub.brief = goal.brief;
  417. pub.branch_name = goal.branch_name;
  418. pub.base_branch = goal.base_branch ?? "";
  419. pub.status = goal.status;
  420. // Plan 16 S5 (U3): a goal awaiting its completion review
  421. // reads "awaiting your review" — the run-task status card
  422. // agrees (no bare "blocked" for review parks).
  423. pub.display = goal.status == GOAL_COMPLETION_REVIEW
  424. ? "awaiting your review" : goal.status.replace("_", " ");
  425. pub.priority = goal.priority;
  426. pub.plan = goal.plan ?? "";
  427. pub.completion_report = goal.completion_report ?? "";
  428. pub.created_display = format_stamp(goal.created_at);
  429. pub.started_display = format_stamp(goal.started_at);
  430. pub.completed_display = format_stamp(goal.completed_at);
  431. pub.terminal = goal.status == GOAL_COMPLETE || goal.status == GOAL_FAILED
  432. || goal.status == GOAL_CANCELLED;
  433. var project = yield ctx.db.query<Project>()
  434. .where(expr("id == $0", elem<int64?>(goal.project_id)))
  435. .first_async();
  436. if (project != null) {
  437. pub.project_name = ((!)project).name;
  438. string base_name = goal.base_branch != null && ((!)goal.base_branch).length > 0
  439. ? ((!)goal.base_branch) : ((!)project).default_branch;
  440. pub.compare_url = @"$(TaskService.https_git_url(((!)project).git_url))/compare/$(base_name)...$(goal.branch_name)";
  441. }
  442. if (ctx.actions != null) {
  443. var sealed = new GoalActionPrivate() { goal_id = goal.id };
  444. pub.comment = ((!)ctx.actions).author_private<GoalCommentAction, GoalActionPrivate>(sealed);
  445. pub.add_link = ((!)ctx.actions).author_private<AddGoalLinkAction, GoalActionPrivate>(sealed);
  446. if (!pub.terminal) {
  447. pub.apply_stack = ((!)ctx.actions).author_private<ApplyStackToGoalAction, GoalActionPrivate>(sealed);
  448. }
  449. if (goal.status == GOAL_DRAFT) {
  450. pub.start = ((!)ctx.actions).author_private<StartGoalAction, GoalActionPrivate>(sealed);
  451. pub.update = ((!)ctx.actions).author_private<UpdateDraftGoalAction, GoalActionPrivate>(sealed);
  452. }
  453. if (goal.status == GOAL_ACTIVE) {
  454. pub.pause = ((!)ctx.actions).author_private<PauseGoalAction, GoalActionPrivate>(sealed);
  455. }
  456. if (goal.status == GOAL_PAUSED) {
  457. pub.resume = ((!)ctx.actions).author_private<ResumeGoalAction, GoalActionPrivate>(sealed);
  458. }
  459. if (!pub.terminal) {
  460. pub.cancel = ((!)ctx.actions).author_private<CancelGoalAction, GoalActionPrivate>(sealed);
  461. }
  462. }
  463. foreach (var block in yield ctx.goals.list_blocks(goal.id)) {
  464. if (block.answer == null
  465. && (block.block_type == BLOCK_COMPLETION
  466. || block.block_type == BLOCK_REVIEW)) {
  467. pub.completion_pending = true;
  468. break;
  469. }
  470. }
  471. foreach (var task in yield ctx.tasks.list_tasks_for_goal(goal.id)) {
  472. if (task.kind == TASK_KIND_GOAL_RUN) {
  473. pub.run_task_id = task.id;
  474. continue;
  475. }
  476. switch (task.status) {
  477. case STATUS_DRAFT:
  478. pub.draft_tasks++; break;
  479. case STATUS_QUEUED:
  480. case STATUS_ASSIGNED:
  481. pub.queued_tasks++; break;
  482. case STATUS_WORKING:
  483. case STATUS_BLOCKED:
  484. case STATUS_REVIEW_REQUESTED:
  485. case STATUS_SUSPENDED:
  486. pub.running_tasks++; break;
  487. case STATUS_DONE:
  488. pub.done_tasks++; break;
  489. case STATUS_FAILED:
  490. pub.failed_tasks++; break;
  491. default:
  492. break;
  493. }
  494. }
  495. return view;
  496. }
  497. /**
  498. * The goal page's whole view (the old `full=true` half): the
  499. * public scalars plus every section list — links, blocks with
  500. * the open/answered split, task rows with their computed
  501. * displays, the run-control actions, the narrative, the
  502. * comment thread, the runs, the live activity and the push
  503. * chips.
  504. */
  505. public static async GoalView full_view(Goal goal,
  506. GoalViewContext ctx) throws GLib.Error {
  507. var view = new GoalView();
  508. var pub = view.pub;
  509. pub.exists = true;
  510. pub.id = goal.id;
  511. pub.project_id = goal.project_id;
  512. pub.title = goal.title;
  513. pub.brief = goal.brief;
  514. pub.branch_name = goal.branch_name;
  515. pub.base_branch = goal.base_branch ?? "";
  516. pub.status = goal.status;
  517. // Plan 16 S5 (U3): a goal awaiting its completion review
  518. // reads "awaiting your review" — the run-task status card
  519. // agrees (no bare "blocked" for review parks).
  520. pub.display = goal.status == GOAL_COMPLETION_REVIEW
  521. ? "awaiting your review" : goal.status.replace("_", " ");
  522. pub.priority = goal.priority;
  523. pub.plan = goal.plan ?? "";
  524. pub.completion_report = goal.completion_report ?? "";
  525. pub.created_display = format_stamp(goal.created_at);
  526. pub.started_display = format_stamp(goal.started_at);
  527. pub.completed_display = format_stamp(goal.completed_at);
  528. pub.terminal = goal.status == GOAL_COMPLETE || goal.status == GOAL_FAILED
  529. || goal.status == GOAL_CANCELLED;
  530. var project = yield ctx.db.query<Project>()
  531. .where(expr("id == $0", elem<int64?>(goal.project_id)))
  532. .first_async();
  533. if (project != null) {
  534. pub.project_name = ((!)project).name;
  535. string base_name = goal.base_branch != null && ((!)goal.base_branch).length > 0
  536. ? ((!)goal.base_branch) : ((!)project).default_branch;
  537. pub.compare_url = @"$(TaskService.https_git_url(((!)project).git_url))/compare/$(base_name)...$(goal.branch_name)";
  538. }
  539. if (ctx.actions != null) {
  540. var sealed = new GoalActionPrivate() { goal_id = goal.id };
  541. pub.comment = ((!)ctx.actions).author_private<GoalCommentAction, GoalActionPrivate>(sealed);
  542. pub.add_link = ((!)ctx.actions).author_private<AddGoalLinkAction, GoalActionPrivate>(sealed);
  543. if (!pub.terminal) {
  544. pub.apply_stack = ((!)ctx.actions).author_private<ApplyStackToGoalAction, GoalActionPrivate>(sealed);
  545. }
  546. if (goal.status == GOAL_DRAFT) {
  547. pub.start = ((!)ctx.actions).author_private<StartGoalAction, GoalActionPrivate>(sealed);
  548. pub.update = ((!)ctx.actions).author_private<UpdateDraftGoalAction, GoalActionPrivate>(sealed);
  549. }
  550. if (goal.status == GOAL_ACTIVE) {
  551. pub.pause = ((!)ctx.actions).author_private<PauseGoalAction, GoalActionPrivate>(sealed);
  552. }
  553. if (goal.status == GOAL_PAUSED) {
  554. pub.resume = ((!)ctx.actions).author_private<ResumeGoalAction, GoalActionPrivate>(sealed);
  555. }
  556. if (!pub.terminal) {
  557. pub.cancel = ((!)ctx.actions).author_private<CancelGoalAction, GoalActionPrivate>(sealed);
  558. }
  559. }
  560. foreach (var link in yield ctx.goals.list_links(goal.id)) {
  561. var link_view = new GoalLinkView() {
  562. id = link.id,
  563. project_id = link.project_id,
  564. relationship = link.relationship,
  565. remove = ctx.actions != null
  566. ? ((!)ctx.actions).author_private<RemoveGoalLinkAction, GoalActionPrivate>(
  567. new GoalActionPrivate() { goal_id = goal.id })
  568. : null
  569. };
  570. var linked = yield ctx.db.query<Project>()
  571. .where(expr("id == $0", elem<int64?>(link.project_id)))
  572. .first_async();
  573. if (linked != null) {
  574. link_view.name = ((!)linked).name;
  575. }
  576. view.links.add(link_view);
  577. }
  578. bool completion_pending = false;
  579. bool plan_pending_open = false;
  580. // Plan 15 C1: the goal's blocks ARE the goal-run task's
  581. // blocks — one row per question, answered in place.
  582. foreach (var block in yield ctx.goals.list_blocks(goal.id)) {
  583. bool pending_plan = block.block_type == BLOCK_PLAN
  584. && block.answer == null;
  585. // The completion review may ride either kind: the web's
  586. // completion raise or the daemon's done-park review block
  587. // (adopted — one row, whichever landed first).
  588. bool pending_completion = block.answer == null
  589. && (block.block_type == BLOCK_COMPLETION
  590. || block.block_type == BLOCK_REVIEW);
  591. var block_view = new GoalBlockView() {
  592. id = block.id,
  593. question = block.question,
  594. block_type = block.block_type,
  595. options = EntryJson.parse_string_array(block.options),
  596. body = block.body ?? "",
  597. answered = block.answer != null,
  598. answer = block.answer ?? "",
  599. answered_at_display = format_stamp(block.answered_at),
  600. answered_by = block.answered_by ?? "",
  601. created_display = format_stamp(block.created_at),
  602. is_plan_review = pending_plan,
  603. is_completion_review = pending_completion,
  604. // A pending plan hides its body preview (the plan
  605. // section renders it); the pending completion review
  606. // SHOWS its body — the block IS the review surface.
  607. show_body = !pending_plan,
  608. approved = block.answer != null
  609. && block.answer == NOTICE_PLAN_APPROVED
  610. };
  611. if (block.answer == null && ctx.actions != null) {
  612. block_view.answer_action = ((!)ctx.actions)
  613. .author_private<AnswerGoalBlockAction, GoalBlockAnswerPrivate>(
  614. new GoalBlockAnswerPrivate() {
  615. goal_id = goal.id,
  616. block_id = block.id
  617. });
  618. }
  619. if (pending_completion) {
  620. completion_pending = true;
  621. }
  622. if (pending_plan) {
  623. plan_pending_open = true;
  624. }
  625. // Newest-first: list_blocks leads with the latest row,
  626. // so appending preserves that order in the view.
  627. view.blocks.add(block_view);
  628. }
  629. pub.completion_pending = completion_pending;
  630. // Plan 15 C4: the current (unanswered) question/RFC blocks
  631. // render in full; the historic answered ones fold behind
  632. // the answered-blocks expander (the task page's pattern).
  633. // The rows already arrive newest-first ({@link
  634. // TaskService.list_blocks} since the ordering sweep), so a
  635. // straight walk keeps both groups newest-first.
  636. foreach (var block_view in view.blocks) {
  637. if (block_view.answered) {
  638. view.blocks_answered.add(block_view);
  639. } else if (block_view.is_plan_review) {
  640. // rendered by the plan section
  641. } else {
  642. // Every pending completion-family row renders (the
  643. // raise-side cross-dedupe keeps them to one —
  644. // fresh databases cannot hold pre-dedupe twins, so
  645. // no render-side guard is needed).
  646. view.blocks_open.add(block_view);
  647. }
  648. }
  649. var goal_task_rows = new Vector<TaskEntity>();
  650. foreach (var task in yield ctx.tasks.list_tasks_for_goal(goal.id)) {
  651. if (task.kind == TASK_KIND_GOAL_RUN) {
  652. pub.run_task_id = task.id;
  653. } else {
  654. goal_task_rows.add(task);
  655. }
  656. }
  657. // The rows consume TaskService.display_map_for directly
  658. // (the same computed display the queue and the dashboard read).
  659. var display_map = yield ctx.tasks.display_map_for(goal_task_rows.to_immutable_buffer());
  660. foreach (var task in goal_task_rows) {
  661. var task_view = new GoalTaskView() {
  662. id = task.id,
  663. title = task.title,
  664. status = task.status,
  665. mode = task.mode,
  666. creator = task.creator,
  667. updated_display = format_stamp(task.completed_at ?? task.created_at)
  668. };
  669. // Plan 14 B5: the row's required-capability badges —
  670. // read straight off the task_capabilities links (no
  671. // editing here).
  672. var task_caps = new Vector<string>();
  673. foreach (var link in yield ctx.db.query<TaskCapability>()
  674. .where(expr("task_id == $0", elem<int64?>(task.id)))
  675. .order_by(expr("id"))
  676. .materialise_async()) {
  677. var capability = yield ctx.db.query<Capability>()
  678. .where(expr("id == $0", elem<int64?>(link.capability_id)))
  679. .first_async();
  680. if (capability != null) {
  681. task_caps.add(((!)capability).name);
  682. }
  683. }
  684. task_view.capabilities = task_caps.to_array();
  685. var display = display_map.get_or_default(task.id.to_string());
  686. task_view.display = display != null ? ((!)display).status : task.status;
  687. view.goal_tasks.add(task_view);
  688. switch (task.status) {
  689. case STATUS_DRAFT:
  690. pub.draft_tasks++; break;
  691. case STATUS_QUEUED:
  692. case STATUS_ASSIGNED:
  693. pub.queued_tasks++; break;
  694. case STATUS_WORKING:
  695. case STATUS_BLOCKED:
  696. case STATUS_REVIEW_REQUESTED:
  697. case STATUS_SUSPENDED:
  698. pub.running_tasks++; break;
  699. case STATUS_DONE:
  700. pub.done_tasks++; break;
  701. case STATUS_FAILED:
  702. pub.failed_tasks++; break;
  703. default:
  704. break;
  705. }
  706. }
  707. // Plan 14 B1: run-control parity on the status head —
  708. // "Retry supervision" for a failed goal, requeue-to-resume
  709. // for a run stale/parked on a dead saddle (NO notify-me
  710. // toggle: goals notify unconditionally by design).
  711. if (ctx.actions != null && pub.run_task_id > 0) {
  712. var run_row = yield ctx.db.query<TaskEntity>()
  713. .where(expr("id == $0", elem<int64?>(pub.run_task_id)))
  714. .first_async();
  715. if (run_row != null) {
  716. string run_status = ((!)run_row).status;
  717. if (goal.status == GOAL_FAILED && run_status == STATUS_FAILED) {
  718. pub.retry_run = ((!)ctx.actions)
  719. .author_private<RetryGoalRunAction, GoalActionPrivate>(
  720. new GoalActionPrivate() { goal_id = goal.id });
  721. }
  722. bool stale_or_parked = run_status == STATUS_WORKING
  723. || run_status == STATUS_BLOCKED
  724. || run_status == STATUS_REVIEW_REQUESTED
  725. || run_status == STATUS_SUSPENDED;
  726. // Goal 4 task 1: Force wake — the run is parked
  727. // on a LIVE saddle under an ACTIVE goal (the
  728. // paused-goal recovery is Resume, the dead-saddle
  729. // one Requeue-to-resume; Force wake covers the
  730. // parked-with-no-pending-wake stall, goal 2).
  731. if (goal.status == GOAL_ACTIVE && run_status == STATUS_SUSPENDED) {
  732. pub.force_wake = ((!)ctx.actions)
  733. .author_private<GoalForceWakeAction, GoalActionPrivate>(
  734. new GoalActionPrivate() { goal_id = goal.id });
  735. }
  736. if (!pub.terminal && stale_or_parked) {
  737. // Plan 16 D4: a parked round's session is
  738. // closed 'parked' — the newest record (open or
  739. // parked) still names the workspace's saddle.
  740. var run_session = yield ctx.tasks.newest_session_for_task(pub.run_task_id);
  741. if (run_session != null && (((!)run_session).ended_at == null
  742. || ((!)run_session).outcome == OUTCOME_PARKED)) {
  743. var host = yield ctx.db.query<Saddle>()
  744. .where(expr("id == $0",
  745. elem<int64?>(((!)run_session).saddle_id)))
  746. .first_async();
  747. if (host != null && ((!)host).status == SADDLE_OFFLINE) {
  748. pub.requeue_run = ((!)ctx.actions)
  749. .author_private<RequeueGoalRunAction, GoalActionPrivate>(
  750. new GoalActionPrivate() { goal_id = goal.id });
  751. }
  752. }
  753. }
  754. }
  755. }
  756. // Plan 15 C3: the narrative derives at read time (the
  757. // service returns newest-first rows).
  758. foreach (var row in yield ctx.goals.narrative(goal.id)) {
  759. view.events.add(new GoalEventView() {
  760. kind = row.kind,
  761. text = row.text,
  762. task_id = row.task_id,
  763. at_display = format_stamp(row.at)
  764. });
  765. }
  766. // Plan 15 C2: the goal's comment thread IS the run
  767. // task's task_comments (injection rides the existing
  768. // delivery paths) — the agent's pushes and the human's
  769. // composer land on the same rows. Plan 16 S5 (U2):
  770. // mechanical rows (restores, park notices) never reach
  771. // the user-facing thread — the audit ledger keeps them.
  772. foreach (var row in yield ctx.goals.list_comments(goal.id)) {
  773. if (row.author == COMMENT_AUTHOR_SYSTEM
  774. && comment_is_mechanical(row.body)) {
  775. continue;
  776. }
  777. view.comments.add(new GoalCommentView() {
  778. author = row.author,
  779. body = row.body,
  780. quote = row.quote ?? "",
  781. created_display = format_stamp(row.created_at),
  782. from_agent = row.author == "agent",
  783. is_system = row.author == COMMENT_AUTHOR_SYSTEM
  784. });
  785. }
  786. int64 open_session_saddle = 0;
  787. if (pub.run_task_id > 0) {
  788. // The goal agent's live progress feed (green, task-page
  789. // pattern) — the run task's own progress lines.
  790. foreach (var row in yield ctx.tasks.list_progress(pub.run_task_id)) {
  791. view.progress.add(new GoalProgressView() {
  792. id = row.id,
  793. summary = row.summary,
  794. when_display = format_stamp(row.created_at)
  795. });
  796. }
  797. var session_rows = yield ctx.db.query<ClankerSaddle.Data.Session>()
  798. .where(expr("task_id == $0", elem<int64?>(pub.run_task_id)))
  799. .order_by_desc(expr("id"))
  800. .materialise_async();
  801. foreach (var session in session_rows) {
  802. string run_saddle = "";
  803. if (ctx.saddles != null) {
  804. var saddle_row = yield ((!)ctx.saddles).get_saddle(session.saddle_id);
  805. run_saddle = saddle_row != null
  806. ? ((!)saddle_row).name : @"saddle #$(session.saddle_id)";
  807. }
  808. view.runs.add(new GoalRunView() {
  809. id = session.id,
  810. started_display = format_stamp(session.started_at),
  811. ended_display = format_stamp(session.ended_at),
  812. open = session.ended_at == null,
  813. outcome = session.outcome ?? "",
  814. saddle_name = run_saddle,
  815. // Deep-links this run's terminal view: the
  816. // picker's client-side ?session_id=
  817. // selection lands on exactly this session.
  818. terminal_url = @"/tasks/$(pub.run_task_id)/log"
  819. });
  820. }
  821. // Current activity (task-page parity): the live round's
  822. // hosting saddle, its health and the newest recorded
  823. // activity line. Plan 16 D4: a PARKED round keeps its
  824. // panel — its session row closed 'parked' at the park,
  825. // and the newest record (open or parked) is still the
  826. // round the page is describing.
  827. var open = yield ctx.tasks.newest_session_for_task(pub.run_task_id);
  828. if (open != null && (((!)open).ended_at != null
  829. && ((!)open).outcome != OUTCOME_PARKED)) {
  830. open = null;
  831. }
  832. if (open != null) {
  833. open_session_saddle = ((!)open).saddle_id;
  834. if (ctx.saddles != null) {
  835. var saddle_row = yield ((!)ctx.saddles).get_saddle(((!)open).saddle_id);
  836. if (saddle_row != null) {
  837. pub.agent_saddle = ((!)saddle_row).name;
  838. if (ctx.polls != null) {
  839. string daemon_state = "";
  840. int64 uptime = 0;
  841. int64 activity = 0;
  842. int64 contact_ms = 0;
  843. ((!)ctx.polls).health_for(((!)saddle_row).id, out daemon_state,
  844. out uptime, out activity, out contact_ms);
  845. if (daemon_state.length > 0) {
  846. pub.saddle_health = @"saddle '$(((!)saddle_row).name)' — daemon $(daemon_state)";
  847. }
  848. }
  849. }
  850. }
  851. var newest = yield ctx.events.newest_activity(pub.run_task_id);
  852. if (newest != null) {
  853. string line = activity_line_of((!)newest);
  854. if (line.length > 0) {
  855. pub.current_command = clip_line(line, 200);
  856. pub.current_command_display = format_stamp(
  857. ((!)newest).created_at);
  858. pub.current_command_running = true;
  859. }
  860. }
  861. }
  862. }
  863. // The one muted activity line (plan 15 C4): what the run is
  864. // doing right now — running the open round, or parked
  865. // awaiting a review / a question / its tasks.
  866. var activity_run = pub.run_task_id > 0
  867. ? yield ctx.db.query<TaskEntity>()
  868. .where(expr("id == $0", elem<int64?>(pub.run_task_id)))
  869. .first_async() : null;
  870. if (activity_run != null) {
  871. if (((!)activity_run).status == STATUS_WORKING
  872. || ((!)activity_run).status == STATUS_ASSIGNED) {
  873. if (pub.current_command.length > 0) {
  874. pub.current_activity = pub.current_command_running
  875. ? @"running: `$(pub.current_command)`"
  876. : @"last: `$(pub.current_command)`";
  877. }
  878. } else if (((!)activity_run).status == STATUS_BLOCKED
  879. || ((!)activity_run).status == STATUS_REVIEW_REQUESTED) {
  880. if (completion_pending) {
  881. pub.current_activity = "parked awaiting the completion review";
  882. } else if (plan_pending_open) {
  883. pub.current_activity = "parked awaiting the plan review";
  884. } else if (view.blocks_open.length > 0) {
  885. pub.current_activity = "parked awaiting a question";
  886. } else {
  887. pub.current_activity = "parked";
  888. }
  889. } else if (((!)activity_run).status == STATUS_SUSPENDED) {
  890. pub.current_activity =
  891. "parked awaiting its tasks' terminal transitions";
  892. }
  893. }
  894. // Plan 15 C4: the push chips derive from the LIVE
  895. // daemon-reported per-repo flags of the run's hosting
  896. // saddle (the last /status probe) — never a stored
  897. // snapshot. A restart with no probe yet renders none.
  898. if (open_session_saddle > 0 && ctx.polls != null) {
  899. string flags = ((!)ctx.polls).goal_branches_for(open_session_saddle);
  900. if (flags.length > 0) {
  901. // The SAME parser the completion-accept gate reads
  902. // (GoalService.parse_goal_branches) feeds the chips —
  903. // gate and display cannot diverge on one payload.
  904. foreach (var row in GoalService.parse_goal_branches(flags)) {
  905. view.push_branches.add(new GoalPushBranchView() {
  906. branch = row.label(),
  907. pushed = row.pushed
  908. });
  909. }
  910. }
  911. }
  912. return view;
  913. }
  914. /** A flattened, clipped activity line (the current-activity display). */
  915. /**
  916. * One ledger activity event as the goal head's current-activity
  917. * line: turn text and progress carry their text, a tool call
  918. * names its tool (the run_command command when present), a
  919. * tool result summarises as "tool result".
  920. */
  921. private static string activity_line_of(EventRecord record) {
  922. var payload = EventService.payload_of(record);
  923. switch (record.kind) {
  924. case EVENT_TURN_TEXT:
  925. case EVENT_PROGRESS:
  926. case EVENT_LIFECYCLE:
  927. return EntryJson.string_member(payload, "text");
  928. case EVENT_COMMENT:
  929. return EntryJson.string_member(payload, "body");
  930. case EVENT_TOOL_CALL:
  931. string tool = EntryJson.string_member(payload, "tool");
  932. var args = payload.has_member("args")
  933. && payload.get_member("args").get_node_type() == Json.NodeType.OBJECT
  934. ? payload.get_object_member("args") : null;
  935. string command = args != null
  936. ? EntryJson.string_member((!)args, "command") : "";
  937. return command.length > 0 ? @"$(tool): $(command)" : tool;
  938. case EVENT_TOOL_RESULT:
  939. return "tool result";
  940. default:
  941. return "";
  942. }
  943. }
  944. private static string clip_line(string text, int limit) {
  945. string flat = text.replace("\n", " ⏎ ").strip();
  946. return flat.length > limit ? flat.substring(0, limit) + "…" : flat;
  947. }
  948. }
  949. /**
  950. * Entrypoint for /goals: the goals dashboard — every goal (newest
  951. * first) with lifecycle badges, branch, priority and task counts,
  952. * plus the create form. The page subscribes to the global pulse
  953. * and re-renders whenever goal or task state changes.
  954. */
  955. public class GoalsEntrypoint : StatumEntrypoint {
  956. private GoalService goal_service = Inversion.inject<GoalService>();
  957. private TaskService task_service = Inversion.inject<TaskService>();
  958. private ProjectService project_service = Inversion.inject<ProjectService>();
  959. private EventService event_service = Inversion.inject<EventService>();
  960. private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
  961. private UserService users = Inversion.inject<UserService>();
  962. public override async DirectiveBuilder handle() throws GLib.Error {
  963. var guard = yield SpryAuth.require_login(directives(), request, users);
  964. if (guard != null) {
  965. return (!)guard;
  966. }
  967. var ctx = new GoalViewContext(goal_service, task_service, project_service,
  968. event_service, db, action_registry);
  969. var rows = new JsonArray();
  970. foreach (var goal in yield goal_service.list_goals()) {
  971. var view = yield GoalState.summary_view(goal, ctx);
  972. var row = (PropertyDictionary) GObjectMapping.to_properties(view.pub);
  973. row["plan_pending"] = new JsonElement.from_element(
  974. new NativeElement<bool>(goal.status == GOAL_PLAN_REVIEW));
  975. rows.add(new JsonElement.from_properties(row));
  976. }
  977. var project_options = new Vector<GoalProjectOptionView>();
  978. foreach (var project in yield project_service.list_projects()) {
  979. project_options.add(new GoalProjectOptionView() {
  980. id = project.id,
  981. name = project.name
  982. });
  983. }
  984. var pub_root = (PropertyDictionary) GObjectMapping.to_properties(
  985. new GoalsListPublic() {
  986. create = action_registry.author<CreateGoalAction>()
  987. });
  988. pub_root["goals"] = new JsonElement.from_elements(rows);
  989. pub_root["projects"] = StateJson.list<GoalProjectOptionView>(
  990. project_options.to_immutable_buffer());
  991. return directives().set(state_service.new_slot(Scope.PAGE, new Statum.State() {
  992. type_name = "goals",
  993. public_data = pub_root,
  994. private_data = new PropertyDictionary()
  995. })).subscribe_global(StatePulse.SLOT_TYPE);
  996. }
  997. }
  998. /**
  999. * Entrypoint for /goals/{goal_id}: the goal page — status head
  1000. * with the lifecycle actions, the locked brief, the approved plan
  1001. * (rejected revisions under the historical-blocks expander), the
  1002. * task strip + list, the supervision narrative, the comment
  1003. * thread, the completion review (report + push state + compare
  1004. * link) and the goal agent's runs with their terminal views.
  1005. * Subscribes to the global pulse for live re-renders.
  1006. */
  1007. public class GoalDetailEntrypoint : StatumEntrypoint {
  1008. private GoalService goal_service = Inversion.inject<GoalService>();
  1009. private TaskService task_service = Inversion.inject<TaskService>();
  1010. private ProjectService project_service = Inversion.inject<ProjectService>();
  1011. private EventService event_service = Inversion.inject<EventService>();
  1012. private SaddleService saddle_service = Inversion.inject<SaddleService>();
  1013. private SaddlePollState poll_state = Inversion.inject<SaddlePollState>();
  1014. private RuleService rule_service = Inversion.inject<RuleService>();
  1015. private StackService stack_service = Inversion.inject<StackService>();
  1016. private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
  1017. private UserService users = Inversion.inject<UserService>();
  1018. public override async DirectiveBuilder handle() throws GLib.Error {
  1019. var guard = yield SpryAuth.require_login(directives(), request, users);
  1020. if (guard != null) {
  1021. return (!)guard;
  1022. }
  1023. int64 goal_id = int64.parse(request.route("goal_id") ?? "0");
  1024. var ctx = new GoalViewContext(goal_service, task_service, project_service,
  1025. event_service, db, action_registry, saddle_service, poll_state, rule_service,
  1026. stack_service);
  1027. var state = yield GoalState.build(goal_id, ctx, state_service);
  1028. return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
  1029. }
  1030. }
  1031. }