ProjectService.vala 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Invercargill.Expressions;
  4. using InvercargillSql;
  5. using InvercargillSql.Orm;
  6. using Inversion;
  7. using ClankerSaddle.Data;
  8. namespace ClankerSaddle.Services {
  9. /** Valid values of {@link ProjectLink.relationship}. */
  10. public const string LINK_CO_DEVELOPED = "co-developed";
  11. public const string LINK_REFERENCE = "reference";
  12. /**
  13. * Project storage: CRUD over `projects`, the directed
  14. * co-developed/reference link graph in `project_links`, and the
  15. * project's LLM-authored notes.
  16. */
  17. public class ProjectService : Object {
  18. private OrmSession db = inject<OrmSession>();
  19. private Connection connection = inject<Connection>();
  20. /** Inserts a project; `summary` may be empty. */
  21. public async Project create_project(string name, string git_url,
  22. string default_branch, string? summary) throws Error {
  23. var project = new Project() {
  24. name = name,
  25. git_url = git_url,
  26. default_branch = default_branch.length > 0 ? default_branch : "main",
  27. summary = summary != null && summary.length > 0 ? summary : null,
  28. created_at = new DateTime.now_local()
  29. };
  30. yield db.insert_async<Project>(project);
  31. return project;
  32. }
  33. /** Loads the project identified by `id`, or null. */
  34. public async Project? get_project(int64 id) throws Error {
  35. return yield db.query<Project>()
  36. .where(expr("id == $0", elem<int64?>(id)))
  37. .first_async();
  38. }
  39. /** Lists every project ordered by name. */
  40. public async ImmutableLot<Project> list_projects() throws Error {
  41. return yield db.query<Project>()
  42. .order_by(expr("name"))
  43. .materialise_async();
  44. }
  45. /**
  46. * Saves the editable fields of the project identified by `id`;
  47. * empty arguments keep their current values. Returns the saved
  48. * project, or null when no such project exists.
  49. */
  50. public async Project? update_project(int64 id, string? name, string? git_url,
  51. string? default_branch, string? summary) throws Error {
  52. var project = yield get_project(id);
  53. if (project == null) {
  54. return null;
  55. }
  56. if (name != null && name.length > 0) {
  57. project.name = name;
  58. }
  59. if (git_url != null && git_url.length > 0) {
  60. project.git_url = git_url;
  61. }
  62. if (default_branch != null && default_branch.length > 0) {
  63. project.default_branch = default_branch;
  64. }
  65. if (summary != null) {
  66. project.summary = summary.length > 0 ? summary : null;
  67. }
  68. yield db.update_async<Project>(project);
  69. return project;
  70. }
  71. /**
  72. * Links `linked_id` to `parent_id` with `relationship`
  73. * ('co-developed' or 'reference'); self-links and duplicates are
  74. * rejected.
  75. */
  76. public async ProjectLink add_link(int64 parent_id, int64 linked_id,
  77. string relationship) throws Error {
  78. if (relationship != LINK_CO_DEVELOPED && relationship != LINK_REFERENCE) {
  79. throw new Error(Quark.from_string("clanker-saddle"), 1,
  80. @"Relationship must be '$(LINK_CO_DEVELOPED)' or '$(LINK_REFERENCE)'");
  81. }
  82. if (parent_id == linked_id) {
  83. throw new Error(Quark.from_string("clanker-saddle"), 2,
  84. "A project cannot be linked to itself");
  85. }
  86. var parent = yield get_project(parent_id);
  87. var linked = yield get_project(linked_id);
  88. if (parent == null || linked == null) {
  89. throw new Error(Quark.from_string("clanker-saddle"), 3,
  90. "Both projects must exist");
  91. }
  92. var existing = yield db.query<ProjectLink>()
  93. .where(expr("parent_project_id == $0 && linked_project_id == $1",
  94. elem<int64?>(parent_id), elem<int64?>(linked_id)))
  95. .materialise_async();
  96. if (existing.length > 0) {
  97. throw new Error(Quark.from_string("clanker-saddle"), 4,
  98. "Those projects are already linked");
  99. }
  100. var link = new ProjectLink() {
  101. parent_project_id = parent_id,
  102. linked_project_id = linked_id,
  103. relationship = relationship
  104. };
  105. yield db.insert_async<ProjectLink>(link);
  106. return link;
  107. }
  108. /** Deletes the link identified by `id`. */
  109. public async void remove_link(int64 id) throws Error {
  110. var link = yield db.query<ProjectLink>()
  111. .where(expr("id == $0", elem<int64?>(id)))
  112. .first_async();
  113. if (link != null) {
  114. yield db.delete_async<ProjectLink>((!)link);
  115. }
  116. }
  117. /** Lists every link whose parent is `project_id`. */
  118. public async ImmutableLot<ProjectLink> list_links(int64 project_id) throws Error {
  119. return yield db.query<ProjectLink>()
  120. .where(expr("parent_project_id == $0", elem<int64?>(project_id)))
  121. .materialise_async();
  122. }
  123. /** Loads the project identified by `name`, or null. */
  124. public async Project? get_project_by_name(string name) throws Error {
  125. return yield db.query<Project>()
  126. .where(expr("name == $0", elem(name)))
  127. .first_async();
  128. }
  129. /**
  130. * Inserts a project note (LLM-authored via the callback endpoint).
  131. * `source_task_id` may be 0 (unknown source task).
  132. */
  133. public async ProjectNote add_note(int64 project_id, int64 source_task_id,
  134. string body) throws Error {
  135. var note = new ProjectNote() {
  136. project_id = project_id,
  137. source_task_id = source_task_id,
  138. body = body,
  139. created_at = new DateTime.now_local()
  140. };
  141. yield db.insert_async<ProjectNote>(note);
  142. return note;
  143. }
  144. /** Loads the note identified by `id`, or null. */
  145. public async ProjectNote? get_note(int64 id) throws Error {
  146. return yield db.query<ProjectNote>()
  147. .where(expr("id == $0", elem<int64?>(id)))
  148. .first_async();
  149. }
  150. /** A project's notes, oldest first. */
  151. public async ImmutableLot<ProjectNote> list_notes(int64 project_id) throws Error {
  152. return yield db.query<ProjectNote>()
  153. .where(expr("project_id == $0", elem<int64?>(project_id)))
  154. .order_by(expr("id"))
  155. .materialise_async();
  156. }
  157. /** Deletes the note identified by `id`. */
  158. public async void delete_note(int64 id) throws Error {
  159. var note = yield get_note(id);
  160. if (note != null) {
  161. yield db.delete_async<ProjectNote>((!)note);
  162. }
  163. }
  164. // ------------------------------------------------------------------
  165. // Deletion (ownership-only cascade)
  166. // ------------------------------------------------------------------
  167. /**
  168. * The task statuses that keep {@link delete_project} refusing:
  169. * a task still queued, assigned to a saddle, working, blocked
  170. * or waiting on review has live work attached — deleting the
  171. * project out from under it would strand a running daemon.
  172. * ({@link STATUS_ASSIGNED} and {@link STATUS_BLOCKED} are
  173. * included: both sit between queued and terminal.)
  174. */
  175. public const string[] ACTIVE_TASK_STATUSES = {
  176. STATUS_QUEUED,
  177. STATUS_ASSIGNED,
  178. STATUS_WORKING,
  179. STATUS_BLOCKED,
  180. STATUS_REVIEW_REQUESTED
  181. };
  182. /**
  183. * What {@link delete_project} will remove — the confirmation
  184. * dialog's counts. `task_count` includes goal-run tasks; the
  185. * page's visible task list hides them, so the dialog says so
  186. * (they are the goal agents' own supervision rows, owned by
  187. * the project's goals).
  188. */
  189. public class ProjectDeleteCounts : Object {
  190. public int64 goal_count { get; set; }
  191. public int64 task_count { get; set; }
  192. public int64 active_task_count { get; set; }
  193. }
  194. /**
  195. * Counts the rows the deletion would take (the dialog's
  196. * "3 goals, 9 tasks"), plus how many tasks are still live
  197. * (the guard's refusal message). Null when the project does
  198. * not exist.
  199. */
  200. public async ProjectDeleteCounts? deletion_counts(int64 project_id) throws Error {
  201. var project = yield get_project(project_id);
  202. if (project == null) {
  203. return null;
  204. }
  205. var counts = new ProjectDeleteCounts();
  206. foreach (var row in yield db.query<Goal>()
  207. .where(expr("project_id == $0", elem<int64?>(project_id)))
  208. .materialise_async()) {
  209. counts.goal_count++;
  210. }
  211. foreach (var task in yield db.query<TaskEntity>()
  212. .where(expr("project_id == $0", elem<int64?>(project_id)))
  213. .materialise_async()) {
  214. counts.task_count++;
  215. if (task.status in ACTIVE_TASK_STATUSES) {
  216. counts.active_task_count++;
  217. }
  218. }
  219. return counts;
  220. }
  221. /**
  222. * The rows a task deletion must take with it — everything that
  223. * dangles under a task row: attached rules and capabilities,
  224. * comments, blocks, notify prefs, the task's sessions and the
  225. * events riding them, plus the events scoped to the task
  226. * itself (web-originated rows carry no session). Bulk DELETEs
  227. * over the task's id set; rules/capabilities themselves belong
  228. * to the fleet, only the attachment rows go.
  229. */
  230. private async void delete_task_children(string task_ids) throws Error {
  231. exec(@"DELETE FROM task_rules WHERE task_id IN ($(task_ids))");
  232. exec(@"DELETE FROM task_capabilities WHERE task_id IN ($(task_ids))");
  233. exec(@"DELETE FROM task_comments WHERE task_id IN ($(task_ids))");
  234. exec(@"DELETE FROM task_blocks WHERE task_id IN ($(task_ids))");
  235. exec(@"DELETE FROM task_notify_prefs WHERE task_id IN ($(task_ids))");
  236. // Sessions carry the daemon-pushed events (events.session_id
  237. // FK); the task-scoped ledger covers the web-originated rows.
  238. exec(@"DELETE FROM events WHERE session_id IN"
  239. + @" (SELECT id FROM sessions WHERE task_id IN ($(task_ids)))");
  240. exec(@"DELETE FROM events WHERE scope_type = 'task' AND scope_id IN ($(task_ids))");
  241. exec(@"DELETE FROM sessions WHERE task_id IN ($(task_ids))");
  242. // A deleted predecessor must not strand its dependents in
  243. // not-ready forever — the reference clears, the dependent
  244. // row itself survives wherever it lives.
  245. exec(@"UPDATE tasks SET enqueue_after_task_id = 0"
  246. + @" WHERE enqueue_after_task_id IN ($(task_ids))");
  247. }
  248. /** Runs one SQL statement on the open transaction's connection. */
  249. private void exec(string sql) throws Error {
  250. connection.create_command(sql).execute_non_query();
  251. }
  252. /**
  253. * Deletes a project by OWNERSHIP CASCADE ONLY — the project
  254. * row and the rows the project owns: its direct tasks
  255. * (`tasks.project_id`), its goals (`goals.project_id`), and
  256. * those rows' children (task comments/blocks/rules/
  257. * capabilities/notify prefs, the tasks' sessions and events,
  258. * the goals' links and goal-scoped events, the project's
  259. * links, notes, understandings and rule attachments, and the
  260. * stack membership rows pointing at it).
  261. *
  262. * What it NEVER takes: another project's tasks, goals or
  263. * project rows — even when they reference this project as a
  264. * co-developed or reference link. Deleting a project only
  265. * tidies those projects' POINTER rows (their `project_links`
  266. * and `goal_links` entries naming this project); their work is
  267. * untouchable. Standalone tasks (project_id 0) are never
  268. * touched, and neither are stack rows (`stacks`) — only the
  269. * membership pointer rows.
  270. *
  271. * Refuses (error code 90) while the project still has tasks
  272. * in queued/assigned/working/blocked/review_requested: cancel
  273. * or let them finish first. Everything runs in ONE
  274. * transaction — a failure anywhere leaves the database
  275. * exactly as it was.
  276. */
  277. public async void delete_project(int64 project_id) throws Error {
  278. var project = yield get_project(project_id);
  279. if (project == null) {
  280. throw new Error(Quark.from_string("clanker-saddle"), 40,
  281. @"No project exists with id $(project_id)");
  282. }
  283. // Guard: refuse while live work sits under the project.
  284. // Counted BEFORE the transaction opens (a refused delete
  285. // should not have opened one at all).
  286. var counts = yield deletion_counts(project_id);
  287. if (counts != null && ((!)counts).active_task_count > 0) {
  288. throw new Error(Quark.from_string("clanker-saddle"), 90,
  289. @"The project still has $(((!)counts).active_task_count)"
  290. + @" task(s) queued, working or awaiting review — cancel or"
  291. + @" let them finish before deleting it");
  292. }
  293. // The id sets, read up front so the DELETEs inside the
  294. // transaction are simple IN-lists (subqueries against
  295. // rows the same transaction is deleting would race
  296. // themselves).
  297. var task_ids = yield owned_id_set("tasks", "project_id", project_id);
  298. var goal_ids = yield owned_id_set("goals", "project_id", project_id);
  299. string task_list = task_ids.length > 0 ? task_ids : "(0)";
  300. string goal_list = goal_ids.length > 0 ? goal_ids : "(0)";
  301. var transaction = connection.begin_transaction();
  302. try {
  303. // 1. Children of the project's tasks — before the
  304. // task rows they hang from.
  305. yield delete_task_children(task_list);
  306. // 2. Children of the project's goals: their link
  307. // pointers and the goal-scoped event ledger. The
  308. // goals' tasks were handled above (tasks carry
  309. // project_id, goal_id or both — the union of the
  310. // two id sets covers every row the project owns).
  311. exec(@"DELETE FROM goal_links WHERE goal_id IN ($(goal_list))");
  312. exec(@"DELETE FROM events WHERE scope_type = 'goal' AND scope_id IN ($(goal_list))");
  313. // Project-scoped ledger rows (the scope constant exists
  314. // though no writer emits them yet — when one appears,
  315. // deleting the project must take them with it).
  316. exec(@"DELETE FROM events WHERE scope_type = 'project' AND scope_id = $(project_id)");
  317. // 3. The tasks, then the goals. A surviving row that
  318. // referenced one of the goals (goal tasks always
  319. // carry the goal's project_id, so this is purely
  320. // defensive) has its pointer cleared, not the row
  321. // taken.
  322. exec(@"DELETE FROM tasks WHERE id IN ($(task_list))");
  323. exec(@"DELETE FROM goals WHERE id IN ($(goal_list))");
  324. exec(@"UPDATE tasks SET goal_id = 0 WHERE goal_id IN ($(goal_list))"
  325. + @" AND id NOT IN ($(task_list))");
  326. // 4. The project's own rows: understandings, notes,
  327. // rule attachments, its outgoing links and the
  328. // stack membership pointers.
  329. exec(@"DELETE FROM project_understandings WHERE project_id = $(project_id)");
  330. exec(@"DELETE FROM project_notes WHERE project_id = $(project_id)");
  331. exec(@"DELETE FROM rule_projects WHERE project_id = $(project_id)");
  332. exec(@"DELETE FROM project_links WHERE parent_project_id = $(project_id)");
  333. exec(@"DELETE FROM stack_projects WHERE project_id = $(project_id)");
  334. // 5. POINTER tidy-up in OTHER projects (their work is
  335. // untouched): drop the link rows naming this
  336. // project as a co-developed/reference target, in
  337. // both the project and the goal link graphs.
  338. exec(@"DELETE FROM project_links WHERE linked_project_id = $(project_id)");
  339. exec(@"DELETE FROM goal_links WHERE project_id = $(project_id)");
  340. // 6. The project row itself, last.
  341. exec(@"DELETE FROM projects WHERE id = $(project_id)");
  342. transaction.commit();
  343. } catch (Error cascade_error) {
  344. transaction.rollback();
  345. throw cascade_error;
  346. }
  347. }
  348. /**
  349. * The comma-separated id list of `table` rows whose `column`
  350. * equals `value` ("1,4,9"), for IN-lists. Empty when none —
  351. * callers substitute "(0)". Only ever called with this
  352. * service's own table/column literals.
  353. */
  354. private async string owned_id_set(string table, string column,
  355. int64 value) throws Error {
  356. var ids = new Vector<string>();
  357. foreach (var row in yield connection.create_command(
  358. @"SELECT id FROM $(table) WHERE $(column) = :value")
  359. .with_parameter<Element>("value", elem<int64?>(value))
  360. .execute_query_async()) {
  361. var id_element = row.get("id");
  362. int64? id = 0;
  363. if (id_element != null && ((!)id_element).try_get_as<int64?>(out id)
  364. && id != 0) {
  365. ids.add(((!)id).to_string());
  366. }
  367. }
  368. return string.joinv(",", ids.to_array());
  369. }
  370. }
  371. }