using Invercargill; using Invercargill.DataStructures; using Invercargill.Expressions; using Spry; using Spry.Authentication; using Statum; using ClankerSaddle.Data; using ClankerSaddle.Services; namespace ClankerSaddle { /** * A comment row in the task detail thread, with its quoted * reference. {@link is_system} marks the harness-authored * bookkeeping rows (plan 15 C2) — rendered with the distinct * muted system styling, never an agent row. */ public class CommentView : Object { public string author { get; set; default = ""; } public string body { get; set; default = ""; } public string quote { get; set; default = ""; } public string when_display { get; set; default = ""; } public bool from_agent { get; set; } public bool is_system { get; set; } } /** One progress line in the task's history. */ public class ProgressView : Object { public int64 id { get; set; } public string summary { get; set; default = ""; } public string when_display { get; set; default = ""; } } /** * One agent celebration (`saddle yeehaw`) on the task page: the * message plus when it landed, rendered inside the festive card. */ public class YeehawView : Object { public string message { get; set; default = ""; } public string when_display { get; set; default = ""; } } /** * A block (question, RFC or review) with its answer state and * answer action. A pending review hides its body preview * ({@link show_body}) because the review banner above it renders * the same markdown; every other typed block (e.g. a question * with a markdown body) keeps its preview. */ public class BlockView : Object { public int64 id { get; set; } public string question { get; set; default = ""; } public string answer { get; set; default = ""; } public string block_type { get; set; default = "question"; } public string body { get; set; default = ""; } public string[] options { get; set; } public bool answered { get; set; } public bool show_body { get; set; default = true; } public string answered_by { get; set; default = ""; } public string created_display { get; set; default = ""; } public string answered_display { get; set; default = ""; } public Statum.Model.ActionDto? answer_action { get; set; } } /** A task option row (the auto-enqueue-after dropdown's entries). */ public class TaskOptionView : Object { public int64 id { get; set; } public string title { get; set; default = ""; } public string status { get; set; default = ""; } } /** A rule attached to the task, with its detach action. */ public class TaskRuleRowView : Object { public int64 link_id { get; set; } public int64 rule_id { get; set; } public string name { get; set; default = ""; } public string body { get; set; default = ""; } public Statum.Model.ActionDto? detach { get; set; } } /** A capability required by the task, with its detach action. */ public class TaskCapabilityRowView : Object { public int64 link_id { get; set; } public string name { get; set; default = ""; } public Statum.Model.ActionDto? detach { get; set; } } /** A session row in the task's history, linking to the terminal view. */ public class SessionView : Object { public int64 id { get; set; } public string saddle_name { get; set; default = ""; } public string started_display { get; set; default = ""; } public string ended_display { get; set; default = ""; } public string outcome { get; set; default = ""; } public bool open { get; set; } } /** Public data of the `task` slot. */ public class TaskDetailPublic : Object { public bool exists { get; set; } public int64 id { get; set; } public string title { get; set; default = ""; } public string description { get; set; default = ""; } public string status { get; set; default = ""; } public int64 priority { get; set; default = 5; } public string summary { get; set; default = ""; } public string task_branch { get; set; default = ""; } public string branch_from { get; set; default = ""; } public bool review_pending { get; set; } public string review_summary { get; set; default = ""; } public string review_diff_url { get; set; default = ""; } public bool resume_available { get; set; } /** * Goal 4 task 1 (Force Wake): the human recovery lever for a * parked task — authored only while the task is suspended * (any kind; a goal_run routes to the goal's wake). Pressing * it sets the task (or goal) force-wake flag. */ public Statum.Model.ActionDto? force_wake { get; set; } public string saddle_health { get; set; default = ""; } public string display { get; set; default = ""; } public bool terminal { get; set; } public bool failed { get; set; } public bool draft { get; set; } /** Draft: every field except status is editable. */ public bool editable_all { get; set; } /** Non-draft non-terminal: priority (only) is editable. */ public bool editable_priority { get; set; } /** Capabilities editable while draft or queued (no worker yet). */ public bool editable_capabilities { get; set; } public int64 project_id { get; set; } public string project_name { get; set; default = ""; } public string git_url { get; set; default = ""; } /** Plan 12: the goal this task belongs to (0 = standalone). */ public int64 goal_id { get; set; } public string goal_title { get; set; default = ""; } public string goal_status { get; set; default = ""; } public string goal_branch { get; set; default = ""; } public string mode { get; set; default = "implement"; } public string creator { get; set; default = "human"; } /** True when the task is the goal agent's own run (goal page owns it). */ public bool is_goal_run { get; set; } /** The auto-enqueue predecessor id (0 = none), human tasks only. */ public int64 enqueue_after_task_id { get; set; } public string enqueue_after_title { get; set; default = ""; } public string created_display { get; set; default = ""; } public string started_display { get; set; default = ""; } public string completed_display { get; set; default = ""; } /** * The completion-report card heading — "Completion report" for * done, "Failure summary" for failed; empty when the card is * absent (non-terminal task or no stored summary). */ public string report_title { get; set; default = ""; } public Statum.Model.ActionDto? update { get; set; } public Statum.Model.ActionDto? comment { get; set; } public Statum.Model.ActionDto? enqueue { get; set; } public Statum.Model.ActionDto? delete { get; set; } public Statum.Model.ActionDto? cancel { get; set; } public Statum.Model.ActionDto? retry { get; set; } public Statum.Model.ActionDto? resume { get; set; } public Statum.Model.ActionDto? refresh { get; set; } public Statum.Model.ActionDto? attach_rule { get; set; } public Statum.Model.ActionDto? attach_capability { get; set; } /** The signed-in user holds a "Notify me" pref for this task. */ public bool notify_me { get; set; } public Statum.Model.ActionDto? toggle_notify { get; set; } /** * The task has at least one `saddle yeehaw` entry — the 🤠 * badge's presence, derived from the existing * session_commands rows (nothing new is stored). */ public bool yeehaw { get; set; } /** The task's uncapped yeehaw count (the card's "+N more" hint). */ public int64 yeehaw_total { get; set; } /** Yeehaw entries beyond the card cap (0 within it). */ public int64 yeehaw_more { get; set; } } /** * Builds the `task` slot state shared by the task detail entrypoint and * the {@link RefreshTaskDataAction} pulse refresh, so both hydrate * identical state: the full task with branch fields and pending * review, plus view rows for comments (with quotes), progress, * blocks (typed, with options) and sessions. */ public class TaskDetailState : Object { /** * Loads task `task_id` and shapes its PAGE slot (full view rows * plus every authored action reference). The state's `exists` flag * is false when the task is missing. */ public static async Slot build(StateService state_service, TaskService tasks, ProjectService projects, SaddleService saddles, RuleService rules, CapabilityService capabilities, ActionRegistry actions, SaddlePollState polls, PushService push, InvercargillSql.Orm.OrmSession db, int64 viewer_id, int64 task_id) throws GLib.Error { var detail = yield tasks.get_task(task_id); if (detail == null) { return slot_for(state_service, new TaskDetailPublic() { exists = false }, new PropertyDictionary()); } var task = ((!)detail).task; var project = yield projects.get_project(task.project_id); var pub = new TaskDetailPublic() { exists = true, id = task.id, title = task.title, description = task.description ?? "", status = task.status, priority = task.priority, summary = task.summary ?? "", task_branch = task.task_branch ?? "", branch_from = task.branch_from ?? "", project_id = task.project_id, project_name = project != null ? ((!)project).name : "", git_url = project != null ? ((!)project).git_url : "", goal_id = task.goal_id, mode = task.mode, creator = task.creator, is_goal_run = task.kind == TASK_KIND_GOAL_RUN, enqueue_after_task_id = task.enqueue_after_task_id, created_display = format_stamp(task.created_at), started_display = format_stamp(task.started_at), completed_display = format_stamp(task.completed_at) }; var display = TaskService.display_of_detail(((!)detail)); pub.display = display.status; pub.terminal = display.terminal; pub.failed = display.failed; pub.draft = task.status == STATUS_DRAFT; // The edit matrix (plan 11): draft edits everything but // status; non-draft non-terminal edits priority; terminal // edits nothing; capabilities stay editable until a worker // has the job (draft or queued). pub.editable_all = pub.draft; pub.editable_priority = !pub.terminal; pub.editable_capabilities = pub.draft || task.status == STATUS_QUEUED; // The heredoc completion report (`cat << 'EOF' | saddle // done` / the failure path's stored summary): terminal // tasks render tasks.summary as markdown in the // completion-report card below the status/description // grid; an empty summary renders no card at all. if (pub.terminal && pub.summary.length > 0) { pub.report_title = pub.failed ? "Failure summary" : "Completion report"; } var sealed = new TaskActionPrivate() { task_id = task.id }; pub.update = actions.author_private(sealed); pub.comment = actions.author_private(sealed); // Plan 12: enqueue/delete are creator-only — the human's // actions exist just for human-created worker tasks; goal // tasks (and the goal agent's own run) are sequenced by // their goal agent. if (task.creator == CREATOR_HUMAN && task.kind == TASK_KIND_TASK) { pub.enqueue = actions.author_private(sealed); pub.delete = actions.author_private(sealed); } pub.cancel = actions.author_private(sealed); pub.retry = actions.author_private(sealed); pub.resume = actions.author_private(sealed); // Goal 4 task 1: Force wake — only a PARKED task offers // it; a suspended goal run's wake IS the goal's wake // (TaskForceWakeAction routes it — one path). if (task.status == STATUS_SUSPENDED) { pub.force_wake = actions.author_private(sealed); } pub.refresh = actions.author_private(sealed, "GET"); pub.attach_rule = actions.author_private(sealed); pub.attach_capability = actions.author_private(sealed); // Web Push notify-me state (plan 11 task E): the signed-in // user's per-task opt-in for start/failure/completion. if (viewer_id > 0) { pub.notify_me = yield push.has_notify_pref(db, viewer_id, task.id); } pub.toggle_notify = actions.author_private(sealed); var comments = new Vector(); foreach (var comment in ((!)detail).comments) { // Plan 16 S5 (U2): mechanical rows (restores, park // notices) never reach the user-facing thread — they // live in the raw terminal log only. if (comment.author == COMMENT_AUTHOR_SYSTEM && comment_is_mechanical(comment.body)) { continue; } comments.add(new CommentView() { author = comment.author, body = comment.body, quote = comment.quote ?? "", when_display = format_stamp(comment.created_at), from_agent = comment.author == "agent", is_system = comment.author == COMMENT_AUTHOR_SYSTEM }); } var progress = new Vector(); foreach (var row in ((!)detail).progress) { progress.add(new ProgressView() { id = row.id, summary = row.summary, when_display = format_stamp(row.created_at) }); } // The festive yeehaw card (saddle yeehaw): newest entries // first, capped at YEEHAW_CARD_MAX with a "+N more" hint; // pub.yeehaw is the status-card-corner badge's presence. var yeehaw_feed = yield tasks.yeehaw_feed(task.id); pub.yeehaw = yeehaw_feed.total > 0; pub.yeehaw_total = yeehaw_feed.total; pub.yeehaw_more = (yeehaw_feed.total - yeehaw_feed.latest.length).clamp(0, int64.MAX); var yeehaws = new Vector(); foreach (var row in yeehaw_feed.latest) { yeehaws.add(new YeehawView() { message = row.message, when_display = format_stamp(row.at) }); } // Newest-first (the ordering sweep): list_blocks itself // returns newest-first now, so the rows flow straight // through — the blocking sections lead with the most // recent question, RFC or review. var blocks_newest = new Vector(); foreach (var block in ((!)detail).blocks) { var block_private = new BlockAnswerPrivate() { task_id = task.id, block_id = block.id }; var view = new BlockView() { id = block.id, question = block.question, answer = block.answer ?? "", block_type = block.block_type, body = block.body ?? "", options = EntryJson.parse_string_array(block.options), answered = block.answer != null, answered_by = block.answered_by ?? "", created_display = format_stamp(block.created_at), answered_display = format_stamp(block.answered_at), answer_action = actions.author_private(block_private) }; if (!view.answered && view.block_type == BLOCK_REVIEW) { pub.review_pending = true; pub.review_summary = view.body.length > 0 ? view.body : view.question; pub.review_diff_url = yield tasks.diff_url_for_task(task.id); } // The pending review banner renders the markdown itself // (the review summary), so the block's own body preview — // the same text — stays hidden until the block is answered. view.show_body = view.block_type != BLOCK_REVIEW || view.answered; blocks_newest.add(view); } var blocks = new Vector(); var open_blocks = new Vector(); var answered_blocks = new Vector(); foreach (var view in blocks_newest) { blocks.add(view); // Plan 11 task D: the current (unanswered) blocks render // in full; the historical ones fold behind an expander — // both groups keep the newest-first order. if (view.answered) { answered_blocks.add(view); } else { open_blocks.add(view); } } // Plan 12: the auto-enqueue-after dropdown lists every // OTHER standalone task (any status eligible as // predecessor); goal tasks are excluded — the goal agent // sequences its own. The dropdown's own query loads // exactly those rows, not every task on every pulse. var enqueue_options = new Vector(); string enqueue_after_title = ""; foreach (var row in yield tasks.list_standalone_tasks()) { if (row.id == task.id) { continue; } enqueue_options.add(new TaskOptionView() { id = row.id, title = row.title, status = row.status }); if (row.id == task.enqueue_after_task_id) { enqueue_after_title = row.title; } } pub.enqueue_after_title = enqueue_after_title; // The goal reference section (goal work only). if (task.goal_id > 0) { var goal_row = yield db.query() .where(expr("id == $0", elem(task.goal_id))) .first_async(); if (goal_row != null) { pub.goal_title = ((!)goal_row).title; pub.goal_status = ((!)goal_row).status; pub.goal_branch = ((!)goal_row).branch_name; } } var task_rules = new Vector(); foreach (var link in yield rules.list_task_attachments(task.id)) { var rule = yield rules.get_rule(link.rule_id); if (rule == null) { continue; } var rule_private = new TaskRuleActionPrivate() { task_id = task.id, link_id = link.id }; task_rules.add(new TaskRuleRowView() { link_id = link.id, rule_id = link.rule_id, name = ((!)rule).name, body = ((!)rule).body, detach = actions.author_private(rule_private) }); } var rule_options = new Vector(); foreach (var rule in yield rules.list_rules()) { rule_options.add(new RuleOptionView() { id = rule.id, name = rule.name }); } var task_capabilities = new Vector(); foreach (var link in yield capabilities.list_task_attachments(task.id)) { var capability = yield capabilities.get_capability(link.capability_id); if (capability == null) { continue; } var capability_private = new TaskCapabilityActionPrivate() { task_id = task.id, link_id = link.id }; task_capabilities.add(new TaskCapabilityRowView() { link_id = link.id, name = ((!)capability).name, detach = actions.author_private(capability_private) }); } var capability_options = new Vector(); foreach (var capability in yield capabilities.list_capabilities()) { capability_options.add(new CapabilityOptionView() { id = capability.id, name = capability.name }); } var sessions = new Vector(); foreach (var session in ((!)detail).sessions) { var saddle = yield saddles.get_saddle(session.saddle_id); sessions.add(new SessionView() { id = session.id, saddle_name = saddle != null ? ((!)saddle).name : @"saddle #$(session.saddle_id)", started_display = format_stamp(session.started_at), ended_display = format_stamp(session.ended_at), outcome = session.outcome ?? "", open = session.ended_at == null }); if (session.ended_at == null && saddle != null) { string daemon_state = ""; int64 uptime = 0; int64 activity = 0; int64 contact_ms = 0; polls.health_for(session.saddle_id, out daemon_state, out uptime, out activity, out contact_ms); if (daemon_state.length > 0) { pub.saddle_health = @"saddle '$(((!)saddle).name)' — daemon $(daemon_state)"; } } // A task stuck working/blocked whose open session sits on // an offline saddle offers the manual requeue-for-resume // path (the automatic three-strike abandon only fires // while the dispatcher still polls the saddle). if (session.ended_at == null && saddle != null && ((!)saddle).status == SADDLE_OFFLINE && (task.status == STATUS_WORKING || task.status == STATUS_BLOCKED || task.status == STATUS_REVIEW_REQUESTED)) { pub.resume_available = true; } } var props = (PropertyDictionary) GObjectMapping.to_properties(pub); props["comments"] = StateJson.list(comments.to_immutable_buffer()); props["progress"] = StateJson.list(progress.to_immutable_buffer()); props["yeehaws"] = StateJson.list(yeehaws.to_immutable_buffer()); props["blocks"] = block_views(blocks); props["open_blocks"] = block_views(open_blocks); props["answered_blocks"] = block_views(answered_blocks); props["enqueue_after_options"] = StateJson.list( enqueue_options.to_immutable_buffer()); props["rules"] = StateJson.list(task_rules.to_immutable_buffer()); props["rule_options"] = StateJson.list(rule_options.to_immutable_buffer()); props["capabilities"] = StateJson.list(task_capabilities.to_immutable_buffer()); props["capability_options"] = StateJson.list(capability_options.to_immutable_buffer()); props["sessions"] = StateJson.list(sessions.to_immutable_buffer()); return slot_for(state_service, pub, props); } private static InvercargillJson.JsonElement block_views(Vector views) throws GLib.Error { return StateJson.list_rich(views.to_immutable_buffer(), (item, dict) => { dict["options"] = StateJson.strings(((BlockView) item).options ?? new string[0]); }); } private static Slot slot_for(StateService state_service, TaskDetailPublic pub, PropertyDictionary props) { return state_service.new_slot(Scope.PAGE, new State() { type_name = "task", public_data = props, private_data = new PropertyDictionary() }); } } /** * Entrypoint for /tasks/{task_id}: the task detail page — plan, * progress, blocks (pending ones carry the answer form), the review * banner, comments thread, goal context, dependencies, session * history links and the lifecycle actions. The page subscribes to the * global pulse and re-runs its sealed refresh action whenever task * state changes, so live updates arrive without polling. */ public class TaskDetailEntrypoint : StatumEntrypoint { private TaskService task_service = Inversion.inject(); private ProjectService project_service = Inversion.inject(); private SaddleService saddle_service = Inversion.inject(); private RuleService rule_service = Inversion.inject(); private CapabilityService capability_service = Inversion.inject(); private SaddlePollState poll_state = Inversion.inject(); private PushService push_service = Inversion.inject(); private InvercargillSql.Orm.OrmSession db = Inversion.inject(); private UserService users = Inversion.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 = int64.parse(request.route("task_id") ?? "0"); 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); // Plan 14 B1: the goal page fully replaces the goal-run // task's page — deep links (dashboard, notifications, // timeline) redirect to the goal. The task state still // hydrates first (the sealed refresh and API readers keep // their slot); worker tasks render normally. var detail = yield task_service.get_task(task_id); if (detail != null && ((!)detail).task.kind == TASK_KIND_GOAL_RUN && ((!)detail).task.goal_id > 0) { return directives().set(state) .navigate(@"/goals/$(((!)detail).task.goal_id)") .subscribe_global(StatePulse.SLOT_TYPE); } return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE); } } }