using Invercargill; using Invercargill.DataStructures; using Invercargill.Expressions; using InvercargillSql.Orm; using Inversion; using ClankerSaddle.Data; namespace ClankerSaddle.Services { /** * Rule storage: CRUD over `rules` plus their project and task * attachments, and the effective-rule computation the dispatcher * ships in the `/start` payload. */ public class RuleService : Object { private OrmSession db = inject(); /** * Inserts a rule; names are unique. Returns the stored row (with * its id). */ public async Rule create_rule(string name, string body) throws Error { if (name.strip().length == 0) { throw new Error(Quark.from_string("clanker-saddle"), 30, "A rule name is required"); } if (body.strip().length == 0) { throw new Error(Quark.from_string("clanker-saddle"), 31, "A rule body is required"); } var existing = yield db.query() .where(expr("name == $0", elem(name.strip()))) .first_async(); if (existing != null) { throw new Error(Quark.from_string("clanker-saddle"), 32, @"A rule named '$(name)' already exists"); } var rule = new Rule() { name = name.strip(), body = body, created_at = new DateTime.now_local() }; yield db.insert_async(rule); return rule; } /** Loads the rule identified by `id`, or null. */ public async Rule? get_rule(int64 id) throws Error { return yield db.query() .where(expr("id == $0", elem(id))) .first_async(); } /** Every rule ordered by name. */ public async ImmutableLot list_rules() throws Error { return yield db.query() .order_by(expr("name")) .materialise_async(); } /** * Saves the editable fields of the rule identified by `id`; null * or empty arguments keep their current values. An edit stamps * `updated_at` — the resume-time context_update delta keys on * it, so a rule edited while a workspace was parked reaches * the agent as a turn. Returns null when the rule does not * exist. */ public async Rule? update_rule(int64 id, string? name, string? body) throws Error { var rule = yield get_rule(id); if (rule == null) { return null; } bool edited = false; if (name != null && name.strip().length > 0) { var clash = yield db.query() .where(expr("name == $0 && id != $1", elem(name.strip()), elem(id))) .first_async(); if (clash != null) { throw new Error(Quark.from_string("clanker-saddle"), 32, @"A rule named '$(name)' already exists"); } if (((!)rule).name != name.strip()) { ((!)rule).name = name.strip(); edited = true; } } if (body != null && body.length > 0) { if (((!)rule).body != body) { ((!)rule).body = body; edited = true; } } if (edited) { ((!)rule).updated_at = new DateTime.now_local(); } yield db.update_async((!)rule); return rule; } /** * Deletes a rule and every attachment referencing it (task and * project links go with it). */ public async void delete_rule(int64 id) throws Error { foreach (var link in yield db.query() .where(expr("rule_id == $0", elem(id))) .materialise_async()) { yield db.delete_async(link); } foreach (var link in yield db.query() .where(expr("rule_id == $0", elem(id))) .materialise_async()) { yield db.delete_async(link); } var rule = yield get_rule(id); if (rule != null) { yield db.delete_async((!)rule); } } /** * Attaches `rule_id` to `project_id`; duplicates are rejected * (the pair is unique). */ public async void attach_rule_to_project(int64 rule_id, int64 project_id) throws Error { var rule = yield get_rule(rule_id); if (rule == null) { throw new Error(Quark.from_string("clanker-saddle"), 33, @"No rule exists with id $(rule_id)"); } var existing = yield db.query() .where(expr("rule_id == $0 && project_id == $1", elem(rule_id), elem(project_id))) .materialise_async(); if (existing.length > 0) { return; } yield db.insert_async(new RuleProject() { rule_id = rule_id, project_id = project_id, created_at = new DateTime.now_local() }); } /** Detaches the `rule_projects` row identified by `id`. */ public async void detach_rule_from_project(int64 link_id) throws Error { var link = yield db.query() .where(expr("id == $0", elem(link_id))) .first_async(); if (link != null) { yield db.delete_async((!)link); } } /** The `rule_projects` attachments of `project_id`, oldest first. */ public async ImmutableLot list_project_attachments(int64 project_id) throws Error { return yield db.query() .where(expr("project_id == $0", elem(project_id))) .order_by(expr("id")) .materialise_async(); } /** The rules attached to `project_id`, attachment order. */ public async ImmutableLot list_rules_for_project(int64 project_id) throws Error { var rows = new Vector(); foreach (var link in yield list_project_attachments(project_id)) { var rule = yield get_rule(link.rule_id); if (rule != null) { rows.add((!)rule); } } return rows.to_immutable_buffer(); } /** * Attaches `rule_id` to `task_id`; duplicates are rejected (the * pair is unique). */ public async void attach_rule_to_task(int64 rule_id, int64 task_id) throws Error { var rule = yield get_rule(rule_id); if (rule == null) { throw new Error(Quark.from_string("clanker-saddle"), 33, @"No rule exists with id $(rule_id)"); } var existing = yield db.query() .where(expr("rule_id == $0 && task_id == $1", elem(rule_id), elem(task_id))) .materialise_async(); if (existing.length > 0) { return; } yield db.insert_async(new TaskRule() { rule_id = rule_id, task_id = task_id, created_at = new DateTime.now_local() }); } /** Detaches the `task_rules` row identified by `id`. */ public async void detach_rule_from_task(int64 link_id) throws Error { var link = yield db.query() .where(expr("id == $0", elem(link_id))) .first_async(); if (link != null) { yield db.delete_async((!)link); } } /** The `task_rules` attachments of `task_id`, oldest first. */ public async ImmutableLot list_task_attachments(int64 task_id) throws Error { return yield db.query() .where(expr("task_id == $0", elem(task_id))) .order_by(expr("id")) .materialise_async(); } /** The rules attached to `task_id`, attachment order. */ public async ImmutableLot list_rules_for_task(int64 task_id) throws Error { var rows = new Vector(); foreach (var link in yield list_task_attachments(task_id)) { var rule = yield get_rule(link.rule_id); if (rule != null) { rows.add((!)rule); } } return rows.to_immutable_buffer(); } /** * The effective rule set for a dispatched task, in the wire * contract's stable order: the task's own rules, then the primary * project's, then the co-developed projects' in link order — * deduped by id (reference projects contribute nothing). */ public async ImmutableLot effective_rules(TaskEntity task, ProjectService projects) throws Error { return yield effective_rules_for(task, projects, 0); } /** * The goal-aware variant (plan 12): with `goal_id` > 0 the * co-developed expansion walks the GOAL's links instead of the * project's — goal work (the agent's run and its worker * tasks) scopes its involved repos through the goal. */ public async ImmutableLot effective_rules_for(TaskEntity task, ProjectService projects, int64 goal_id) throws Error { var ordered = new Vector(); // Ids are stringified: the Invercargill collections cannot // key (or hash) on value types. var seen = new HashSet(); foreach (var rule in yield list_rules_for_task(task.id)) { if (seen.add(rule.id.to_string())) { ordered.add(rule); } } // Goal 8 task 4: standalone tasks (project 0) carry only // their own task rules — no project exists to expand. if (task.project_id > 0) { foreach (var rule in yield list_rules_for_project(task.project_id)) { if (seen.add(rule.id.to_string())) { ordered.add(rule); } } } if (goal_id > 0) { foreach (var link in yield db.query() .where(expr("goal_id == $0", elem(goal_id))) .order_by(expr("id")) .materialise_async()) { if (link.relationship != LINK_CO_DEVELOPED) { continue; } foreach (var rule in yield list_rules_for_project(link.project_id)) { if (seen.add(rule.id.to_string())) { ordered.add(rule); } } } } else if (task.project_id > 0) { foreach (var link in yield projects.list_links(task.project_id)) { if (link.relationship != LINK_CO_DEVELOPED) { continue; } foreach (var rule in yield list_rules_for_project(link.linked_project_id)) { if (seen.add(rule.id.to_string())) { ordered.add(rule); } } } } return ordered.to_immutable_buffer(); } } }