TasksEntrypoint.vala 18 KB

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