using Inversion; using Spry; using Spry.Authentication; using Statum; using ClankerSaddle.Services; namespace ClankerSaddle { /** Sealed project id carried by the project actions. */ public class ProjectActionPrivate : Object { public int64 project_id { get; set; } } /** Sealed link identity carried by {@link RemoveProjectLinkAction}. */ public class ProjectLinkActionPrivate : Object { public int64 project_id { get; set; } public int64 link_id { get; set; } } /** Sealed note identity carried by {@link DeleteProjectNoteAction}. */ public class ProjectNoteActionPrivate : Object { public int64 project_id { get; set; } public int64 note_id { get; set; } } /** * The title prefix of every understanding refresh task (goal 24): * {@link RefreshUnderstandingAction} creates them and refuses a * second refresh while one is still live. */ public const string UNDERSTANDING_REFRESH_PREFIX = "Understanding refresh:"; /** * Creates a project from the projects form (name and git URL * required); default_branch falls back to 'main'. */ public class CreateProjectAction : StatumAction { private ProjectService projects = inject(); private UserService users = inject(); public override async DirectiveBuilder handle() throws GLib.Error { var guard = yield SpryAuth.require_login(directives(), request, users); if (guard != null) { return (!)guard; } string name = Forms.field(request, "name"); string git_url = Forms.field(request, "git_url"); if (name.length == 0 || git_url.length == 0) { return directives().notify("error", "Name and git URL are required").navigate("/projects"); } try { var project = yield projects.create_project(name, git_url, Forms.field(request, "default_branch"), Forms.field(request, "summary")); return directives().notify("success", @"Project '$(project.name)' created") .navigate(@"/projects/$(project.id)"); } catch (Error e) { return directives().notify("error", @"Could not create the project: $(e.message)").navigate("/projects"); } } } /** * Saves the editable fields of a project; the id comes from the sealed * {@link ProjectActionPrivate} blob (the hidden form field is a * fallback). An empty summary clears the stored one. */ public class UpdateProjectAction : TypedStatumAction { private ProjectService projects = inject(); private UserService users = inject(); protected override Type private_type { get { return typeof(ProjectActionPrivate); } } public override async DirectiveBuilder handle() throws GLib.Error { var guard = yield SpryAuth.require_login(directives(), request, users); if (guard != null) { return (!)guard; } var ctx = request_private; int64 project_id = ctx != null ? ((!)ctx).project_id : Forms.integer(request, "project_id", 0); if (project_id <= 0) { return directives().navigate("/projects"); } try { yield projects.update_project(project_id, Forms.optional(request, "name"), Forms.optional(request, "git_url"), Forms.optional(request, "default_branch"), request.form != null ? Forms.field(request, "summary") : null); return directives().notify("success", "Project saved") .navigate(@"/projects/$(project_id)"); } catch (Error e) { return directives().notify("error", @"Could not save the project: $(e.message)") .navigate(@"/projects/$(project_id)"); } } } /** * Links another project to this one; the parent id is sealed, the * target and relationship come from the form selects. */ public class AddProjectLinkAction : TypedStatumAction { private ProjectService projects = inject(); private UserService users = inject(); protected override Type private_type { get { return typeof(ProjectActionPrivate); } } public override async DirectiveBuilder handle() throws GLib.Error { var guard = yield SpryAuth.require_login(directives(), request, users); if (guard != null) { return (!)guard; } var ctx = request_private; int64 project_id = ctx != null ? ((!)ctx).project_id : Forms.integer(request, "project_id", 0); int64 linked_id = Forms.integer(request, "linked_project_id", 0); string relationship = Forms.field(request, "relationship"); if (project_id <= 0 || linked_id <= 0) { return directives().notify("error", "Pick a project to link").navigate(@"/projects/$(project_id)"); } try { yield projects.add_link(project_id, linked_id, relationship); return directives().notify("success", "Link added").navigate(@"/projects/$(project_id)"); } catch (Error e) { return directives().notify("error", @"Could not add the link: $(e.message)") .navigate(@"/projects/$(project_id)"); } } } /** Removes a project link; both ids come from the sealed private blob. */ public class RemoveProjectLinkAction : TypedStatumAction { private ProjectService projects = inject(); private UserService users = inject(); protected override Type private_type { get { return typeof(ProjectLinkActionPrivate); } } public override async DirectiveBuilder handle() throws GLib.Error { var guard = yield SpryAuth.require_login(directives(), request, users); if (guard != null) { return (!)guard; } var ctx = request_private; if (ctx == null) { return directives().navigate("/projects"); } try { yield projects.remove_link(((!)ctx).link_id); return directives().notify("success", "Link removed") .navigate(@"/projects/$(((!)ctx).project_id)"); } catch (Error e) { return directives().notify("error", @"Could not remove the link: $(e.message)") .navigate(@"/projects/$(((!)ctx).project_id)"); } } } /** * Deletes a project note; both ids come from the sealed private blob. * Notes are LLM-authored (`saddle note` via the callback endpoint) — * the web UI only curates them. */ public class DeleteProjectNoteAction : TypedStatumAction { private ProjectService projects = inject(); private UserService users = inject(); protected override Type private_type { get { return typeof(ProjectNoteActionPrivate); } } public override async DirectiveBuilder handle() throws GLib.Error { var guard = yield SpryAuth.require_login(directives(), request, users); if (guard != null) { return (!)guard; } var ctx = request_private; if (ctx == null) { return directives().navigate("/projects"); } try { yield projects.delete_note(((!)ctx).note_id); return directives().notify("success", "Note deleted") .navigate(@"/projects/$(((!)ctx).project_id)"); } catch (Error e) { return directives().notify("error", @"Could not delete the note: $(e.message)") .navigate(@"/projects/$(((!)ctx).project_id)"); } } } /** * Spins up the project's understanding refresh task (goal 24): a * QUEUED investigate task whose agent explores the project, walks * the git history since the current document's commit when one * exists, and saves an UPDATED markdown understanding via the * `save_understanding` tool. Refuses while another refresh task * for the project is still live — one explorer at a time. */ public class RefreshUnderstandingAction : TypedStatumAction { private ProjectService projects = inject(); private UnderstandingService understandings = inject(); private TaskService tasks = inject(); private UserService users = inject(); protected override Type private_type { get { return typeof(ProjectActionPrivate); } } public override async DirectiveBuilder handle() throws GLib.Error { var guard = yield SpryAuth.require_login(directives(), request, users); if (guard != null) { return (!)guard; } var ctx = request_private; int64 project_id = ctx != null ? ((!)ctx).project_id : Forms.integer(request, "project_id", 0); if (project_id <= 0) { return directives().navigate("/projects"); } var project = yield projects.get_project(project_id); if (project == null) { return directives().notify("error", "That project no longer exists").navigate("/projects"); } // One live explorer at a time: a non-terminal task whose // title carries the refresh prefix blocks a new refresh. foreach (var task in yield tasks.list_tasks(null, project_id)) { if (!task.title.has_prefix(UNDERSTANDING_REFRESH_PREFIX)) { continue; } if (task.status == STATUS_DONE || task.status == STATUS_FAILED) { continue; } return directives().notify("error", @"An understanding refresh (#$(task.id)) is already running for this project") .navigate(@"/projects/$(project_id)"); } var current = yield understandings.current_understanding(project_id); string old_hash = current != null ? ((!)current).commit_hash : ""; string old_body = current != null ? ((!)current).body : ""; var brief = new StringBuilder(); brief.append("MANDATE — you are the project's understanding explorer.\n\n"); brief.append("Explore the project's structure and produce a markdown understanding "); brief.append("document covering the project layout and anything unusual that would "); brief.append("be useful to know for anyone working on it.\n"); if (old_body.length > 0) { brief.append("\nA previous understanding document exists. Do NOT start from "); brief.append("scratch: review the git history since the old commit hash "); brief.append("(git log ..HEAD, plus diffs of the significant changes) and "); brief.append("produce an UPDATED document that keeps what still holds and "); brief.append("rewrites what changed.\n"); } brief.append("\nThe finishing move: call the save_understanding tool exactly ONCE "); brief.append("with the new document and the git HEAD hash of the explored checkout "); brief.append("(run `git rev-parse HEAD`), then finish(status=done) with a short "); brief.append("summary. You are in investigate mode — do not edit any files.\n"); brief.append("\nPrevious commit hash: "); brief.append(old_hash.length > 0 ? old_hash : "none — first refresh"); if (old_body.length > 0) { brief.append("\n\nPrevious document:\n\n```markdown\n"); brief.append(old_body); brief.append("\n```\n"); } var notes = yield projects.list_notes(project_id); brief.append("\nProject notes from other agents:\n"); if (notes.length == 0) { brief.append("(none)\n"); } else { foreach (var note in notes) { brief.append("\n---\n"); brief.append(note.body); brief.append("\n"); } } try { var task = yield tasks.create_task(project_id, @"$(UNDERSTANDING_REFRESH_PREFIX) $(((!)project).name)", brief.str, 5, null, null, STATUS_QUEUED, TASK_KIND_TASK, "investigate", CREATOR_HUMAN); yield tasks.add_system_comment(task.id, @"Understanding refresh queued against commit $(old_hash.length > 0 ? old_hash : "(none)")"); return directives().notify("success", @"Refresh queued — the explorer task is #$(task.id)") .navigate(@"/projects/$(project_id)"); } catch (Error e) { return directives().notify("error", @"Could not queue the refresh: $(e.message)") .navigate(@"/projects/$(project_id)"); } } } }