using Inversion; using Spry; using Spry.Authentication; using Statum; using ClankerSaddle.Services; namespace ClankerSaddle { /** Sealed task id carried by the task row actions. */ public class TaskActionPrivate : Object { public int64 task_id { get; set; } } /** Sealed block identity carried by {@link AnswerBlockAction}. */ public class BlockAnswerPrivate : Object { public int64 task_id { get; set; } public int64 block_id { get; set; } } /** * Common base for actions sealed against a task id: materialises the * typed private blob and exposes the resolved id (falling back to the * hidden `task_id` form field). */ public abstract class SealedTaskAction : TypedStatumAction { protected override Type private_type { get { return typeof(TaskActionPrivate); } } /** The sealed task id, or 0 when neither blob nor form field carried one. */ protected int64 sealed_task_id() { var ctx = request_private; return ctx != null ? ((!)ctx).task_id : Forms.integer(request, "task_id", 0); } } /** * Creates a task from the queue form (project and title required). * Priority clamps to 1–9; the design-task checkbox and the optional * parent id support design tasks and manual subtasks. Branch fields * are optional — `branch_from` prefills from the project's default * branch and `task_branch` is inherited from the parent task when * left blank. */ public class CreateTaskAction : StatumAction { private TaskService tasks = 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; } int64 project_id = Forms.integer(request, "project_id", 0); string title = Forms.field(request, "title"); if (project_id <= 0 || title.length == 0) { return directives().notify("error", "Project and title are required").navigate("/tasks"); } int64? parent_id = null; int64 parsed_parent = Forms.integer(request, "parent_task_id", 0); if (parsed_parent > 0) { parent_id = parsed_parent; } try { var task = yield tasks.create_task(project_id, title, Forms.field_raw(request, "description"), Forms.integer(request, "priority", 5), Forms.field(request, "is_design_task") == "on", parent_id, Forms.optional(request, "task_branch"), Forms.optional(request, "branch_from")); if (Forms.field(request, "plan").length > 0) { yield tasks.update_plan(task.id, Forms.field_raw(request, "plan")); } return directives().notify("success", @"Task '$(task.title)' queued") .navigate(@"/tasks/$(task.id)"); } catch (Error e) { return directives().notify("error", @"Could not create the task: $(e.message)").navigate("/tasks"); } } } /** * Saves the editable fields of a task; the id comes from the sealed * {@link TaskActionPrivate} blob (the hidden form field is a * fallback). */ public class UpdateTaskAction : SealedTaskAction { private TaskService tasks = 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; } int64 task_id = sealed_task_id(); if (task_id <= 0) { return directives().navigate("/tasks"); } try { int64? priority = null; if (Forms.field(request, "priority").length > 0) { priority = Forms.integer(request, "priority", 5); } yield tasks.update_task(task_id, Forms.optional(request, "title"), request.form != null ? Forms.field(request, "description") : null, priority, Forms.optional(request, "status")); yield tasks.set_branch_fields(task_id, Forms.optional(request, "task_branch"), Forms.optional(request, "branch_from")); return directives().notify("success", "Task saved").navigate(@"/tasks/$(task_id)"); } catch (Error e) { return directives().notify("error", @"Could not save the task: $(e.message)") .navigate(@"/tasks/$(task_id)"); } } } /** * Appends a comment to a task as the signed-in user. An optional * `quote` field carries text the user highlighted in the terminal log * or an RFC body; the quote is stored with the comment and delivered * to the saddle as a quote block. */ public class AddCommentAction : SealedTaskAction { private TaskService tasks = 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; } int64 task_id = sealed_task_id(); string body = Forms.field_raw(request, "body"); string quote = Forms.field_raw(request, "quote"); if (task_id <= 0 || body.length == 0) { return directives().notify("error", "A comment body is required").navigate(@"/tasks/$(task_id)"); } var me = SpryAuth.current(request); try { yield tasks.add_comment(task_id, @"user:$(me.username)", body, quote); yield tasks.log_session_entry(task_id, "user-comment", @"comment by user:$(me.username)", quote.length > 0 ? "> " + quote.replace("\n", "\n> ") + "\n\n" + body : body); // Terminals post with a return_to so the live view survives // the comment; every other caller lands on the task page. string return_to = Forms.field(request, "return_to"); string destination = return_to.length > 0 && return_to.has_prefix("/") ? return_to : @"/tasks/$(task_id)"; return directives().notify("success", "Comment added").navigate(destination); } catch (Error e) { return directives().notify("error", @"Could not add the comment: $(e.message)") .navigate(@"/tasks/$(task_id)"); } } } /** * Saves the task's markdown plan from the web UI's plan editor (the * human counterpart of the agent's `saddle plan`). */ public class UpdatePlanAction : SealedTaskAction { private TaskService tasks = 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; } int64 task_id = sealed_task_id(); if (task_id <= 0) { return directives().navigate("/tasks"); } try { yield tasks.update_plan(task_id, Forms.field_raw(request, "plan")); return directives().notify("success", "Plan saved").navigate(@"/tasks/$(task_id)"); } catch (Error e) { return directives().notify("error", @"Could not save the plan: $(e.message)") .navigate(@"/tasks/$(task_id)"); } } } /** * Records the human's answer to a block; the task and block ids come * from the sealed {@link BlockAnswerPrivate} blob. Question blocks * submit either a chosen `option` or the free-text `other` field. * Answers are stored verbatim — the saddle daemon applies its own * "Human selected: …" formatting, so a stored prefix would wrap the * answer a second time. */ public class AnswerBlockAction : TypedStatumAction { private TaskService tasks = inject(); private UserService users = inject(); protected override Type private_type { get { return typeof(BlockAnswerPrivate); } } 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("/tasks"); } int64 task_id = ((!)ctx).task_id; string option = Forms.field(request, "option"); string other = Forms.field_raw(request, "other"); string answer = Forms.field_raw(request, "answer"); if (option == "__other__") { answer = other.strip().length > 0 ? other.strip() : answer; } else if (option.length > 0) { answer = option; } if (answer.length == 0) { return directives().notify("error", "An answer is required").navigate(@"/tasks/$(task_id)"); } try { yield tasks.answer_block(((!)ctx).block_id, answer); return directives().notify("success", "Answer recorded; the saddle will collect it") .navigate(@"/tasks/$(task_id)"); } catch (Error e) { return directives().notify("error", @"Could not answer the block: $(e.message)") .navigate(@"/tasks/$(task_id)"); } } } /** Creates a child task under the sealed parent task id. */ public class CreateSubtaskAction : SealedTaskAction { private TaskService tasks = 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; } int64 task_id = sealed_task_id(); string title = Forms.field(request, "title"); if (task_id <= 0 || title.length == 0) { return directives().notify("error", "A subtask title is required").navigate(@"/tasks/$(task_id)"); } try { yield tasks.create_subtask(task_id, title, Forms.field_raw(request, "description")); return directives().notify("success", "Subtask created").navigate(@"/tasks/$(task_id)"); } catch (Error e) { return directives().notify("error", @"Could not create the subtask: $(e.message)") .navigate(@"/tasks/$(task_id)"); } } } /** Adds a dependency edge from the sealed task to the form's target. */ public class AddDependencyAction : SealedTaskAction { private TaskService tasks = 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; } int64 task_id = sealed_task_id(); int64 depends_on = Forms.integer(request, "depends_on_task_id", 0); if (task_id <= 0 || depends_on <= 0) { return directives().notify("error", "Pick a task to depend on").navigate(@"/tasks/$(task_id)"); } try { yield tasks.add_dependency(task_id, depends_on); return directives().notify("success", "Dependency added").navigate(@"/tasks/$(task_id)"); } catch (Error e) { return directives().notify("error", @"Could not add the dependency: $(e.message)") .navigate(@"/tasks/$(task_id)"); } } } /** * Completes a task (summary and working branch from the form); closes * the open session and frees the saddle running it. */ public class CompleteTaskAction : SealedTaskAction { private TaskService tasks = 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; } int64 task_id = sealed_task_id(); if (task_id <= 0) { return directives().navigate("/tasks"); } try { yield tasks.complete_task(task_id, Forms.optional(request, "summary"), Forms.optional(request, "working_branch"), "done"); return directives().notify("success", "Task completed").navigate(@"/tasks/$(task_id)"); } catch (Error e) { return directives().notify("error", @"Could not complete the task: $(e.message)") .navigate(@"/tasks/$(task_id)"); } } } /** * Cancels a task (status 'failed'); the sealed id comes from the task * page's cancel button. */ public class CancelTaskAction : SealedTaskAction { private TaskService tasks = 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; } int64 task_id = sealed_task_id(); if (task_id <= 0) { return directives().navigate("/tasks"); } try { yield tasks.cancel_task(task_id); return directives().notify("success", "Task cancelled").navigate(@"/tasks/$(task_id)"); } catch (Error e) { return directives().notify("error", @"Could not cancel the task: $(e.message)") .navigate(@"/tasks/$(task_id)"); } } } /** * The task detail page's polling action: rebuilds the `task` slot from * current data so the three-second client poll shows live comments, * blocks, output-driven status changes and completions. Navigates * nowhere; a failed reload leaves the client's state untouched. */ public class RefreshTaskDataAction : SealedTaskAction { private TaskService task_service = inject(); private ProjectService project_service = inject(); private SaddleService saddle_service = inject(); private RuleService rule_service = 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; } int64 task_id = sealed_task_id(); if (task_id <= 0) { return directives(); } var state = yield TaskDetailState.build(state_service, task_service, project_service, saddle_service, rule_service, action_registry, task_id); return directives().set(state); } } }