ProjectActions.vala 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  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. */
  175. public class RefreshUnderstandingAction : TypedStatumAction<ProjectActionPrivate> {
  176. private ProjectService projects = inject<ProjectService>();
  177. private UnderstandingService understandings = inject<UnderstandingService>();
  178. private TaskService tasks = inject<TaskService>();
  179. private UserService users = inject<UserService>();
  180. protected override Type private_type { get { return typeof(ProjectActionPrivate); } }
  181. public override async DirectiveBuilder handle() throws GLib.Error {
  182. var guard = yield SpryAuth.require_login(directives(), request, users);
  183. if (guard != null) {
  184. return (!)guard;
  185. }
  186. var ctx = request_private;
  187. int64 project_id = ctx != null ? ((!)ctx).project_id : Forms.integer(request, "project_id", 0);
  188. if (project_id <= 0) {
  189. return directives().navigate("/projects");
  190. }
  191. var project = yield projects.get_project(project_id);
  192. if (project == null) {
  193. return directives().notify("error", "That project no longer exists").navigate("/projects");
  194. }
  195. // One live explorer at a time: a non-terminal task whose
  196. // title carries the refresh prefix blocks a new refresh.
  197. foreach (var task in yield tasks.list_tasks(null, project_id)) {
  198. if (!task.title.has_prefix(UNDERSTANDING_REFRESH_PREFIX)) {
  199. continue;
  200. }
  201. if (task.status == STATUS_DONE || task.status == STATUS_FAILED) {
  202. continue;
  203. }
  204. return directives().notify("error",
  205. @"An understanding refresh (#$(task.id)) is already running for this project")
  206. .navigate(@"/projects/$(project_id)");
  207. }
  208. var current = yield understandings.current_understanding(project_id);
  209. string old_hash = current != null ? ((!)current).commit_hash : "";
  210. string old_body = current != null ? ((!)current).body : "";
  211. var brief = new StringBuilder();
  212. brief.append("MANDATE — you are the project's understanding explorer.\n\n");
  213. brief.append("Explore the project's structure and produce a markdown understanding ");
  214. brief.append("document covering the project layout and anything unusual that would ");
  215. brief.append("be useful to know for anyone working on it.\n");
  216. if (old_body.length > 0) {
  217. brief.append("\nA previous understanding document exists. Do NOT start from ");
  218. brief.append("scratch: review the git history since the old commit hash ");
  219. brief.append("(git log <old>..HEAD, plus diffs of the significant changes) and ");
  220. brief.append("produce an UPDATED document that keeps what still holds and ");
  221. brief.append("rewrites what changed.\n");
  222. }
  223. brief.append("\nThe finishing move: call the save_understanding tool exactly ONCE ");
  224. brief.append("with the new document and the git HEAD hash of the explored checkout ");
  225. brief.append("(run `git rev-parse HEAD`), then finish(status=done) with a short ");
  226. brief.append("summary. You are in investigate mode — do not edit any files.\n");
  227. brief.append("\nPrevious commit hash: ");
  228. brief.append(old_hash.length > 0 ? old_hash : "none — first refresh");
  229. if (old_body.length > 0) {
  230. brief.append("\n\nPrevious document:\n\n```markdown\n");
  231. brief.append(old_body);
  232. brief.append("\n```\n");
  233. }
  234. var notes = yield projects.list_notes(project_id);
  235. brief.append("\nProject notes from other agents:\n");
  236. if (notes.length == 0) {
  237. brief.append("(none)\n");
  238. } else {
  239. foreach (var note in notes) {
  240. brief.append("\n---\n");
  241. brief.append(note.body);
  242. brief.append("\n");
  243. }
  244. }
  245. try {
  246. var task = yield tasks.create_task(project_id,
  247. @"$(UNDERSTANDING_REFRESH_PREFIX) $(((!)project).name)",
  248. brief.str, 5, null, null, STATUS_QUEUED,
  249. TASK_KIND_TASK, "investigate", CREATOR_HUMAN);
  250. yield tasks.add_system_comment(task.id,
  251. @"Understanding refresh queued against commit $(old_hash.length > 0 ? old_hash : "(none)")");
  252. return directives().notify("success",
  253. @"Refresh queued — the explorer task is #$(task.id)")
  254. .navigate(@"/projects/$(project_id)");
  255. } catch (Error e) {
  256. return directives().notify("error", @"Could not queue the refresh: $(e.message)")
  257. .navigate(@"/projects/$(project_id)");
  258. }
  259. }
  260. }
  261. }