ProjectActions.vala 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. using Inversion;
  2. using Spry;
  3. using Spry.Authentication;
  4. using Statum;
  5. using ClankerSaddle.Services;
  6. namespace ClankerSaddle {
  7. /** Sealed project id carried by the project actions. */
  8. public class ProjectActionPrivate : Object {
  9. public int64 project_id { get; set; }
  10. }
  11. /** Sealed link identity carried by {@link RemoveProjectLinkAction}. */
  12. public class ProjectLinkActionPrivate : Object {
  13. public int64 project_id { get; set; }
  14. public int64 link_id { get; set; }
  15. }
  16. /** Sealed note identity carried by {@link DeleteProjectNoteAction}. */
  17. public class ProjectNoteActionPrivate : Object {
  18. public int64 project_id { get; set; }
  19. public int64 note_id { get; set; }
  20. }
  21. /**
  22. * The title prefix of every understanding refresh task (goal 24):
  23. * {@link RefreshUnderstandingAction} creates them and refuses a
  24. * second refresh while one is still live.
  25. */
  26. public const string UNDERSTANDING_REFRESH_PREFIX = "Understanding refresh:";
  27. /**
  28. * Creates a project from the projects form (name and git URL
  29. * required); default_branch falls back to 'main'.
  30. */
  31. public class CreateProjectAction : StatumAction {
  32. private ProjectService projects = inject<ProjectService>();
  33. private UserService users = inject<UserService>();
  34. public override async DirectiveBuilder handle() throws GLib.Error {
  35. var guard = yield SpryAuth.require_login(directives(), request, users);
  36. if (guard != null) {
  37. return (!)guard;
  38. }
  39. string name = Forms.field(request, "name");
  40. string git_url = Forms.field(request, "git_url");
  41. if (name.length == 0 || git_url.length == 0) {
  42. return directives().notify("error", "Name and git URL are required").navigate("/projects");
  43. }
  44. try {
  45. var project = yield projects.create_project(name, git_url,
  46. Forms.field(request, "default_branch"), Forms.field(request, "summary"));
  47. return directives().notify("success", @"Project '$(project.name)' created")
  48. .navigate(@"/projects/$(project.id)");
  49. } catch (Error e) {
  50. return directives().notify("error", @"Could not create the project: $(e.message)").navigate("/projects");
  51. }
  52. }
  53. }
  54. /**
  55. * Saves the editable fields of a project; the id comes from the sealed
  56. * {@link ProjectActionPrivate} blob (the hidden form field is a
  57. * fallback). An empty summary clears the stored one.
  58. */
  59. public class UpdateProjectAction : TypedStatumAction<ProjectActionPrivate> {
  60. private ProjectService projects = inject<ProjectService>();
  61. private UserService users = inject<UserService>();
  62. protected override Type private_type { get { return typeof(ProjectActionPrivate); } }
  63. public override async DirectiveBuilder handle() throws GLib.Error {
  64. var guard = yield SpryAuth.require_login(directives(), request, users);
  65. if (guard != null) {
  66. return (!)guard;
  67. }
  68. var ctx = request_private;
  69. int64 project_id = ctx != null ? ((!)ctx).project_id : Forms.integer(request, "project_id", 0);
  70. if (project_id <= 0) {
  71. return directives().navigate("/projects");
  72. }
  73. try {
  74. yield projects.update_project(project_id,
  75. Forms.optional(request, "name"), Forms.optional(request, "git_url"),
  76. Forms.optional(request, "default_branch"),
  77. request.form != null ? Forms.field(request, "summary") : null);
  78. return directives().notify("success", "Project saved")
  79. .navigate(@"/projects/$(project_id)");
  80. } catch (Error e) {
  81. return directives().notify("error", @"Could not save the project: $(e.message)")
  82. .navigate(@"/projects/$(project_id)");
  83. }
  84. }
  85. }
  86. /**
  87. * Links another project to this one; the parent id is sealed, the
  88. * target and relationship come from the form selects.
  89. */
  90. public class AddProjectLinkAction : TypedStatumAction<ProjectActionPrivate> {
  91. private ProjectService projects = inject<ProjectService>();
  92. private UserService users = inject<UserService>();
  93. protected override Type private_type { get { return typeof(ProjectActionPrivate); } }
  94. public override async DirectiveBuilder handle() throws GLib.Error {
  95. var guard = yield SpryAuth.require_login(directives(), request, users);
  96. if (guard != null) {
  97. return (!)guard;
  98. }
  99. var ctx = request_private;
  100. int64 project_id = ctx != null ? ((!)ctx).project_id : Forms.integer(request, "project_id", 0);
  101. int64 linked_id = Forms.integer(request, "linked_project_id", 0);
  102. string relationship = Forms.field(request, "relationship");
  103. if (project_id <= 0 || linked_id <= 0) {
  104. return directives().notify("error", "Pick a project to link").navigate(@"/projects/$(project_id)");
  105. }
  106. try {
  107. yield projects.add_link(project_id, linked_id, relationship);
  108. return directives().notify("success", "Link added").navigate(@"/projects/$(project_id)");
  109. } catch (Error e) {
  110. return directives().notify("error", @"Could not add the link: $(e.message)")
  111. .navigate(@"/projects/$(project_id)");
  112. }
  113. }
  114. }
  115. /** Removes a project link; both ids come from the sealed private blob. */
  116. public class RemoveProjectLinkAction : TypedStatumAction<ProjectLinkActionPrivate> {
  117. private ProjectService projects = inject<ProjectService>();
  118. private UserService users = inject<UserService>();
  119. protected override Type private_type { get { return typeof(ProjectLinkActionPrivate); } }
  120. public override async DirectiveBuilder handle() throws GLib.Error {
  121. var guard = yield SpryAuth.require_login(directives(), request, users);
  122. if (guard != null) {
  123. return (!)guard;
  124. }
  125. var ctx = request_private;
  126. if (ctx == null) {
  127. return directives().navigate("/projects");
  128. }
  129. try {
  130. yield projects.remove_link(((!)ctx).link_id);
  131. return directives().notify("success", "Link removed")
  132. .navigate(@"/projects/$(((!)ctx).project_id)");
  133. } catch (Error e) {
  134. return directives().notify("error", @"Could not remove the link: $(e.message)")
  135. .navigate(@"/projects/$(((!)ctx).project_id)");
  136. }
  137. }
  138. }
  139. /**
  140. * Deletes a project note; both ids come from the sealed private blob.
  141. * Notes are LLM-authored (`saddle note` via the callback endpoint) —
  142. * the web UI only curates them.
  143. */
  144. public class DeleteProjectNoteAction : TypedStatumAction<ProjectNoteActionPrivate> {
  145. private ProjectService projects = inject<ProjectService>();
  146. private UserService users = inject<UserService>();
  147. protected override Type private_type { get { return typeof(ProjectNoteActionPrivate); } }
  148. public override async DirectiveBuilder handle() throws GLib.Error {
  149. var guard = yield SpryAuth.require_login(directives(), request, users);
  150. if (guard != null) {
  151. return (!)guard;
  152. }
  153. var ctx = request_private;
  154. if (ctx == null) {
  155. return directives().navigate("/projects");
  156. }
  157. try {
  158. yield projects.delete_note(((!)ctx).note_id);
  159. return directives().notify("success", "Note deleted")
  160. .navigate(@"/projects/$(((!)ctx).project_id)");
  161. } catch (Error e) {
  162. return directives().notify("error", @"Could not delete the note: $(e.message)")
  163. .navigate(@"/projects/$(((!)ctx).project_id)");
  164. }
  165. }
  166. }
  167. /**
  168. * Spins up the project's understanding refresh task (goal 24): a
  169. * QUEUED investigate task whose agent explores the project, walks
  170. * the git history since the current document's commit when one
  171. * exists, and saves an UPDATED markdown understanding via the
  172. * `save_understanding` tool. Refuses while another refresh task
  173. * for the project is still live — one explorer at a time.
  174. */ public class RefreshUnderstandingAction : TypedStatumAction<ProjectActionPrivate> {
  175. private ProjectService projects = inject<ProjectService>();
  176. private UnderstandingService understandings = inject<UnderstandingService>();
  177. private TaskService tasks = inject<TaskService>();
  178. private UserService users = inject<UserService>();
  179. protected override Type private_type { get { return typeof(ProjectActionPrivate); } }
  180. public override async DirectiveBuilder handle() throws GLib.Error {
  181. var guard = yield SpryAuth.require_login(directives(), request, users);
  182. if (guard != null) {
  183. return (!)guard;
  184. }
  185. var ctx = request_private;
  186. int64 project_id = ctx != null ? ((!)ctx).project_id : Forms.integer(request, "project_id", 0);
  187. if (project_id <= 0) {
  188. return directives().navigate("/projects");
  189. }
  190. var project = yield projects.get_project(project_id);
  191. if (project == null) {
  192. return directives().notify("error", "That project no longer exists").navigate("/projects");
  193. }
  194. // One live explorer at a time: a non-terminal task whose
  195. // title carries the refresh prefix blocks a new refresh.
  196. foreach (var task in yield tasks.list_tasks(null, project_id)) {
  197. if (!task.title.has_prefix(UNDERSTANDING_REFRESH_PREFIX)) {
  198. continue;
  199. }
  200. if (task.status == STATUS_DONE || task.status == STATUS_FAILED) {
  201. continue;
  202. }
  203. return directives().notify("error",
  204. @"An understanding refresh (#$(task.id)) is already running for this project")
  205. .navigate(@"/projects/$(project_id)");
  206. }
  207. var current = yield understandings.current_understanding(project_id);
  208. string old_hash = current != null ? ((!)current).commit_hash : "";
  209. string old_body = current != null ? ((!)current).body : "";
  210. var brief = new StringBuilder();
  211. brief.append("MANDATE — you are the project's understanding explorer.\n\n");
  212. brief.append("Explore the project's structure and produce a markdown understanding ");
  213. brief.append("document covering the project layout and anything unusual that would ");
  214. brief.append("be useful to know for anyone working on it.\n");
  215. if (old_body.length > 0) {
  216. brief.append("\nA previous understanding document exists. Do NOT start from ");
  217. brief.append("scratch: review the git history since the old commit hash ");
  218. brief.append("(git log <old>..HEAD, plus diffs of the significant changes) and ");
  219. brief.append("produce an UPDATED document that keeps what still holds and ");
  220. brief.append("rewrites what changed.\n");
  221. }
  222. brief.append("\nThe finishing move: call the save_understanding tool exactly ONCE ");
  223. brief.append("with the new document and the git HEAD hash of the explored checkout ");
  224. brief.append("(run `git rev-parse HEAD`), then finish(status=done) with a short ");
  225. brief.append("summary. You are in investigate mode — do not edit any files.\n");
  226. brief.append("\nPrevious commit hash: ");
  227. brief.append(old_hash.length > 0 ? old_hash : "none — first refresh");
  228. if (old_body.length > 0) {
  229. brief.append("\n\nPrevious document:\n\n```markdown\n");
  230. brief.append(old_body);
  231. brief.append("\n```\n");
  232. }
  233. var notes = yield projects.list_notes(project_id);
  234. brief.append("\nProject notes from other agents:\n");
  235. if (notes.length == 0) {
  236. brief.append("(none)\n");
  237. } else {
  238. foreach (var note in notes) {
  239. brief.append("\n---\n");
  240. brief.append(note.body);
  241. brief.append("\n");
  242. }
  243. }
  244. try {
  245. var task = yield tasks.create_task(project_id,
  246. @"$(UNDERSTANDING_REFRESH_PREFIX) $(((!)project).name)",
  247. brief.str, 5, null, null, STATUS_QUEUED,
  248. TASK_KIND_TASK, "investigate", CREATOR_HUMAN);
  249. yield tasks.add_system_comment(task.id,
  250. @"Understanding refresh queued against commit $(old_hash.length > 0 ? old_hash : "(none)")");
  251. return directives().notify("success",
  252. @"Refresh queued — the explorer task is #$(task.id)")
  253. .navigate(@"/projects/$(project_id)");
  254. } catch (Error e) {
  255. return directives().notify("error", @"Could not queue the refresh: $(e.message)")
  256. .navigate(@"/projects/$(project_id)");
  257. }
  258. }
  259. }
  260. /**
  261. * Deletes a project (admin-only), by OWNERSHIP CASCADE ONLY: the
  262. * project row and the rows it owns — its tasks, its goals and
  263. * those rows' children — die together; other projects' tasks,
  264. * goals and rows are NEVER touched, even when they link to this
  265. * project (their link lists only lose the pointer entry).
  266. *
  267. * The confirmation dialog must have typed the project's EXACT
  268. * current name — the sealed id names the row, the form's
  269. * `confirm_name` must equal the row's name at delete time, so a
  270. * stale tab cannot delete a renamed project on one click.
  271. * Refuses while the project still has tasks queued, working or
  272. * awaiting review (the guard is re-checked server-side; see
  273. * {@link ProjectService.delete_project}).
  274. */
  275. public class DeleteProjectAction : TypedStatumAction<ProjectActionPrivate> {
  276. private ProjectService projects = inject<ProjectService>();
  277. private UserService users = inject<UserService>();
  278. protected override Type private_type { get { return typeof(ProjectActionPrivate); } }
  279. public override async DirectiveBuilder handle() throws GLib.Error {
  280. var guard = yield SpryAuth.require_permission(directives(), request, users,
  281. SpryAuth.ADMIN_PERMISSION, "/login");
  282. if (guard != null) {
  283. return (!)guard;
  284. }
  285. var ctx = request_private;
  286. int64 project_id = ctx != null
  287. ? ((!)ctx).project_id : Forms.integer(request, "project_id", 0);
  288. if (project_id <= 0) {
  289. return directives().navigate("/projects");
  290. }
  291. var project = yield projects.get_project(project_id);
  292. if (project == null) {
  293. return directives().notify("error", "Project not found")
  294. .navigate("/projects");
  295. }
  296. // The typed-name gate: the dialog asks for the exact name.
  297. if (Forms.field(request, "confirm_name").strip() != ((!)project).name) {
  298. return directives().notify("error",
  299. "Type the project's exact name to confirm the deletion")
  300. .navigate(@"/projects/$(project_id)");
  301. }
  302. try {
  303. yield projects.delete_project(project_id);
  304. return directives().notify("success",
  305. @"Project '$(((!)project).name)' deleted — linked projects' work was left untouched")
  306. .navigate("/projects");
  307. } catch (Error e) {
  308. return directives().notify("error", @"Could not delete the project: $(e.message)")
  309. .navigate(@"/projects/$(project_id)");
  310. }
  311. }
  312. }
  313. }