TasksEntrypoint.vala 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Spry;
  4. using Spry.Authentication;
  5. using Statum;
  6. using ClankerSaddle.Data;
  7. using ClankerSaddle.Services;
  8. namespace ClankerSaddle {
  9. /** A task row on the task queue page, with a dependency summary. */
  10. public class TaskRowView : Object {
  11. public int64 id { get; set; }
  12. public string title { get; set; default = ""; }
  13. public string status { get; set; default = ""; }
  14. public string display { get; set; default = ""; }
  15. public bool terminal { get; set; }
  16. public bool failed { get; set; }
  17. public int64 priority { get; set; }
  18. public string project_name { get; set; default = ""; }
  19. public string dependency_summary { get; set; default = ""; }
  20. public bool has_dependencies { get; set; }
  21. public bool ready { get; set; }
  22. public string[] capabilities { get; set; }
  23. public bool waiting_for_saddle { get; set; }
  24. public bool unreachable { get; set; }
  25. /**
  26. * The task earned its 🤠 badge — any of its sessions carries a
  27. * `saddle yeehaw` entry (derived, nothing stored for it).
  28. */
  29. public bool has_yeehaw { get; set; }
  30. /** Plan 12: the task's work mode (badge). */
  31. public string mode { get; set; default = "implement"; }
  32. /** Plan 12: the task's creator ("human"/"goal"). */
  33. public string creator { get; set; default = "human"; }
  34. /** Plan 12: the goal the task runs under (0 = standalone). */
  35. public int64 goal_id { get; set; }
  36. public string goal_title { get; set; default = ""; }
  37. /** Plan 12: the auto-enqueue predecessor (0 = none). */
  38. public int64 enqueue_after_task_id { get; set; }
  39. public int64 project_id { get; set; }
  40. }
  41. /**
  42. * A project option in the queue page's filter and create forms,
  43. * carrying the default branch so the create form can prefill
  44. * `branch_from` as the project selection changes.
  45. */
  46. public class TaskProjectOptionView : Object {
  47. public int64 id { get; set; }
  48. public string name { get; set; default = ""; }
  49. public string default_branch { get; set; default = ""; }
  50. }
  51. /** Public data of the `tasks` slot. */
  52. public class TasksPublic : Object {
  53. public string filter_status { get; set; default = ""; }
  54. public int64 filter_project_id { get; set; }
  55. /** Plan 12: the goal filter ("all" | "standalone" | goal id). */
  56. public string filter_goal { get; set; default = "all"; }
  57. public Statum.Model.ActionDto? create { get; set; }
  58. /** The sealed GET refresh (live pulses + filter re-renders). */
  59. public Statum.Model.ActionDto? refresh { get; set; }
  60. }
  61. /**
  62. * One goal option in the queue page's filter and create forms:
  63. * the goal picker for the plan-12 task fan-out view.
  64. */
  65. public class GoalOptionView : Object {
  66. public int64 id { get; set; }
  67. public string title { get; set; default = ""; }
  68. public string status { get; set; default = ""; }
  69. }
  70. /**
  71. * The queue page's state builder, shared by the entrypoint and the
  72. * sealed refresh action (live pulse rehydration + the client-side
  73. * filter clicks re-render through it).
  74. */
  75. public class TasksState : Object {
  76. public static async Statum.Slot build(StateService state_service,
  77. TaskService task_service, ProjectService project_service,
  78. CapabilityService capability_service, SaddleService saddle_service,
  79. GoalService goal_service, ActionRegistry? actions,
  80. StatumRequest? request = null) throws GLib.Error {
  81. // Query filters arrive only on direct entrypoint hits (the
  82. // browser client's entrypoint fetch never carries the
  83. // page's own query string — the selects filter
  84. // client-side).
  85. string? status = null;
  86. if (request != null) {
  87. request.query.try_get_any("status", out status);
  88. }
  89. if (status == null || status.length == 0 || status == "all") {
  90. status = null;
  91. }
  92. string? goal_filter = null;
  93. if (request != null) {
  94. request.query.try_get_any("goal", out goal_filter);
  95. }
  96. if (goal_filter == null || goal_filter.length == 0) {
  97. goal_filter = "all";
  98. }
  99. int64? project_id = null;
  100. string? project_text = null;
  101. if (request != null) {
  102. request.query.try_get_any("project_id", out project_text);
  103. }
  104. if (project_text != null && project_text.length > 0) {
  105. int64 parsed = int64.parse(project_text);
  106. if (parsed > 0) {
  107. project_id = parsed;
  108. }
  109. }
  110. var projects = yield project_service.list_projects();
  111. var project_names = new Dictionary<string, string>();
  112. foreach (var project in projects) {
  113. project_names.set(project.id.to_string(), project.name);
  114. }
  115. // ONE materialisation of the task list feeds every consumer
  116. // below (the status lookup, the goal-filtered strip and the
  117. // enqueue dropdown) — the triple list_tasks died with arch
  118. // item 14.
  119. var tasks_all = yield task_service.list_tasks(null, null);
  120. var statuses = new Dictionary<string, string>();
  121. foreach (var task in tasks_all) {
  122. statuses.set(task.id.to_string(), task.status);
  123. }
  124. var ready = yield task_service.get_ready_tasks();
  125. var ready_ids = new Invercargill.DataStructures.HashSet<string>();
  126. foreach (var ready_task in ready) {
  127. ready_ids.add(ready_task.id.to_string());
  128. }
  129. // Computed display state for every listed task (review
  130. // requested / delegating / suspended surface here). The
  131. // plan-12 goal filter narrows the list AFTER the display
  132. // map (the strip needs every task's display state). The
  133. // GoalService arrives as a parameter — an inject inside
  134. // this async builder would resume past an await where the
  135. // injection context stack is empty.
  136. var goal_titles = new Dictionary<string, string>();
  137. foreach (var goal in yield goal_service.list_goals()) {
  138. goal_titles.set(goal.id.to_string(), goal.title);
  139. }
  140. int64 goal_filter_id = 0;
  141. bool standalone_only = goal_filter == "standalone";
  142. if (goal_filter != "all" && !standalone_only) {
  143. goal_filter_id = int64.parse(goal_filter);
  144. }
  145. var listed = new Vector<ClankerSaddle.Data.TaskEntity>();
  146. foreach (var task in tasks_all) {
  147. // Goal-agent runs are internal: they never list here —
  148. // goals are browsed from the goal page, which alone
  149. // exposes the run task (its terminals, its progress).
  150. if (task.kind == TASK_KIND_GOAL_RUN) {
  151. continue;
  152. }
  153. if (goal_filter_id > 0 && task.goal_id != goal_filter_id) {
  154. continue;
  155. }
  156. if (standalone_only && task.goal_id > 0) {
  157. continue;
  158. }
  159. listed.add(task);
  160. }
  161. var display_map = yield task_service.display_map_for(listed.to_immutable_buffer());
  162. // Capability routing context: required names per task, carried
  163. // names per idle saddle — a queued task waits for a capable
  164. // saddle when no idle saddle's set covers its requirements.
  165. var capability_names = new Dictionary<string, string>();
  166. foreach (var capability in yield capability_service.list_capabilities()) {
  167. capability_names.set(capability.id.to_string(), capability.name);
  168. }
  169. var required_by_task = new Dictionary<string, Vector<string>>();
  170. foreach (var link in yield capability_service.all_task_attachments()) {
  171. string name = capability_names.get_or_default(link.capability_id.to_string()) ?? "?";
  172. var names = required_by_task.get_or_default(link.task_id.to_string());
  173. if (names == null) {
  174. names = new Vector<string>();
  175. required_by_task.set(link.task_id.to_string(), names);
  176. }
  177. ((!)names).add(name);
  178. }
  179. var unreachable_ids = yield task_service.unreachable_task_ids();
  180. // The 🤠 badge's batch presence set: every task with a
  181. // yeehaw entry, one DISTINCT query for the whole queue.
  182. var yeehaw_ids = yield task_service.yeehaw_task_ids();
  183. var idle_capability_sets = new Vector<Invercargill.DataStructures.HashSet<string>>();
  184. foreach (var saddle in yield saddle_service.list_idle_saddles()) {
  185. var carried = new Invercargill.DataStructures.HashSet<string>();
  186. foreach (var link in yield capability_service.list_saddle_attachments(saddle.id)) {
  187. carried.add(capability_names.get_or_default(link.capability_id.to_string()) ?? "?");
  188. }
  189. idle_capability_sets.add(carried);
  190. }
  191. var rows = new Vector<TaskRowView>();
  192. foreach (var task in listed) {
  193. var display = display_map.get_or_default(task.id.to_string());
  194. var view = new TaskRowView() {
  195. id = task.id,
  196. title = task.title,
  197. status = task.status,
  198. display = display != null ? ((!)display).status : task.status,
  199. terminal = display != null && ((!)display).terminal,
  200. failed = display != null && ((!)display).failed,
  201. priority = task.priority,
  202. project_id = task.project_id,
  203. project_name = project_names.get_or_default(task.project_id.to_string()) ?? "",
  204. mode = task.mode,
  205. creator = task.creator,
  206. goal_id = task.goal_id,
  207. goal_title = goal_titles.get_or_default(task.goal_id.to_string()) ?? "",
  208. enqueue_after_task_id = task.enqueue_after_task_id,
  209. ready = ready_ids.has(task.id.to_string())
  210. };
  211. // Plan 12: the dependency summary became the
  212. // auto-enqueue-after predecessor note.
  213. if (task.enqueue_after_task_id > 0) {
  214. view.has_dependencies = true;
  215. view.dependency_summary = @"after #$(task.enqueue_after_task_id) (%s)".printf(
  216. statuses.get_or_default(task.enqueue_after_task_id.to_string()) ?? "?");
  217. }
  218. var required = required_by_task.get_or_default(task.id.to_string());
  219. view.capabilities = required != null ? ((!)required).to_array() : new string[0];
  220. if (view.capabilities.length > 0 && task.status == STATUS_QUEUED) {
  221. bool covered = false;
  222. foreach (var carried in idle_capability_sets) {
  223. bool missing = false;
  224. foreach (var name in view.capabilities) {
  225. if (!carried.has(name)) {
  226. missing = true;
  227. break;
  228. }
  229. }
  230. if (!missing) {
  231. covered = true;
  232. break;
  233. }
  234. }
  235. view.waiting_for_saddle = !covered && idle_capability_sets.length > 0;
  236. }
  237. view.unreachable = unreachable_ids.has(task.id.to_string());
  238. view.has_yeehaw = yeehaw_ids.has(task.id.to_string());
  239. rows.add(view);
  240. }
  241. var options = new Vector<TaskProjectOptionView>();
  242. foreach (var project in projects) {
  243. options.add(new TaskProjectOptionView() {
  244. id = project.id,
  245. name = project.name,
  246. default_branch = project.default_branch
  247. });
  248. }
  249. var pub = new TasksPublic() {
  250. filter_status = status ?? "",
  251. filter_project_id = project_id != null ? ((!)project_id) : 0,
  252. filter_goal = goal_filter,
  253. create = actions != null
  254. ? actions.author<CreateTaskAction>() : null,
  255. refresh = actions != null
  256. ? actions.author<RefreshTasksDataAction>("GET") : null
  257. };
  258. var capability_options = new Vector<CapabilityOptionView>();
  259. foreach (var capability in yield capability_service.list_capabilities()) {
  260. capability_options.add(new CapabilityOptionView() {
  261. id = capability.id,
  262. name = capability.name
  263. });
  264. }
  265. // Plan 12: the goal filter's options (live goals, newest
  266. // status badge) and the create form's auto-enqueue-after
  267. // dropdown — every human-created worker task of the global
  268. // list (goal tasks are excluded: the goal agent sequences
  269. // its own).
  270. var goal_options = new Vector<GoalOptionView>();
  271. foreach (var goal in yield goal_service.list_goals()) {
  272. goal_options.add(new GoalOptionView() {
  273. id = goal.id,
  274. title = goal.title,
  275. status = goal.status
  276. });
  277. }
  278. var enqueue_options = new Vector<TaskProjectOptionView>();
  279. foreach (var task in tasks_all) {
  280. if (task.kind != TASK_KIND_TASK || task.goal_id > 0
  281. || task.creator != CREATOR_HUMAN) {
  282. continue;
  283. }
  284. enqueue_options.add(new TaskProjectOptionView() {
  285. id = task.id,
  286. name = @"#$(task.id) $(task.title) ($(task.status))"
  287. });
  288. }
  289. var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
  290. props["tasks"] = StateJson.list_rich<TaskRowView>(rows.to_immutable_buffer(),
  291. (item, dict) => {
  292. dict["capabilities"] = StateJson.strings(
  293. ((TaskRowView) item).capabilities ?? new string[0]);
  294. });
  295. props["projects"] = StateJson.list<TaskProjectOptionView>(options.to_immutable_buffer());
  296. props["capability_options"] = StateJson.list<CapabilityOptionView>(capability_options.to_immutable_buffer());
  297. props["goal_options"] = StateJson.list<GoalOptionView>(goal_options.to_immutable_buffer());
  298. props["enqueue_after_options"] = StateJson.list<TaskProjectOptionView>(
  299. enqueue_options.to_immutable_buffer());
  300. return state_service.new_slot(Scope.PAGE, new State() {
  301. type_name = "tasks",
  302. public_data = props,
  303. private_data = new PropertyDictionary()
  304. });
  305. }
  306. }
  307. /**
  308. * Entrypoint for /tasks: the filterable, sortable queue. Plan 12:
  309. * the status/project/goal filters apply client-side (the Statum
  310. * entrypoint fetch carries no page query string), rows carry the
  311. * mode badge and goal link, and the create form offers the mode
  312. * select and the auto-enqueue-after dropdown. The page subscribes
  313. * to the global pulse and re-runs its sealed refresh action.
  314. */
  315. public class TasksEntrypoint : StatumEntrypoint {
  316. private TaskService task_service = Inversion.inject<TaskService>();
  317. private ProjectService project_service = Inversion.inject<ProjectService>();
  318. private CapabilityService capability_service = Inversion.inject<CapabilityService>();
  319. private SaddleService saddle_service = Inversion.inject<SaddleService>();
  320. private GoalService goal_service = Inversion.inject<GoalService>();
  321. private UserService users = Inversion.inject<UserService>();
  322. public override async DirectiveBuilder handle() throws GLib.Error {
  323. var guard = yield SpryAuth.require_login(directives(), request, users);
  324. if (guard != null) {
  325. return (!)guard;
  326. }
  327. var slot = yield TasksState.build(state_service, task_service,
  328. project_service, capability_service, saddle_service,
  329. goal_service, action_registry, request);
  330. return directives().set(slot).subscribe_global(StatePulse.SLOT_TYPE);
  331. }
  332. }
  333. /**
  334. * The sealed GET refresh action the queue page re-runs on global
  335. * pulses and after a filter click (the client-side filter survives
  336. * the re-render in window.taskFilters).
  337. */
  338. public class RefreshTasksDataAction : StatumAction {
  339. private TaskService task_service = Inversion.inject<TaskService>();
  340. private ProjectService project_service = Inversion.inject<ProjectService>();
  341. private CapabilityService capability_service = Inversion.inject<CapabilityService>();
  342. private SaddleService saddle_service = Inversion.inject<SaddleService>();
  343. private GoalService goal_service = Inversion.inject<GoalService>();
  344. private UserService users = Inversion.inject<UserService>();
  345. public override async DirectiveBuilder handle() throws GLib.Error {
  346. var guard = yield SpryAuth.require_login(directives(), request, users);
  347. if (guard != null) {
  348. return (!)guard;
  349. }
  350. var slot = yield TasksState.build(state_service, task_service,
  351. project_service, capability_service, saddle_service,
  352. goal_service, action_registry);
  353. return directives().set(slot);
  354. }
  355. }
  356. }