using Inversion; using Spry; using Spry.Authentication; using Statum; using ClankerSaddle.Services; using ClankerSaddle.Data; 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); } } /** * Shared base for task-page actions that answer with the * refreshed task slot instead of a navigate (goal 2 QoL): owns * the service set {@link TaskDetailState.build} needs, so a * mutation rebuilds the page view with ONE call - * {@link task_detail_slot} - instead of every action copy-pasting * the injections and the build chain. Success paths answer * notify(...).set(slot); flows whose result genuinely IS * navigation (create -> detail, delete -> list) keep navigating. */ public abstract class TaskDetailRefreshingAction : SealedTaskAction { protected TaskService task_service = inject(); protected ProjectService project_service = inject(); protected SaddleService saddle_service = inject(); protected RuleService rule_service = inject(); protected CapabilityService capability_service = inject(); protected SaddlePollState poll_state = inject(); protected PushService push_service = inject(); protected EventService event_service = inject(); protected InvercargillSql.Orm.OrmSession db = Inversion.inject(); protected UserService users = inject(); /** * Rebuilds the task detail slot from current data - the ONE * call a mutation needs to answer with refreshed state (the * same build the page's refresh action runs). */ protected async Statum.Slot task_detail_slot(int64 task_id) throws GLib.Error { var me = SpryAuth.current(request); return yield TaskDetailState.build(state_service, task_service, project_service, saddle_service, rule_service, capability_service, action_registry, poll_state, push_service, event_service, db, me != null ? ((!)me).user_id : 0, task_id); } } /** * 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. Branch fields * are optional — `branch_from` prefills from the project's default * branch. The mode select and capabilities apply until a worker is * assigned; `enqueue_after_task_id` is the human-only auto-enqueue * predecessor (goal tasks sequence via the goal agent instead). */ public class CreateTaskAction : StatumAction { private TaskService tasks = inject(); private CapabilityService capabilities = inject(); private UserService users = inject(); private ProjectService projects = inject(); /** The project of `id`, or null (standalone validation). */ private async ClankerSaddle.Data.Project? tasks_project(int64 id) throws GLib.Error { return yield projects.get_project(id); } 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"); // Goal 8 task 4: project_id 0 posts a STANDALONE task (the // form's "No project" option) — no repository, its own // ephemeral workspace; negative ids are form corruption. if (project_id < 0) { return directives().notify("error", "Unknown project").navigate("/tasks"); } if (title.length == 0) { return directives().notify("error", "Title is required").navigate("/tasks"); } if (project_id > 0) { var project = yield tasks_project(project_id); if (project == null) { return directives().notify("error", @"No project exists with id $(project_id)").navigate("/tasks"); } } // 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"; } if (!is_valid_mode(mode)) { 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), Forms.optional(request, "task_branch"), Forms.optional(request, "branch_from"), STATUS_DRAFT, TASK_KIND_TASK, mode, CREATOR_HUMAN, 0, enqueue_after); 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/agent transitions own it. */ public class UpdateTaskAction : TaskDetailRefreshingAction { 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; } // The carried fix: the mandate only updates when the // form actually carried the description field (the // priority-only edit must not blank it — an absent // field is "leave unchanged", same as title). yield task_service.edit_task(task_id, Forms.optional(request, "title"), request.form != null && request.form.get_field("description") != 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, mode.length > 0 ? mode : null, enqueue_after); return directives().notify("success", "Task saved").set(yield task_detail_slot(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 { var task = yield tasks.get_task(task_id); if (task == null) { return directives().notify("error", @"No task exists with id $(task_id)") .navigate("/tasks"); } // Goal 8 task 4: comments stay OPEN on terminal tasks, // and a comment on a TERMINAL NON-GOAL task (goal_id // == 0) resurrects it — the task re-queues with the // comment injected (the daemon receives the thread's // comments on start). Goal-owned worker tasks keep // their goal agent as the only voice: the service // refuses below. bool terminal_plain = (((!)task).task.goal_id == 0) && ((((!)task).task.status == STATUS_DONE) || (((!)task).task.status == STATUS_FAILED)); yield tasks.add_comment(task_id, @"user:$(me.username)", body, quote); if (terminal_plain) { yield tasks.requeue_terminal_task(task_id); } // 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)"; string message = terminal_plain ? "Comment added — task re-queued for another round" : "Comment added"; return directives().notify("success", message).navigate(destination); } catch (Error e) { return directives().notify("error", @"Could not add the comment: $(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. Plan 16 S2/R4: accept forms post * a `verdict` marker (`accept`) instead of the stable constants — * the server resolves the block kind and owns every verdict string. * 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 GoalService goals = 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"); string verdict = Forms.field(request, "verdict"); if (option == "__other__") { answer = other.strip().length > 0 ? other.strip() : answer; } else if (option.length > 0) { answer = option; } else if (verdict == "accept" || feedback.strip().length > 0) { // S2/R4: the server owns the verdict wording — the // block's kind picks the stable string. string type = ""; var detail = yield tasks.get_task(task_id); if (detail != null) { foreach (var candidate in ((!)detail).blocks) { if (candidate.id == ((!)ctx).block_id) { type = candidate.block_type; break; } } } if (verdict == "accept") { answer = type == BLOCK_PLAN ? NOTICE_PLAN_APPROVED : NOTICE_REVIEW_APPROVED; } else { 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 { // Plan 15 C1: a goal-run task's blocks ARE the goal's // blocks — the deep-link answer funnels into the ONE // goal answer path (verdict transitions + row settle + // resume machinery); every other task answers plainly. // The answer rides the parked workspace's resume // verbatim (the stable verdicts). var detail = yield tasks.get_task(task_id); if (detail != null && ((!)detail).task.kind == TASK_KIND_GOAL_RUN && ((!)detail).task.goal_id > 0) { var me = SpryAuth.current(request); yield goals.answer_block(((!)ctx).block_id, answer, me != null ? @"user:$(me.username)" : null); } else { 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)"); } } } /** * 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 agent-only. The abort-before-cancel * orchestration lives in {@link LifecycleService.cancel_task}. */ public class CancelTaskAction : SealedTaskAction { private TaskService tasks = inject(); private LifecycleService lifecycle = 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 lifecycle.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 * cleared (plan 16 S4: the terminal cache dies with its attempt — * nothing derived is copied to the thread), all other history * kept. The next dispatch resumes the daemon-side workspace when * it still exists; the abort-first orchestration lives in * {@link LifecycleService.retry_task}. */ public class RetryTaskAction : SealedTaskAction { private TaskService tasks = inject(); private LifecycleService lifecycle = 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 lifecycle.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)"); } } } /** * Goal 4 task 1 (Force Wake): the human recovery lever for a * parked PLAIN task — sets the task force-wake flag so the * allocation offers it again on its affine saddle with the * explicit wake notice. A suspended GOAL RUN task routes to the * SAME goal force-wake (GoalForceWakeAction) — one path, no * duplicate semantics; the page authors that variant's button. */ public class TaskForceWakeAction : TaskDetailRefreshingAction { private GoalService goal_service = 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().notify("error", @"No task exists with id $(task_id)").navigate("/tasks"); } var task = ((!)detail).task; if (task.kind == TASK_KIND_GOAL_RUN) { // One path: the run's wake is the GOAL's wake. yield goal_service.force_wake_goal(task.goal_id); return directives().notify("success", "Goal run force-woke — the parked agent resumes on its saddle") .navigate(@"/goals/$(task.goal_id)"); } if (task.status != STATUS_SUSPENDED) { return directives().notify("error", "Only parked tasks can be force-woke — nothing was woken") .navigate(@"/tasks/$(task_id)"); } task.force_wake = true; yield db.update_async(task); var wake_payload = new Json.Object(); wake_payload.set_string_member("text", "The user explicitly force-woke this parked task — resume where you left off."); yield event_service.append(EVENT_FORCE_WAKE, EVENT_ACTOR_USER, EVENT_SCOPE_TASK, task_id, wake_payload); return directives().notify("success", "Task force-woke — the parked run resumes on its saddle") .set(yield task_detail_slot(task_id)); } catch (Error e) { return directives().notify("error", @"Could not force-woke the task: $(e.message)") .navigate(@"/tasks/$(task_id)"); } } } /** * Manually requeues a task stuck working/blocked on a saddle that * went offline (the automatic staleness probe may never fire when * the saddle row was refreshed to offline first). The same path as * the dispatcher's abandon: requeue + system comment + saddle * offline — owned by {@link LifecycleService.requeue_for_resume}. */ public class ResumeTaskAction : SealedTaskAction { private LifecycleService lifecycle = 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 lifecycle.requeue_for_resume(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)"); } } } /** * 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. * * Goal 41 T7: the page also embeds the event log's shell (its * "Log" tab), so the refresh rebuilds the `task_log` slot too — * the embedded live badge's status words stay truthful after a * transition without the log shell owning its own poll. */ public class RefreshTaskDataAction : TaskDetailRefreshingAction { 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(); } return directives().set(yield task_detail_slot(task_id)) .set(yield TaskLogEntrypoint.log_slot_for(state_service, db, task_id)); } } }