GoalDetailEntrypoint.vala 47 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959
  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 = "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 bool yeehaw { get; set; default = false; }
  87. public string updated_display { get; set; default = ""; }
  88. /** The task's required capability tags (plan 14 B5 row badges). */
  89. public string[] capabilities { get; set; default = {}; }
  90. }
  91. /**
  92. * One effective rule of the goal (plan 14 B5): the rules that
  93. * govern the goal agent and every worker under it — the run
  94. * task's own set, the primary project's, and the co-developed
  95. * links' in link order.
  96. */
  97. public class GoalRuleView : Object {
  98. public int64 id { get; set; }
  99. public string name { get; set; default = ""; }
  100. public string body { get; set; default = ""; }
  101. }
  102. /** One goal-agent run (a session of the goal-run task). */
  103. public class GoalRunView : Object {
  104. public int64 id { get; set; }
  105. public string started_display { get; set; default = ""; }
  106. public string ended_display { get; set; default = ""; }
  107. public bool open { get; set; default = false; }
  108. public string outcome { get; set; default = ""; }
  109. public string terminal_url { get; set; default = ""; }
  110. /** The saddle that hosted the run ("" when unknown). */
  111. public string saddle_name { get; set; default = ""; }
  112. }
  113. /** One branch of the daemon-reported goal push state. */
  114. public class GoalPushBranchView : Object {
  115. public string branch { get; set; default = ""; }
  116. public bool pushed { get; set; default = false; }
  117. }
  118. /** A project option chip (the goal link form's select). */
  119. public class GoalProjectOptionView : Object {
  120. public int64 id { get; set; }
  121. public string name { get; set; default = ""; }
  122. }
  123. /** Public root of the `/goals` slot. */
  124. public class GoalsListPublic : Object {
  125. public Statum.Model.ActionDto create { get; set; }
  126. }
  127. /**
  128. * The scalar half of the goal page's public state (the lists ride
  129. * alongside as separate properties; see {@link GoalState.build}).
  130. */
  131. public class GoalPublic : Object {
  132. public bool exists { get; set; }
  133. public int64 id { get; set; }
  134. public int64 project_id { get; set; }
  135. public string project_name { get; set; default = ""; }
  136. public string title { get; set; default = ""; }
  137. public string brief { get; set; default = ""; }
  138. public string branch_name { get; set; default = ""; }
  139. public string base_branch { get; set; default = ""; }
  140. public string status { get; set; default = "draft"; }
  141. public string display { get; set; default = "draft"; }
  142. public int64 priority { get; set; default = 3; }
  143. public string plan { get; set; default = ""; }
  144. public string completion_report { get; set; default = ""; }
  145. public string compare_url { get; set; default = ""; }
  146. public string created_display { get; set; default = ""; }
  147. public string started_display { get; set; default = ""; }
  148. public string completed_display { get; set; default = ""; }
  149. public bool terminal { get; set; }
  150. public int64 draft_tasks { get; set; }
  151. public int64 queued_tasks { get; set; }
  152. public int64 running_tasks { get; set; }
  153. public int64 done_tasks { get; set; }
  154. public int64 failed_tasks { get; set; }
  155. public int64 run_task_id { get; set; }
  156. /** True while a completion report awaits review. */
  157. public bool completion_pending { get; set; }
  158. /** The saddle currently hosting the goal agent ("" while unassigned). */
  159. public string agent_saddle { get; set; default = ""; }
  160. /** Health line for the agent's saddle ("" when none). */
  161. public string saddle_health { get; set; default = ""; }
  162. /** The agent's newest recorded activity line ("" when none). */
  163. public string current_command { get; set; default = ""; }
  164. public string current_command_display { get; set; default = ""; }
  165. /** The current activity's entry has not completed yet. */
  166. public bool current_command_running { get; set; }
  167. /**
  168. * Plan 15 C4: the ONE muted activity line folded into the
  169. * status head (the Current-activity card is gone) —
  170. * "running: `cmd`" for the open round, "parked awaiting <a
  171. * review | a question | its tasks' terminal transitions>"
  172. * for the parked ones ("" when there is nothing to say).
  173. */
  174. public string current_activity { get; set; default = ""; }
  175. public Statum.Model.ActionDto? start { get; set; }
  176. public Statum.Model.ActionDto? pause { get; set; }
  177. public Statum.Model.ActionDto? resume { get; set; }
  178. public Statum.Model.ActionDto? cancel { get; set; }
  179. /**
  180. * Plan 14 B1 parity: re-offer a failed goal's supervision (the
  181. * task page's Retry, worded for the run).
  182. */
  183. public Statum.Model.ActionDto? retry_run { get; set; }
  184. /**
  185. * Plan 14 B1 parity: the run is stale/parked on a dead saddle
  186. * — requeue it; the next dispatch restores the workspace.
  187. */
  188. public Statum.Model.ActionDto? requeue_run { get; set; }
  189. public Statum.Model.ActionDto? comment { get; set; }
  190. public Statum.Model.ActionDto? add_link { get; set; }
  191. /** The sealed GET refresh action (live pulse rehydration). */
  192. public Statum.Model.ActionDto? refresh { get; set; }
  193. }
  194. /**
  195. * State builder shared by the goals list row and the goal page:
  196. * fills a {@link GoalPublic} plus the section lists from a goal
  197. * row. `full` builds the narrative/runs/sections the list skips.
  198. */
  199. public class GoalState : Object {
  200. /**
  201. * Builds the goal page's whole slot from `goal_id` — the
  202. * shape the entrypoint sets and the sealed refresh action
  203. * re-hydrates. `exists` is false when the goal is missing.
  204. */
  205. public static async Statum.Slot build(int64 goal_id, GoalService goals,
  206. TaskService tasks, ProjectService projects,
  207. InvercargillSql.Orm.OrmSession db, ActionRegistry? actions,
  208. StateService state_service,
  209. SaddleService? saddles = null, SaddlePollState? polls = null,
  210. RuleService? rules = null) throws GLib.Error {
  211. var pub = new GoalPublic();
  212. var links = new Vector<GoalLinkView>();
  213. var blocks = new Vector<GoalBlockView>();
  214. var blocks_open = new Vector<GoalBlockView>();
  215. var blocks_answered = new Vector<GoalBlockView>();
  216. var goal_tasks = new Vector<GoalTaskView>();
  217. var events = new Vector<GoalEventView>();
  218. var comments = new Vector<GoalCommentView>();
  219. var runs = new Vector<GoalRunView>();
  220. var push_branches = new Vector<GoalPushBranchView>();
  221. var progress = new Vector<GoalProgressView>();
  222. var effective_rules = new Vector<GoalRuleView>();
  223. GoalBlockView? plan_pending = null;
  224. GoalBlockView? completion_pending = null;
  225. var plan_revisions = new Vector<GoalBlockView>();
  226. var project_options = new Vector<GoalProjectOptionView>();
  227. var goal = yield goals.get_goal(goal_id);
  228. if (goal != null) {
  229. yield populate(((!)goal), goals, tasks, db, actions, true, pub,
  230. links, blocks, goal_tasks, events, comments, runs, push_branches,
  231. progress, saddles, polls, blocks_open, blocks_answered);
  232. foreach (var block in blocks) {
  233. if (block.is_plan_review && plan_pending == null) {
  234. plan_pending = block;
  235. }
  236. if (block.is_completion_review && completion_pending == null) {
  237. completion_pending = block;
  238. }
  239. if (block.block_type == GOAL_BLOCK_PLAN) {
  240. plan_revisions.add(block);
  241. }
  242. }
  243. foreach (var project in yield projects.list_projects()) {
  244. if (project.id != ((!)goal).project_id) {
  245. project_options.add(new GoalProjectOptionView() {
  246. id = project.id,
  247. name = project.name
  248. });
  249. }
  250. }
  251. // Plan 14 B5: the goal's effective rules — the set a
  252. // dispatch ships for the run (and every worker):
  253. // run-task rules, the primary project's, then the
  254. // co-developed goal links' in link order.
  255. if (rules != null) {
  256. var scope_task = new TaskEntity() {
  257. id = pub.run_task_id,
  258. project_id = ((!)goal).project_id
  259. };
  260. foreach (var rule in yield ((!)rules).effective_rules_for(
  261. scope_task, projects, ((!)goal).id)) {
  262. effective_rules.add(new GoalRuleView() {
  263. id = rule.id,
  264. name = rule.name,
  265. body = rule.body
  266. });
  267. }
  268. }
  269. }
  270. if (actions != null && goal != null) {
  271. pub.refresh = ((!)actions)
  272. .author_private<RefreshGoalDataAction, GoalActionPrivate>(
  273. new GoalActionPrivate() { goal_id = goal_id }, "GET");
  274. }
  275. var pub_root = (PropertyDictionary) GObjectMapping.to_properties(pub);
  276. pub_root["links"] = StateJson.list<GoalLinkView>(links.to_immutable_buffer());
  277. pub_root["blocks"] = StateJson.list_rich<GoalBlockView>(blocks.to_immutable_buffer(),
  278. (item, dict) => {
  279. dict["options"] = StateJson.strings(
  280. ((GoalBlockView) item).options ?? new string[0]);
  281. });
  282. pub_root["open_blocks"] = StateJson.list_rich<GoalBlockView>(
  283. blocks_open.to_immutable_buffer(),
  284. (item, dict) => {
  285. dict["options"] = StateJson.strings(
  286. ((GoalBlockView) item).options ?? new string[0]);
  287. });
  288. pub_root["answered_blocks"] = StateJson.list_rich<GoalBlockView>(
  289. blocks_answered.to_immutable_buffer(),
  290. (item, dict) => {
  291. dict["options"] = StateJson.strings(
  292. ((GoalBlockView) item).options ?? new string[0]);
  293. });
  294. pub_root["goal_tasks"] = StateJson.list_rich<GoalTaskView>(
  295. goal_tasks.to_immutable_buffer(),
  296. (item, dict) => {
  297. dict["capabilities"] = StateJson.strings(
  298. ((GoalTaskView) item).capabilities ?? new string[0]);
  299. });
  300. pub_root["events"] = StateJson.list<GoalEventView>(events.to_immutable_buffer());
  301. pub_root["comments"] = StateJson.list<GoalCommentView>(comments.to_immutable_buffer());
  302. pub_root["runs"] = StateJson.list<GoalRunView>(runs.to_immutable_buffer());
  303. pub_root["progress"] = StateJson.list<GoalProgressView>(
  304. progress.to_immutable_buffer());
  305. pub_root["push_branches"] = StateJson.list<GoalPushBranchView>(
  306. push_branches.to_immutable_buffer());
  307. pub_root["plan_revisions"] = StateJson.list<GoalBlockView>(
  308. plan_revisions.to_immutable_buffer());
  309. pub_root["rules"] = StateJson.list<GoalRuleView>(
  310. effective_rules.to_immutable_buffer());
  311. pub_root["project_options"] = StateJson.list<GoalProjectOptionView>(
  312. project_options.to_immutable_buffer());
  313. pub_root["plan_pending_block"] = plan_pending != null
  314. ? new JsonElement.from_properties(
  315. (PropertyDictionary) GObjectMapping.to_properties((!)plan_pending))
  316. : new JsonElement.from_element(new NativeElement<bool>(false));
  317. pub_root["completion_pending_block"] = completion_pending != null
  318. ? new JsonElement.from_properties(
  319. (PropertyDictionary) GObjectMapping.to_properties((!)completion_pending))
  320. : new JsonElement.from_element(new NativeElement<bool>(false));
  321. return state_service.new_slot(Scope.PAGE, new Statum.State() {
  322. type_name = "goal",
  323. public_data = pub_root,
  324. private_data = new PropertyDictionary()
  325. });
  326. }
  327. public static async void populate(Goal goal, GoalService goals, TaskService tasks,
  328. InvercargillSql.Orm.OrmSession db, ActionRegistry? actions,
  329. bool full, GoalPublic pub,
  330. Vector<GoalLinkView> links, Vector<GoalBlockView> blocks,
  331. Vector<GoalTaskView> goal_tasks, Vector<GoalEventView> events,
  332. Vector<GoalCommentView> comments, Vector<GoalRunView> runs,
  333. Vector<GoalPushBranchView> push_branches,
  334. Vector<GoalProgressView>? progress = null,
  335. SaddleService? saddles = null,
  336. SaddlePollState? polls = null,
  337. Vector<GoalBlockView>? blocks_open = null,
  338. Vector<GoalBlockView>? blocks_answered = null) throws GLib.Error {
  339. pub.exists = true;
  340. pub.id = goal.id;
  341. pub.project_id = goal.project_id;
  342. pub.title = goal.title;
  343. pub.brief = goal.brief;
  344. pub.branch_name = goal.branch_name;
  345. pub.base_branch = goal.base_branch ?? "";
  346. pub.status = goal.status;
  347. pub.display = goal.status.replace("_", " ");
  348. pub.priority = goal.priority;
  349. pub.plan = goal.plan ?? "";
  350. pub.completion_report = goal.completion_report ?? "";
  351. pub.created_display = format_stamp(goal.created_at);
  352. pub.started_display = format_stamp(goal.started_at);
  353. pub.completed_display = format_stamp(goal.completed_at);
  354. pub.terminal = goal.status == GOAL_COMPLETE || goal.status == GOAL_FAILED
  355. || goal.status == GOAL_CANCELLED;
  356. var project = yield db.query<Project>()
  357. .where(expr("id == $0", elem<int64?>(goal.project_id)))
  358. .first_async();
  359. if (project != null) {
  360. pub.project_name = ((!)project).name;
  361. string base_name = goal.base_branch != null && ((!)goal.base_branch).length > 0
  362. ? ((!)goal.base_branch) : ((!)project).default_branch;
  363. pub.compare_url = @"$(TaskService.https_git_url(((!)project).git_url))/compare/$(base_name)...$(goal.branch_name)";
  364. }
  365. if (actions != null) {
  366. var sealed = new GoalActionPrivate() { goal_id = goal.id };
  367. pub.comment = ((!)actions).author_private<GoalCommentAction, GoalActionPrivate>(sealed);
  368. pub.add_link = ((!)actions).author_private<AddGoalLinkAction, GoalActionPrivate>(sealed);
  369. if (goal.status == GOAL_DRAFT) {
  370. pub.start = ((!)actions).author_private<StartGoalAction, GoalActionPrivate>(sealed);
  371. }
  372. if (goal.status == GOAL_ACTIVE) {
  373. pub.pause = ((!)actions).author_private<PauseGoalAction, GoalActionPrivate>(sealed);
  374. }
  375. if (goal.status == GOAL_PAUSED) {
  376. pub.resume = ((!)actions).author_private<ResumeGoalAction, GoalActionPrivate>(sealed);
  377. }
  378. if (!pub.terminal) {
  379. pub.cancel = ((!)actions).author_private<CancelGoalAction, GoalActionPrivate>(sealed);
  380. }
  381. }
  382. foreach (var link in yield goals.list_links(goal.id)) {
  383. var view = new GoalLinkView() {
  384. id = link.id,
  385. project_id = link.project_id,
  386. relationship = link.relationship,
  387. remove = ((!)actions).author_private<RemoveGoalLinkAction, GoalActionPrivate>(
  388. new GoalActionPrivate() { goal_id = goal.id })
  389. };
  390. var linked = yield db.query<Project>()
  391. .where(expr("id == $0", elem<int64?>(link.project_id)))
  392. .first_async();
  393. if (linked != null) {
  394. view.name = ((!)linked).name;
  395. }
  396. links.add(view);
  397. }
  398. bool completion_pending = false;
  399. bool plan_pending_open = false;
  400. // Plan 15 C1: the goal's blocks ARE the goal-run task's
  401. // blocks — one row per question, answered in place.
  402. foreach (var block in yield goals.list_blocks(goal.id)) {
  403. bool pending_plan = block.block_type == GOAL_BLOCK_PLAN
  404. && block.answer == null;
  405. // The completion review may ride either kind: the web's
  406. // completion raise or the daemon's done-park review block
  407. // (adopted — one row, whichever landed first).
  408. bool pending_completion = block.answer == null
  409. && (block.block_type == GOAL_BLOCK_COMPLETION
  410. || block.block_type == "review");
  411. var view = new GoalBlockView() {
  412. id = block.id,
  413. question = block.question,
  414. block_type = block.block_type,
  415. options = TaskService.parse_block_options(block.options),
  416. body = block.body ?? "",
  417. answered = block.answer != null,
  418. answer = block.answer ?? "",
  419. answered_at_display = format_stamp(block.answered_at),
  420. answered_by = block.answered_by ?? "",
  421. created_display = format_stamp(block.created_at),
  422. is_plan_review = pending_plan,
  423. is_completion_review = pending_completion,
  424. // A pending plan hides its body preview (the plan
  425. // section renders it); the pending completion review
  426. // SHOWS its body — the block IS the review surface.
  427. show_body = !pending_plan,
  428. approved = block.answer != null
  429. && block.answer == NOTICE_PLAN_APPROVED
  430. };
  431. if (block.answer == null && actions != null) {
  432. view.answer_action = ((!)actions)
  433. .author_private<AnswerGoalBlockAction, GoalBlockAnswerPrivate>(
  434. new GoalBlockAnswerPrivate() {
  435. goal_id = goal.id,
  436. block_id = block.id
  437. });
  438. }
  439. if (pending_completion) {
  440. completion_pending = true;
  441. }
  442. if (pending_plan) {
  443. plan_pending_open = true;
  444. }
  445. blocks.add(view);
  446. }
  447. pub.completion_pending = completion_pending;
  448. // Plan 15 C4: the current (unanswered) question/RFC blocks
  449. // render in full; the historic answered ones fold behind
  450. // the answered-blocks expander (the task page's pattern).
  451. var open_sink = blocks_open ?? new Vector<GoalBlockView>();
  452. var answered_sink = blocks_answered ?? new Vector<GoalBlockView>();
  453. var block_array = blocks.to_array();
  454. GoalBlockView? pending_review = null;
  455. for (var index = block_array.length - 1; index >= 0; index--) {
  456. var view = (GoalBlockView) block_array[index];
  457. if (view.answered) {
  458. answered_sink.add(view);
  459. } else if (view.is_plan_review) {
  460. // rendered by the plan section
  461. } else if (view.is_completion_review) {
  462. // AT MOST ONE pending completion-family block renders
  463. // (the rich review form): pre-dedupe twins may exist in
  464. // stale data — the first pending row owns the review,
  465. // any others stay hidden until the verdict answers
  466. // them all.
  467. if (pending_review == null) {
  468. pending_review = view;
  469. open_sink.add(view);
  470. }
  471. } else {
  472. open_sink.add(view);
  473. }
  474. }
  475. var display_map = yield task_display_map(tasks, goal.id);
  476. foreach (var task in yield tasks.list_tasks_for_goal(goal.id)) {
  477. if (task.kind == TASK_KIND_GOAL_RUN) {
  478. pub.run_task_id = task.id;
  479. continue;
  480. }
  481. var view = new GoalTaskView() {
  482. id = task.id,
  483. title = task.title,
  484. status = task.status,
  485. mode = task.mode,
  486. creator = task.creator,
  487. updated_display = format_stamp(task.completed_at ?? task.created_at)
  488. };
  489. // Plan 14 B5: the row's required-capability badges —
  490. // read straight off the task_capabilities links (no
  491. // editing here).
  492. var task_caps = new Vector<string>();
  493. foreach (var link in yield db.query<TaskCapability>()
  494. .where(expr("task_id == $0", elem<int64?>(task.id)))
  495. .order_by(expr("id"))
  496. .materialise_async()) {
  497. var capability = yield db.query<Capability>()
  498. .where(expr("id == $0", elem<int64?>(link.capability_id)))
  499. .first_async();
  500. if (capability != null) {
  501. task_caps.add(((!)capability).name);
  502. }
  503. }
  504. view.capabilities = task_caps.to_array();
  505. var display = display_map.get_or_default(task.id.to_string());
  506. view.display = display != null ? ((!)display).display : task.status;
  507. view.yeehaw = display != null && ((!)display).yeehaw;
  508. goal_tasks.add(view);
  509. switch (task.status) {
  510. case STATUS_DRAFT:
  511. pub.draft_tasks++; break;
  512. case STATUS_QUEUED:
  513. case STATUS_ASSIGNED:
  514. pub.queued_tasks++; break;
  515. case STATUS_WORKING:
  516. case STATUS_BLOCKED:
  517. case STATUS_REVIEW_REQUESTED:
  518. case STATUS_SUSPENDED:
  519. pub.running_tasks++; break;
  520. case STATUS_DONE:
  521. pub.done_tasks++; break;
  522. case STATUS_FAILED:
  523. pub.failed_tasks++; break;
  524. default:
  525. break;
  526. }
  527. }
  528. if (!full) {
  529. return;
  530. }
  531. // Plan 14 B1: run-control parity on the status head —
  532. // "Retry supervision" for a failed goal, requeue-to-resume
  533. // for a run stale/parked on a dead saddle (NO notify-me
  534. // toggle: goals notify unconditionally by design).
  535. if (actions != null && pub.run_task_id > 0) {
  536. var run_row = yield db.query<TaskEntity>()
  537. .where(expr("id == $0", elem<int64?>(pub.run_task_id)))
  538. .first_async();
  539. if (run_row != null) {
  540. string run_status = ((!)run_row).status;
  541. if (goal.status == GOAL_FAILED && run_status == STATUS_FAILED) {
  542. pub.retry_run = ((!)actions)
  543. .author_private<RetryGoalRunAction, GoalActionPrivate>(
  544. new GoalActionPrivate() { goal_id = goal.id });
  545. }
  546. bool stale_or_parked = run_status == STATUS_WORKING
  547. || run_status == STATUS_BLOCKED
  548. || run_status == STATUS_REVIEW_REQUESTED
  549. || run_status == STATUS_SUSPENDED;
  550. if (!pub.terminal && stale_or_parked) {
  551. var open_run = yield tasks.open_session_for_task(pub.run_task_id);
  552. if (open_run != null) {
  553. var host = yield db.query<Saddle>()
  554. .where(expr("id == $0",
  555. elem<int64?>(((!)open_run).saddle_id)))
  556. .first_async();
  557. if (host != null && ((!)host).status == "offline") {
  558. pub.requeue_run = ((!)actions)
  559. .author_private<RequeueGoalRunAction, GoalActionPrivate>(
  560. new GoalActionPrivate() { goal_id = goal.id });
  561. }
  562. }
  563. }
  564. }
  565. }
  566. // Plan 15 C3: the narrative derives at read time (the
  567. // service returns newest-first rows).
  568. foreach (var row in yield goals.narrative(goal.id)) {
  569. events.add(new GoalEventView() {
  570. kind = row.kind,
  571. text = row.text,
  572. task_id = row.task_id,
  573. at_display = format_stamp(row.at)
  574. });
  575. }
  576. // Plan 15 C2: the goal's comment thread IS the run
  577. // task's task_comments (injection rides the existing
  578. // delivery paths) — the agent's pushes and the human's
  579. // composer land on the same rows.
  580. foreach (var row in yield goals.list_comments(goal.id)) {
  581. comments.add(new GoalCommentView() {
  582. author = row.author,
  583. body = row.body,
  584. quote = row.quote ?? "",
  585. created_display = format_stamp(row.created_at),
  586. from_agent = row.author == "agent",
  587. is_system = row.author == COMMENT_AUTHOR_SYSTEM
  588. });
  589. }
  590. int64 open_session_saddle = 0;
  591. if (pub.run_task_id > 0) {
  592. // The goal agent's live progress feed (green, task-page
  593. // pattern) — the run task's own progress lines.
  594. if (progress != null) {
  595. foreach (var row in yield tasks.list_progress(pub.run_task_id)) {
  596. ((!)progress).add(new GoalProgressView() {
  597. id = row.id,
  598. summary = row.summary,
  599. when_display = format_stamp(row.created_at)
  600. });
  601. }
  602. }
  603. var session_rows = yield db.query<ClankerSaddle.Data.Session>()
  604. .where(expr("task_id == $0", elem<int64?>(pub.run_task_id)))
  605. .order_by_desc(expr("id"))
  606. .materialise_async();
  607. foreach (var session in session_rows) {
  608. string run_saddle = "";
  609. if (saddles != null) {
  610. var saddle_row = yield ((!)saddles).get_saddle(session.saddle_id);
  611. run_saddle = saddle_row != null
  612. ? ((!)saddle_row).name : @"saddle #$(session.saddle_id)";
  613. }
  614. runs.add(new GoalRunView() {
  615. id = session.id,
  616. started_display = format_stamp(session.started_at),
  617. ended_display = format_stamp(session.ended_at),
  618. open = session.ended_at == null,
  619. outcome = session.outcome ?? "",
  620. saddle_name = run_saddle,
  621. // Deep-links this run's terminal view: the
  622. // picker's client-side ?session_id=
  623. // selection lands on exactly this session.
  624. terminal_url = @"/tasks/$(pub.run_task_id)/terminal?session_id=$(session.id)"
  625. });
  626. }
  627. // Current activity (task-page parity): the open round's
  628. // hosting saddle, its health and the newest recorded
  629. // activity line.
  630. var open = yield tasks.open_session_for_task(pub.run_task_id);
  631. if (open != null) {
  632. open_session_saddle = ((!)open).saddle_id;
  633. if (saddles != null) {
  634. var saddle_row = yield ((!)saddles).get_saddle(((!)open).saddle_id);
  635. if (saddle_row != null) {
  636. pub.agent_saddle = ((!)saddle_row).name;
  637. if (polls != null) {
  638. string daemon_state = "";
  639. int64 uptime = 0;
  640. int64 activity = 0;
  641. int64 contact_ms = 0;
  642. ((!)polls).health_for(((!)saddle_row).id, out daemon_state,
  643. out uptime, out activity, out contact_ms);
  644. if (daemon_state.length > 0) {
  645. pub.saddle_health = @"saddle '$(((!)saddle_row).name)' — daemon $(daemon_state)";
  646. }
  647. }
  648. }
  649. }
  650. SessionCommand? newest = null;
  651. foreach (var command in yield db.query<SessionCommand>()
  652. .where(expr("session_id == $0", elem<int64?>(((!)open).id)))
  653. .order_by(expr("id"))
  654. .materialise_async()) {
  655. newest = command;
  656. }
  657. if (newest != null) {
  658. // Plan 13 B4: body-first for conversation-level
  659. // entries — the output (the comment body, the
  660. // failure explanation) leads; only a bare
  661. // command renders its command line, so the
  662. // literal `saddle comment` echo never leaves
  663. // the terminal.
  664. string line = ((!)newest).command_text;
  665. if (((!)newest).entry_type != "command"
  666. && (((!)newest).output_text ?? "").length > 0) {
  667. line = (!)newest.output_text;
  668. }
  669. if (line.length > 0) {
  670. pub.current_command = clip_line(line, 200);
  671. pub.current_command_display = format_stamp(((!)newest).started_at);
  672. pub.current_command_running = ((!)newest).completed_at == null;
  673. }
  674. }
  675. }
  676. }
  677. // The one muted activity line (plan 15 C4): what the run is
  678. // doing right now — running the open round, or parked
  679. // awaiting a review / a question / its tasks.
  680. var activity_run = pub.run_task_id > 0
  681. ? yield db.query<TaskEntity>()
  682. .where(expr("id == $0", elem<int64?>(pub.run_task_id)))
  683. .first_async() : null;
  684. if (activity_run != null) {
  685. if (((!)activity_run).status == STATUS_WORKING
  686. || ((!)activity_run).status == STATUS_ASSIGNED) {
  687. if (pub.current_command.length > 0) {
  688. pub.current_activity = pub.current_command_running
  689. ? @"running: `$(pub.current_command)`"
  690. : @"last: `$(pub.current_command)`";
  691. }
  692. } else if (((!)activity_run).status == STATUS_BLOCKED
  693. || ((!)activity_run).status == STATUS_REVIEW_REQUESTED) {
  694. if (completion_pending) {
  695. pub.current_activity = "parked awaiting the completion review";
  696. } else if (plan_pending_open) {
  697. pub.current_activity = "parked awaiting the plan review";
  698. } else if (open_sink.length > 0) {
  699. pub.current_activity = "parked awaiting a question";
  700. } else {
  701. pub.current_activity = "parked";
  702. }
  703. } else if (((!)activity_run).status == STATUS_SUSPENDED) {
  704. pub.current_activity =
  705. "parked awaiting its tasks' terminal transitions";
  706. }
  707. }
  708. // Plan 15 C4: the push chips derive from the LIVE
  709. // daemon-reported per-repo flags of the run's hosting
  710. // saddle (the last /status probe) — never a stored
  711. // snapshot. A restart with no probe yet renders none.
  712. if (open_session_saddle > 0 && polls != null) {
  713. string flags = ((!)polls).goal_branches_for(open_session_saddle);
  714. if (flags.length > 0) {
  715. parse_push_state(flags, push_branches);
  716. }
  717. }
  718. }
  719. /**
  720. * The goal tasks' display states + yeehaw badges in one map —
  721. * the task page's computed display (`display_of_detail`) with
  722. * the queue batch lookups.
  723. */
  724. private static async Dictionary<string, GoalTaskDisplay> task_display_map(
  725. TaskService tasks, int64 goal_id) throws GLib.Error {
  726. var map = new Dictionary<string, GoalTaskDisplay>();
  727. var listed = new Vector<TaskEntity>();
  728. foreach (var task in yield tasks.list_tasks_for_goal(goal_id)) {
  729. if (task.kind != TASK_KIND_GOAL_RUN) {
  730. listed.add(task);
  731. }
  732. }
  733. var display_map = yield tasks.display_map_for(listed.to_immutable_buffer());
  734. var yeehaw_ids = yield tasks.yeehaw_task_ids();
  735. foreach (var task in listed) {
  736. var display = display_map.get_or_default(task.id.to_string());
  737. map.set(task.id.to_string(), new GoalTaskDisplay() {
  738. display = display != null ? ((!)display).status : task.status,
  739. yeehaw = yeehaw_ids.has(task.id.to_string())
  740. });
  741. }
  742. return map;
  743. }
  744. /**
  745. * Parses the daemon's goal_branches report into display rows:
  746. * both the array form ([{"repo": "r", "branch": "g/x",
  747. * "pushed": true}] — the daemon's shape, labelled "r: g/x" so
  748. * multi-repo rows stay distinguishable) and the object form
  749. * ({"g/x": true}) are accepted.
  750. */
  751. public static void parse_push_state(string raw, Vector<GoalPushBranchView> rows) {
  752. try {
  753. var node = Json.from_string(raw);
  754. if (node.get_node_type() == Json.NodeType.ARRAY) {
  755. foreach (var element in node.get_array().get_elements()) {
  756. if (element.get_node_type() != Json.NodeType.OBJECT) {
  757. continue;
  758. }
  759. var obj = element.get_object();
  760. var row = new GoalPushBranchView();
  761. string repo = obj.has_member("repo")
  762. ? (obj.get_string_member("repo") ?? "") : "";
  763. string branch = "";
  764. if (obj.has_member("branch")) {
  765. branch = obj.get_string_member("branch") ?? "";
  766. } else if (repo.length > 0) {
  767. branch = repo;
  768. repo = "";
  769. }
  770. if (repo.length > 0 && branch.length > 0) {
  771. row.branch = @"$(repo): $(branch)";
  772. } else {
  773. row.branch = branch;
  774. }
  775. if (obj.has_member("pushed")) {
  776. var pushed = obj.get_member("pushed");
  777. row.pushed = node_truthy(pushed);
  778. }
  779. if (row.branch.length > 0) {
  780. rows.add(row);
  781. }
  782. }
  783. } else if (node.get_node_type() == Json.NodeType.OBJECT) {
  784. foreach (var member in node.get_object().get_members()) {
  785. var value = node.get_object().get_member(member);
  786. if (value.get_node_type() != Json.NodeType.VALUE) {
  787. continue;
  788. }
  789. rows.add(new GoalPushBranchView() {
  790. branch = member,
  791. pushed = node_truthy(value)
  792. });
  793. }
  794. }
  795. } catch (Error e) {
  796. // Unparseable push state renders as no rows.
  797. }
  798. }
  799. private static bool node_truthy(Json.Node node) {
  800. if (node.get_node_type() != Json.NodeType.VALUE) {
  801. return false;
  802. }
  803. var value = node.get_value();
  804. if (value.holds(typeof(bool))) {
  805. return value.get_boolean();
  806. }
  807. if (value.holds(typeof(int64)) || value.holds(typeof(long))) {
  808. return value.get_int64() != 0;
  809. }
  810. return false;
  811. }
  812. private static string format_stamp(DateTime? stamp) {
  813. return stamp != null ? ((!)stamp).format("%Y-%m-%d %H:%M") : "";
  814. }
  815. /** A flattened, clipped activity line (the current-activity display). */
  816. private static string clip_line(string text, int limit) {
  817. string flat = text.replace("\n", " ⏎ ").strip();
  818. return flat.length > limit ? flat.substring(0, limit) + "…" : flat;
  819. }
  820. }
  821. /** Display + badge pair for a goal task row. */
  822. public class GoalTaskDisplay : Object {
  823. public string display { get; set; default = ""; }
  824. public bool yeehaw { get; set; }
  825. }
  826. /**
  827. * Entrypoint for /goals: the goals dashboard — every goal (newest
  828. * first) with lifecycle badges, branch, priority and task counts,
  829. * plus the create form. The page subscribes to the global pulse
  830. * and re-renders whenever goal or task state changes.
  831. */
  832. public class GoalsEntrypoint : StatumEntrypoint {
  833. private GoalService goal_service = Inversion.inject<GoalService>();
  834. private TaskService task_service = Inversion.inject<TaskService>();
  835. private ProjectService project_service = Inversion.inject<ProjectService>();
  836. private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
  837. private UserService users = Inversion.inject<UserService>();
  838. public override async DirectiveBuilder handle() throws GLib.Error {
  839. var guard = yield SpryAuth.require_login(directives(), request, users);
  840. if (guard != null) {
  841. return (!)guard;
  842. }
  843. var rows = new JsonArray();
  844. foreach (var goal in yield goal_service.list_goals()) {
  845. var pub = new GoalPublic();
  846. var links = new Vector<GoalLinkView>();
  847. var blocks = new Vector<GoalBlockView>();
  848. var goal_tasks = new Vector<GoalTaskView>();
  849. var events = new Vector<GoalEventView>();
  850. var comments = new Vector<GoalCommentView>();
  851. var runs = new Vector<GoalRunView>();
  852. var push_branches = new Vector<GoalPushBranchView>();
  853. yield GoalState.populate(goal, goal_service, task_service, db,
  854. action_registry, false, pub, links, blocks, goal_tasks,
  855. events, comments, runs, push_branches);
  856. var row = (PropertyDictionary) GObjectMapping.to_properties(pub);
  857. row["plan_pending"] = new JsonElement.from_element(
  858. new NativeElement<bool>(goal.status == GOAL_PLAN_REVIEW));
  859. rows.add(new JsonElement.from_properties(row));
  860. }
  861. var project_options = new Vector<GoalProjectOptionView>();
  862. foreach (var project in yield project_service.list_projects()) {
  863. project_options.add(new GoalProjectOptionView() {
  864. id = project.id,
  865. name = project.name
  866. });
  867. }
  868. var pub_root = (PropertyDictionary) GObjectMapping.to_properties(
  869. new GoalsListPublic() {
  870. create = action_registry.author<CreateGoalAction>()
  871. });
  872. pub_root["goals"] = new JsonElement.from_elements(rows);
  873. pub_root["projects"] = StateJson.list<GoalProjectOptionView>(
  874. project_options.to_immutable_buffer());
  875. return directives().set(state_service.new_slot(Scope.PAGE, new Statum.State() {
  876. type_name = "goals",
  877. public_data = pub_root,
  878. private_data = new PropertyDictionary()
  879. })).subscribe_global(StatePulse.SLOT_TYPE);
  880. }
  881. }
  882. /**
  883. * Entrypoint for /goals/{goal_id}: the goal page — status head
  884. * with the lifecycle actions, the locked brief, the approved plan
  885. * (rejected revisions under the historical-blocks expander), the
  886. * task strip + list, the supervision narrative, the comment
  887. * thread, the completion review (report + push state + compare
  888. * link) and the goal agent's runs with their terminal views.
  889. * Subscribes to the global pulse for live re-renders.
  890. */
  891. public class GoalDetailEntrypoint : StatumEntrypoint {
  892. private GoalService goal_service = Inversion.inject<GoalService>();
  893. private TaskService task_service = Inversion.inject<TaskService>();
  894. private ProjectService project_service = Inversion.inject<ProjectService>();
  895. private SaddleService saddle_service = Inversion.inject<SaddleService>();
  896. private SaddlePollState poll_state = Inversion.inject<SaddlePollState>();
  897. private RuleService rule_service = Inversion.inject<RuleService>();
  898. private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
  899. private UserService users = Inversion.inject<UserService>();
  900. public override async DirectiveBuilder handle() throws GLib.Error {
  901. var guard = yield SpryAuth.require_login(directives(), request, users);
  902. if (guard != null) {
  903. return (!)guard;
  904. }
  905. int64 goal_id = int64.parse(request.route("goal_id") ?? "0");
  906. var state = yield GoalState.build(goal_id, goal_service, task_service,
  907. project_service, db, action_registry, state_service,
  908. saddle_service, poll_state, rule_service);
  909. return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
  910. }
  911. }
  912. }