| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574 |
- 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<UpdateTaskAction, TaskActionPrivate>(sealed);
- pub.comment = actions.author_private<AddCommentAction, TaskActionPrivate>(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<EnqueueTaskAction, TaskActionPrivate>(sealed);
- pub.delete = actions.author_private<DeleteTaskAction, TaskActionPrivate>(sealed);
- }
- pub.cancel = actions.author_private<CancelTaskAction, TaskActionPrivate>(sealed);
- pub.retry = actions.author_private<RetryTaskAction, TaskActionPrivate>(sealed);
- pub.resume = actions.author_private<ResumeTaskAction, TaskActionPrivate>(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<TaskForceWakeAction, TaskActionPrivate>(sealed);
- }
- pub.refresh = actions.author_private<RefreshTaskDataAction, TaskActionPrivate>(sealed, "GET");
- pub.attach_rule = actions.author_private<AttachTaskRuleAction, TaskActionPrivate>(sealed);
- pub.attach_capability = actions.author_private<AttachTaskCapabilityAction, TaskActionPrivate>(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<ToggleTaskNotifyAction, TaskActionPrivate>(sealed);
- var comments = new Vector<CommentView>();
- 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<ProgressView>();
- 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<YeehawView>();
- 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<BlockView>();
- 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<AnswerBlockAction, BlockAnswerPrivate>(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<BlockView>();
- var open_blocks = new Vector<BlockView>();
- var answered_blocks = new Vector<BlockView>();
- 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<TaskOptionView>();
- 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<Goal>()
- .where(expr("id == $0", elem<int64?>(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<TaskRuleRowView>();
- 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<DetachTaskRuleAction, TaskRuleActionPrivate>(rule_private)
- });
- }
- var rule_options = new Vector<RuleOptionView>();
- foreach (var rule in yield rules.list_rules()) {
- rule_options.add(new RuleOptionView() {
- id = rule.id,
- name = rule.name
- });
- }
- var task_capabilities = new Vector<TaskCapabilityRowView>();
- 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<DetachTaskCapabilityAction, TaskCapabilityActionPrivate>(capability_private)
- });
- }
- var capability_options = new Vector<CapabilityOptionView>();
- foreach (var capability in yield capabilities.list_capabilities()) {
- capability_options.add(new CapabilityOptionView() {
- id = capability.id,
- name = capability.name
- });
- }
- var sessions = new Vector<SessionView>();
- 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<CommentView>(comments.to_immutable_buffer());
- props["progress"] = StateJson.list<ProgressView>(progress.to_immutable_buffer());
- props["yeehaws"] = StateJson.list<YeehawView>(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<TaskOptionView>(
- enqueue_options.to_immutable_buffer());
- props["rules"] = StateJson.list<TaskRuleRowView>(task_rules.to_immutable_buffer());
- props["rule_options"] = StateJson.list<RuleOptionView>(rule_options.to_immutable_buffer());
- props["capabilities"] = StateJson.list<TaskCapabilityRowView>(task_capabilities.to_immutable_buffer());
- props["capability_options"] = StateJson.list<CapabilityOptionView>(capability_options.to_immutable_buffer());
- props["sessions"] = StateJson.list<SessionView>(sessions.to_immutable_buffer());
- return slot_for(state_service, pub, props);
- }
- private static InvercargillJson.JsonElement block_views(Vector<BlockView> views)
- throws GLib.Error {
- return StateJson.list_rich<BlockView>(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<TaskService>();
- private ProjectService project_service = Inversion.inject<ProjectService>();
- private SaddleService saddle_service = Inversion.inject<SaddleService>();
- private RuleService rule_service = Inversion.inject<RuleService>();
- private CapabilityService capability_service = Inversion.inject<CapabilityService>();
- private SaddlePollState poll_state = Inversion.inject<SaddlePollState>();
- private PushService push_service = Inversion.inject<PushService>();
- private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
- private UserService users = Inversion.inject<UserService>();
- 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);
- }
- }
- }
|