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). * The task lands in DRAFT — the human Enqueue action queues it; * there is no agent path. Priority clamps to 1–9; the optional * parent id supports 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. * The plan-auto-approve and auto-enqueue-subtasks options store * the per-task plan-11 levers. */ public class CreateTaskAction : StatumAction { private TaskService tasks = inject(); private CapabilityService capabilities = 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; } // Required capabilities: the create form's multi-select posts // several capability_id fields; every id must resolve before // the task is created. (A plain array — the Invercargill // collections mishandle boxed value types in foreach.) int64[] required = {}; if (request.form != null) { foreach (var value in request.form.get_field_all("capability_id")) { int64 parsed = int64.parse(value.strip()); if (parsed <= 0) { continue; } var capability = yield capabilities.get_capability(parsed); if (capability == null) { return directives().notify("error", @"No capability exists with id $(parsed)").navigate("/tasks"); } required += parsed; } } try { string mode = Forms.field(request, "mode"); if (mode.length == 0) { mode = "implement"; } bool mode_valid = false; foreach (string candidate in TASK_MODES) { if (mode == candidate) { mode_valid = true; break; } } if (!mode_valid) { return directives().notify("error", @"Unknown mode '$(mode)'").navigate("/tasks"); } int64 enqueue_after = Forms.integer(request, "enqueue_after_task_id", 0); if (enqueue_after > 0) { yield tasks.validate_enqueue_after_cycle(-1, enqueue_after); } var task = yield tasks.create_task(project_id, title, Forms.field_raw(request, "description"), Forms.integer(request, "priority", 5), parent_id, Forms.optional(request, "task_branch"), Forms.optional(request, "branch_from"), Forms.field(request, "plan_auto_approve") == "on", Forms.field(request, "auto_enqueue_subtasks") == "on", Forms.field(request, "auto_approve") == "on", STATUS_DRAFT, TASK_KIND_TASK, mode, CREATOR_HUMAN, 0, enqueue_after); if (Forms.field(request, "plan").length > 0) { yield tasks.update_plan(task.id, Forms.field_raw(request, "plan")); } foreach (int64 capability_id in required) { yield capabilities.attach_to_task(capability_id, task.id); } return directives().notify("success", @"Task '$(task.title)' created in draft") .navigate(@"/tasks/$(task.id)"); } catch (Error e) { return directives().notify("error", @"Could not create the task: $(e.message)").navigate("/tasks"); } } } /** * Saves the human-editable fields of a task under the draft * lifecycle's edit matrix (see {@link TaskService.edit_task}): in * draft every field except status, in non-draft non-terminal * states priority only, in terminal states nothing. Status is not * part of the form — Enqueue/Cancel/bot transitions own it. */ 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; string priority_field = Forms.field(request, "priority"); if (priority_field.length > 0) { priority = Forms.integer(request, "priority", 5); } // The plan-12 mode select and the auto-enqueue // predecessor (draft-only fields; -1 clears). string mode = ""; if (request.form != null && request.form.get_field("mode") != null) { mode = Forms.field(request, "mode"); } int64? enqueue_after = null; string enqueue_field = Forms.field(request, "enqueue_after_task_id"); if (enqueue_field.length > 0) { int64 parsed = int64.parse(enqueue_field); enqueue_after = parsed > 0 ? parsed : -1; } yield tasks.edit_task(task_id, Forms.optional(request, "title"), request.form != null ? Forms.field(request, "description") : null, priority, request.form != null && request.form.get_field("task_branch") != null ? Forms.field(request, "task_branch") : null, request.form != null && request.form.get_field("branch_from") != null ? Forms.field(request, "branch_from") : null, Forms.field(request, "plan_auto_approve") == "on", Forms.field(request, "auto_enqueue_subtasks") == "on", Forms.field(request, "auto_approve") == "on", mode.length > 0 ? mode : null, enqueue_after); 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. * Review and plan blocks submit a `feedback` reply which is stored * wrapped in the stable verdict wording ("Review feedback * received:\n…" / "Plan feedback received:\n…") — the answer is the * resume_notice the daemon reads. Accepting a plan (the stable * approval wording) also applies the submitted plan markdown to * task.plan. Question/RFC answers are stored verbatim — the * resume-notice builder applies the "Human selected: …" formatting. */ 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"); string feedback = Forms.field_raw(request, "feedback"); if (option == "__other__") { answer = other.strip().length > 0 ? other.strip() : answer; } else if (option.length > 0) { answer = option; } else if (feedback.strip().length > 0) { var block = yield tasks.get_task(task_id); string type = ""; foreach (var candidate in ((!)block).blocks) { if (candidate.id == ((!)ctx).block_id) { type = candidate.block_type; break; } } answer = type == BLOCK_PLAN ? @"$(NOTICE_PLAN_FEEDBACK)\n$(feedback.strip())" : @"$(NOTICE_REVIEW_FEEDBACK)\n$(feedback.strip())"; } if (answer.length == 0) { return directives().notify("error", "An answer is required").navigate(@"/tasks/$(task_id)"); } try { // Accepting a plan applies the submitted markdown to the // task's plan field (the approved text IS the plan). if (answer == NOTICE_PLAN_APPROVED) { foreach (var candidate in (yield tasks.get_task(task_id)).blocks) { if (candidate.id == ((!)ctx).block_id && candidate.block_type == BLOCK_PLAN && candidate.body != null && ((!)candidate.body).length > 0) { yield tasks.update_plan(task_id, (!)candidate.body); break; } } } 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)"); } } } /** * The human-only Enqueue action: moves a draft task into the * queue. Refused with a shown error while any dependency is still * in draft; there is no agent path to enqueueing whatsoever. */ public class EnqueueTaskAction : 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.enqueue_task(task_id, CREATOR_HUMAN); return directives().notify("success", "Task enqueued").navigate(@"/tasks/$(task_id)"); } catch (Error e) { return directives().notify("error", @"Could not enqueue the task: $(e.message)") .navigate(@"/tasks/$(task_id)"); } } } /** * The human-only Delete action: removes a draft task that nothing * depends on (an error names the dependents otherwise — they must * be deleted first). Non-draft tasks are refused. */ public class DeleteTaskAction : 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"); } // Plan 12: goal tasks are the goal agent's to delete (the // task CLI callback under its goal) — humans cancel goals // instead; the cascade closes their tasks. var detail = yield tasks.get_task(task_id); if (detail != null && (((!)detail).task.creator != CREATOR_HUMAN || ((!)detail).task.kind == TASK_KIND_GOAL_RUN)) { return directives().notify("error", "Goal tasks are managed by their goal agent — cancel the goal instead") .navigate(@"/tasks/$(task_id)"); } try { yield tasks.delete_task(task_id); return directives().notify("success", "Draft task deleted").navigate("/tasks"); } catch (Error e) { return directives().notify("error", @"Could not delete the task: $(e.message)") .navigate(@"/tasks/$(task_id)"); } } } /** * Cancels a task (status 'failed'); the sealed id comes from the task * page's cancel button. Only non-draft non-terminal tasks offer the * action — drafts are deleted, terminal tasks are already finished, * and completion itself is bot-only. */ public class CancelTaskAction : SealedTaskAction { private TaskService tasks = inject(); private SaddleService saddles = 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"); } var detail = yield tasks.get_task(task_id); if (detail == null) { return directives().navigate("/tasks"); } string status = ((!)detail).task.status; if (status == STATUS_DRAFT || status == STATUS_DONE || status == STATUS_FAILED) { return directives().notify("error", "Only non-draft, non-terminal tasks can be cancelled") .navigate(@"/tasks/$(task_id)"); } try { yield saddles.abort_live_session(tasks, task_id, "cancelled"); 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)"); } } } /** * Retries a failed task: the requeue path with the failure summary * moved into a comment (and cleared), all history kept. The next * dispatch resumes the daemon-side workspace when it still exists. */ public class RetryTaskAction : SealedTaskAction { private TaskService tasks = inject(); private SaddleService saddles = 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"); } var detail = yield tasks.get_task(task_id); if (detail == null) { return directives().navigate("/tasks"); } if (((!)detail).task.status != STATUS_FAILED) { return directives().notify("error", "Only failed tasks can be retried") .navigate(@"/tasks/$(task_id)"); } try { yield saddles.abort_live_session(tasks, task_id, "retried"); yield tasks.retry_task(task_id); return directives().notify("success", "Task requeued for another attempt") .navigate(@"/tasks/$(task_id)"); } catch (Error e) { return directives().notify("error", @"Could not retry the task: $(e.message)") .navigate(@"/tasks/$(task_id)"); } } } /** * Manually requeues a task stuck working/blocked on a saddle that * went offline (the automatic three-strike path may never fire when * the saddle row was refreshed to offline first). The same path as * the dispatcher's abandon: requeue + bot comment + saddle offline. */ public class ResumeTaskAction : SealedTaskAction { private TaskService task_service = inject(); private SaddleService saddle_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().navigate("/tasks"); } try { var session = yield task_service.open_session_for_task(task_id); if (session != null) { yield saddle_service.abort_live_session(task_service, task_id, "stopped"); yield saddle_service.mark_saddle_unreachable(((!)session).saddle_id, task_service); } else { yield task_service.requeue_task(task_id); } return directives().notify("success", "Task requeued; the daemon restores its context on the next dispatch") .navigate(@"/tasks/$(task_id)"); } catch (Error e) { return directives().notify("error", @"Could not requeue the task: $(e.message)") .navigate(@"/tasks/$(task_id)"); } } } /** * Flips a task's subtask auto-approve toggle: when on, review blocks * raised by this task's descendants are answered automatically — * the task's own review stays a human gate. */ public class ToggleAutoApproveAction : SealedTaskAction { private TaskService task_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().navigate("/tasks"); } try { var detail = yield task_service.get_task(task_id); if (detail == null) { return directives().navigate("/tasks"); } bool enabled = ((!)detail).task.auto_approve == 0; yield task_service.save_auto_approve(task_id, enabled); string state = enabled ? "on" : "off"; return directives().notify("success", @"Subtask auto-approve is now $(state)") .navigate(@"/tasks/$(task_id)"); } catch (Error e) { return directives().notify("error", @"Could not toggle auto-approve: $(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 CapabilityService capability_service = inject(); private SaddlePollState poll_state = inject(); private PushService push_service = inject(); private InvercargillSql.Orm.OrmSession db = Inversion.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 me = SpryAuth.current(request); var state = yield TaskDetailState.build(state_service, task_service, project_service, saddle_service, rule_service, capability_service, action_registry, poll_state, push_service, db, me != null ? ((!)me).user_id : 0, task_id); return directives().set(state); } } }