Sfoglia il codice sorgente

r2: GoalViewContext + GoalView — the 17-parameter GoalState.populate dies (arch 12): a GoalViewContext carries the dependency bundle (goals/tasks/projects/db/actions/saddles/polls/rules) every build threads, a returned GoalView aggregate holds pub + the ten section lists (out-params die), and the full flag becomes the two methods — full_view builds the goal page's whole aggregate, summary_view the goals-list row's scalars alone (the old call threaded 13 throwaway vectors plus their per-task display/yeehaw/capability queries through the full builder only to serialise pub; the row now computes exactly the fields it emits — same pub field set, byte-identical row JSON). GoalState.build takes the context (10-dep thread dies); GoalDetailEntrypoint, GoalsEntrypoint and RefreshGoalDataAction construct it once. The misindented runs/activity block normalised; RemoveGoalLinkAction's DTO authors null-safely when the registry is absent.

clanker 1 mese fa
parent
commit
1fcc84c971
2 ha cambiato i file con 289 aggiunte e 166 eliminazioni
  1. 3 3
      src/actions/GoalActions.vala
  2. 286 163
      src/entrypoints/GoalDetailEntrypoint.vala

+ 3 - 3
src/actions/GoalActions.vala

@@ -412,9 +412,9 @@ namespace ClankerSaddle {
                 return directives();
             }
 
-            var state = yield GoalState.build(goal_id, goal_service, task_service,
-                project_service, db, action_registry, state_service,
-                saddle_service, poll_state, rule_service);
+            var ctx = new GoalViewContext(goal_service, task_service, project_service,
+                db, action_registry, saddle_service, poll_state, rule_service);
+            var state = yield GoalState.build(goal_id, ctx, state_service);
             return directives().set(state);
         }
     }

+ 286 - 163
src/entrypoints/GoalDetailEntrypoint.vala

@@ -206,9 +206,62 @@ namespace ClankerSaddle {
     }
 
     /**
-     * State builder shared by the goals list row and the goal page:
-     * fills a {@link GoalPublic} plus the section lists from a goal
-     * row. `full` builds the narrative/runs/sections the list skips.
+     * The dependency bundle every goal-view build threads (plan 16
+     * R2, arch item 12): the services and registries {@link GoalState}'s
+     * builders need, constructed once per entrypoint or refresh —
+     * one object where the 17-parameter populate signature (and
+     * build's own 10-dependency thread) used to be.
+     */
+    public class GoalViewContext : Object {
+        public GoalService goals { get; set; }
+        public TaskService tasks { get; set; }
+        public ProjectService projects { get; set; }
+        public InvercargillSql.Orm.OrmSession db { get; set; }
+        public ActionRegistry? actions { get; set; default = null; }
+        public SaddleService? saddles { get; set; default = null; }
+        public SaddlePollState? polls { get; set; default = null; }
+        public RuleService? rules { get; set; default = null; }
+
+        public GoalViewContext(GoalService goals, TaskService tasks,
+                ProjectService projects, InvercargillSql.Orm.OrmSession db,
+                ActionRegistry? actions = null, SaddleService? saddles = null,
+                SaddlePollState? polls = null, RuleService? rules = null) {
+            this.goals = goals;
+            this.tasks = tasks;
+            this.projects = projects;
+            this.db = db;
+            this.actions = actions;
+            this.saddles = saddles;
+            this.polls = polls;
+            this.rules = rules;
+        }
+    }
+
+    /**
+     * A built goal view (plan 16 R2, arch item 12): the public scalar
+     * half plus the section lists the goal page serialises —
+     * populate's out-params became fields.
+     */
+    public class GoalView : Object {
+        public GoalPublic pub { get; set; default = new GoalPublic(); }
+        public Vector<GoalLinkView> links { get; set; default = new Vector<GoalLinkView>(); }
+        public Vector<GoalBlockView> blocks { get; set; default = new Vector<GoalBlockView>(); }
+        public Vector<GoalBlockView> blocks_open { get; set; default = new Vector<GoalBlockView>(); }
+        public Vector<GoalBlockView> blocks_answered { get; set; default = new Vector<GoalBlockView>(); }
+        public Vector<GoalTaskView> goal_tasks { get; set; default = new Vector<GoalTaskView>(); }
+        public Vector<GoalEventView> events { get; set; default = new Vector<GoalEventView>(); }
+        public Vector<GoalCommentView> comments { get; set; default = new Vector<GoalCommentView>(); }
+        public Vector<GoalRunView> runs { get; set; default = new Vector<GoalRunView>(); }
+        public Vector<GoalPushBranchView> push_branches { get; set; default = new Vector<GoalPushBranchView>(); }
+        public Vector<GoalProgressView> progress { get; set; default = new Vector<GoalProgressView>(); }
+    }
+
+    /**
+     * State builder for the goal surfaces: fills a {@link GoalView}
+     * from a goal row — {@link GoalState.full_view} builds the goal
+     * page's whole aggregate (narrative, runs, sections), {@link
+     * GoalState.summary_view} the goals-list row's scalars (the old
+     * `full` flag became the two methods).
      */
     public class GoalState : Object {
 
@@ -217,36 +270,19 @@ namespace ClankerSaddle {
          * shape the entrypoint sets and the sealed refresh action
          * re-hydrates. `exists` is false when the goal is missing.
          */
-        public static async Statum.Slot build(int64 goal_id, GoalService goals,
-                TaskService tasks, ProjectService projects,
-                InvercargillSql.Orm.OrmSession db, ActionRegistry? actions,
-                StateService state_service,
-                SaddleService? saddles = null, SaddlePollState? polls = null,
-                RuleService? rules = null) throws GLib.Error {
-            var pub = new GoalPublic();
-            var links = new Vector<GoalLinkView>();
-            var blocks = new Vector<GoalBlockView>();
-            var blocks_open = new Vector<GoalBlockView>();
-            var blocks_answered = new Vector<GoalBlockView>();
-            var goal_tasks = new Vector<GoalTaskView>();
-            var events = new Vector<GoalEventView>();
-            var comments = new Vector<GoalCommentView>();
-            var runs = new Vector<GoalRunView>();
-            var push_branches = new Vector<GoalPushBranchView>();
-            var progress = new Vector<GoalProgressView>();
-            var effective_rules = new Vector<GoalRuleView>();
-
+        public static async Statum.Slot build(int64 goal_id, GoalViewContext ctx,
+                StateService state_service) throws GLib.Error {
+            var view = new GoalView();
             GoalBlockView? plan_pending = null;
             GoalBlockView? completion_pending = null;
             var plan_revisions = new Vector<GoalBlockView>();
             var project_options = new Vector<GoalProjectOptionView>();
+            var effective_rules = new Vector<GoalRuleView>();
 
-            var goal = yield goals.get_goal(goal_id);
+            var goal = yield ctx.goals.get_goal(goal_id);
             if (goal != null) {
-                yield populate(((!)goal), goals, tasks, db, actions, true, pub,
-                    links, blocks, goal_tasks, events, comments, runs, push_branches,
-                    progress, saddles, polls, blocks_open, blocks_answered);
-                foreach (var block in blocks) {
+                view = yield full_view((!)goal, ctx);
+                foreach (var block in view.blocks) {
                     if (block.is_plan_review && plan_pending == null) {
                         plan_pending = block;
                     }
@@ -257,7 +293,7 @@ namespace ClankerSaddle {
                         plan_revisions.add(block);
                     }
                 }
-                foreach (var project in yield projects.list_projects()) {
+                foreach (var project in yield ctx.projects.list_projects()) {
                     if (project.id != ((!)goal).project_id) {
                         project_options.add(new GoalProjectOptionView() {
                             id = project.id,
@@ -269,13 +305,13 @@ namespace ClankerSaddle {
                 // dispatch ships for the run (and every worker):
                 // run-task rules, the primary project's, then the
                 // co-developed goal links' in link order.
-                if (rules != null) {
+                if (ctx.rules != null) {
                     var scope_task = new TaskEntity() {
-                        id = pub.run_task_id,
+                        id = view.pub.run_task_id,
                         project_id = ((!)goal).project_id
                     };
-                    foreach (var rule in yield ((!)rules).effective_rules_for(
-                            scope_task, projects, ((!)goal).id)) {
+                    foreach (var rule in yield ((!)ctx.rules).effective_rules_for(
+                            scope_task, ctx.projects, ((!)goal).id)) {
                         effective_rules.add(new GoalRuleView() {
                             id = rule.id,
                             name = rule.name,
@@ -285,44 +321,44 @@ namespace ClankerSaddle {
                 }
             }
 
-            if (actions != null && goal != null) {
-                pub.refresh = ((!)actions)
+            if (ctx.actions != null && goal != null) {
+                view.pub.refresh = ((!)ctx.actions)
                     .author_private<RefreshGoalDataAction, GoalActionPrivate>(
                         new GoalActionPrivate() { goal_id = goal_id }, "GET");
             }
 
-            var pub_root = (PropertyDictionary) GObjectMapping.to_properties(pub);
-            pub_root["links"] = StateJson.list<GoalLinkView>(links.to_immutable_buffer());
-            pub_root["blocks"] = StateJson.list_rich<GoalBlockView>(blocks.to_immutable_buffer(),
+            var pub_root = (PropertyDictionary) GObjectMapping.to_properties(view.pub);
+            pub_root["links"] = StateJson.list<GoalLinkView>(view.links.to_immutable_buffer());
+            pub_root["blocks"] = StateJson.list_rich<GoalBlockView>(view.blocks.to_immutable_buffer(),
                 (item, dict) => {
                     dict["options"] = StateJson.strings(
                         ((GoalBlockView) item).options ?? new string[0]);
                 });
             pub_root["open_blocks"] = StateJson.list_rich<GoalBlockView>(
-                blocks_open.to_immutable_buffer(),
+                view.blocks_open.to_immutable_buffer(),
                 (item, dict) => {
                     dict["options"] = StateJson.strings(
                         ((GoalBlockView) item).options ?? new string[0]);
                 });
             pub_root["answered_blocks"] = StateJson.list_rich<GoalBlockView>(
-                blocks_answered.to_immutable_buffer(),
+                view.blocks_answered.to_immutable_buffer(),
                 (item, dict) => {
                     dict["options"] = StateJson.strings(
                         ((GoalBlockView) item).options ?? new string[0]);
                 });
             pub_root["goal_tasks"] = StateJson.list_rich<GoalTaskView>(
-                goal_tasks.to_immutable_buffer(),
+                view.goal_tasks.to_immutable_buffer(),
                 (item, dict) => {
                     dict["capabilities"] = StateJson.strings(
                         ((GoalTaskView) item).capabilities ?? new string[0]);
                 });
-            pub_root["events"] = StateJson.list<GoalEventView>(events.to_immutable_buffer());
-            pub_root["comments"] = StateJson.list<GoalCommentView>(comments.to_immutable_buffer());
-            pub_root["runs"] = StateJson.list<GoalRunView>(runs.to_immutable_buffer());
+            pub_root["events"] = StateJson.list<GoalEventView>(view.events.to_immutable_buffer());
+            pub_root["comments"] = StateJson.list<GoalCommentView>(view.comments.to_immutable_buffer());
+            pub_root["runs"] = StateJson.list<GoalRunView>(view.runs.to_immutable_buffer());
             pub_root["progress"] = StateJson.list<GoalProgressView>(
-                progress.to_immutable_buffer());
+                view.progress.to_immutable_buffer());
             pub_root["push_branches"] = StateJson.list<GoalPushBranchView>(
-                push_branches.to_immutable_buffer());
+                view.push_branches.to_immutable_buffer());
             pub_root["plan_revisions"] = StateJson.list<GoalBlockView>(
                 plan_revisions.to_immutable_buffer());
             pub_root["rules"] = StateJson.list<GoalRuleView>(
@@ -343,18 +379,118 @@ namespace ClankerSaddle {
                 private_data = new PropertyDictionary()
             });
         }
-        public static async void populate(Goal goal, GoalService goals, TaskService tasks,
-                InvercargillSql.Orm.OrmSession db, ActionRegistry? actions,
-                bool full, GoalPublic pub,
-                Vector<GoalLinkView> links, Vector<GoalBlockView> blocks,
-                Vector<GoalTaskView> goal_tasks, Vector<GoalEventView> events,
-                Vector<GoalCommentView> comments, Vector<GoalRunView> runs,
-                Vector<GoalPushBranchView> push_branches,
-                Vector<GoalProgressView>? progress = null,
-                SaddleService? saddles = null,
-                SaddlePollState? polls = null,
-                Vector<GoalBlockView>? blocks_open = null,
-                Vector<GoalBlockView>? blocks_answered = null) throws GLib.Error {
+        /**
+         * The goals-list row's view (the old `full=false` half): the
+         * {@link GoalPublic} scalars alone — display fields, the
+         * action DTOs, the completion-pending flag, the run-task id
+         * and the task counts. The section lists stay empty: the row
+         * reads none of them, and the old call threaded thirteen
+         * throwaway vectors (and their per-task display/capability
+         * queries) through the full builder to fill views nobody
+         * serialised.
+         */
+        public static async GoalView summary_view(Goal goal,
+                GoalViewContext ctx) throws GLib.Error {
+            var view = new GoalView();
+            var pub = view.pub;
+            pub.exists = true;
+            pub.id = goal.id;
+            pub.project_id = goal.project_id;
+            pub.title = goal.title;
+            pub.brief = goal.brief;
+            pub.branch_name = goal.branch_name;
+            pub.base_branch = goal.base_branch ?? "";
+            pub.status = goal.status;
+            // Plan 16 S5 (U3): a goal awaiting its completion review
+            // reads "awaiting your review" — the run-task status card
+            // agrees (no bare "blocked" for review parks).
+            pub.display = goal.status == GOAL_COMPLETION_REVIEW
+                ? "awaiting your review" : goal.status.replace("_", " ");
+            pub.priority = goal.priority;
+            pub.plan = goal.plan ?? "";
+            pub.completion_report = goal.completion_report ?? "";
+            pub.created_display = format_stamp(goal.created_at);
+            pub.started_display = format_stamp(goal.started_at);
+            pub.completed_display = format_stamp(goal.completed_at);
+            pub.terminal = goal.status == GOAL_COMPLETE || goal.status == GOAL_FAILED
+                || goal.status == GOAL_CANCELLED;
+
+            var project = yield ctx.db.query<Project>()
+                .where(expr("id == $0", elem<int64?>(goal.project_id)))
+                .first_async();
+            if (project != null) {
+                pub.project_name = ((!)project).name;
+                string base_name = goal.base_branch != null && ((!)goal.base_branch).length > 0
+                    ? ((!)goal.base_branch) : ((!)project).default_branch;
+                pub.compare_url = @"$(TaskService.https_git_url(((!)project).git_url))/compare/$(base_name)...$(goal.branch_name)";
+            }
+
+            if (ctx.actions != null) {
+                var sealed = new GoalActionPrivate() { goal_id = goal.id };
+                pub.comment = ((!)ctx.actions).author_private<GoalCommentAction, GoalActionPrivate>(sealed);
+                pub.add_link = ((!)ctx.actions).author_private<AddGoalLinkAction, GoalActionPrivate>(sealed);
+                if (goal.status == GOAL_DRAFT) {
+                    pub.start = ((!)ctx.actions).author_private<StartGoalAction, GoalActionPrivate>(sealed);
+                }
+                if (goal.status == GOAL_ACTIVE) {
+                    pub.pause = ((!)ctx.actions).author_private<PauseGoalAction, GoalActionPrivate>(sealed);
+                }
+                if (goal.status == GOAL_PAUSED) {
+                    pub.resume = ((!)ctx.actions).author_private<ResumeGoalAction, GoalActionPrivate>(sealed);
+                }
+                if (!pub.terminal) {
+                    pub.cancel = ((!)ctx.actions).author_private<CancelGoalAction, GoalActionPrivate>(sealed);
+                }
+            }
+
+            foreach (var block in yield ctx.goals.list_blocks(goal.id)) {
+                if (block.answer == null
+                        && (block.block_type == BLOCK_COMPLETION
+                            || block.block_type == BLOCK_REVIEW)) {
+                    pub.completion_pending = true;
+                    break;
+                }
+            }
+
+            foreach (var task in yield ctx.tasks.list_tasks_for_goal(goal.id)) {
+                if (task.kind == TASK_KIND_GOAL_RUN) {
+                    pub.run_task_id = task.id;
+                    continue;
+                }
+                switch (task.status) {
+                    case STATUS_DRAFT:
+                        pub.draft_tasks++; break;
+                    case STATUS_QUEUED:
+                    case STATUS_ASSIGNED:
+                        pub.queued_tasks++; break;
+                    case STATUS_WORKING:
+                    case STATUS_BLOCKED:
+                    case STATUS_REVIEW_REQUESTED:
+                    case STATUS_SUSPENDED:
+                        pub.running_tasks++; break;
+                    case STATUS_DONE:
+                        pub.done_tasks++; break;
+                    case STATUS_FAILED:
+                        pub.failed_tasks++; break;
+                    default:
+                        break;
+                }
+            }
+            return view;
+        }
+
+        /**
+         * The goal page's whole view (the old `full=true` half): the
+         * public scalars plus every section list — links, blocks with
+         * the open/answered split, task rows with their computed
+         * displays, the run-control actions, the narrative, the
+         * comment thread, the runs, the live activity and the push
+         * chips.
+         */
+        public static async GoalView full_view(Goal goal,
+                GoalViewContext ctx) throws GLib.Error {
+            var view = new GoalView();
+            var pub = view.pub;
             pub.exists = true;
             pub.id = goal.id;
             pub.project_id = goal.project_id;
@@ -377,7 +513,7 @@ namespace ClankerSaddle {
             pub.terminal = goal.status == GOAL_COMPLETE || goal.status == GOAL_FAILED
                 || goal.status == GOAL_CANCELLED;
 
-            var project = yield db.query<Project>()
+            var project = yield ctx.db.query<Project>()
                 .where(expr("id == $0", elem<int64?>(goal.project_id)))
                 .first_async();
             if (project != null) {
@@ -387,46 +523,48 @@ namespace ClankerSaddle {
                 pub.compare_url = @"$(TaskService.https_git_url(((!)project).git_url))/compare/$(base_name)...$(goal.branch_name)";
             }
 
-            if (actions != null) {
+            if (ctx.actions != null) {
                 var sealed = new GoalActionPrivate() { goal_id = goal.id };
-                pub.comment = ((!)actions).author_private<GoalCommentAction, GoalActionPrivate>(sealed);
-                pub.add_link = ((!)actions).author_private<AddGoalLinkAction, GoalActionPrivate>(sealed);
+                pub.comment = ((!)ctx.actions).author_private<GoalCommentAction, GoalActionPrivate>(sealed);
+                pub.add_link = ((!)ctx.actions).author_private<AddGoalLinkAction, GoalActionPrivate>(sealed);
                 if (goal.status == GOAL_DRAFT) {
-                    pub.start = ((!)actions).author_private<StartGoalAction, GoalActionPrivate>(sealed);
+                    pub.start = ((!)ctx.actions).author_private<StartGoalAction, GoalActionPrivate>(sealed);
                 }
                 if (goal.status == GOAL_ACTIVE) {
-                    pub.pause = ((!)actions).author_private<PauseGoalAction, GoalActionPrivate>(sealed);
+                    pub.pause = ((!)ctx.actions).author_private<PauseGoalAction, GoalActionPrivate>(sealed);
                 }
                 if (goal.status == GOAL_PAUSED) {
-                    pub.resume = ((!)actions).author_private<ResumeGoalAction, GoalActionPrivate>(sealed);
+                    pub.resume = ((!)ctx.actions).author_private<ResumeGoalAction, GoalActionPrivate>(sealed);
                 }
                 if (!pub.terminal) {
-                    pub.cancel = ((!)actions).author_private<CancelGoalAction, GoalActionPrivate>(sealed);
+                    pub.cancel = ((!)ctx.actions).author_private<CancelGoalAction, GoalActionPrivate>(sealed);
                 }
             }
 
-            foreach (var link in yield goals.list_links(goal.id)) {
-                var view = new GoalLinkView() {
+            foreach (var link in yield ctx.goals.list_links(goal.id)) {
+                var link_view = new GoalLinkView() {
                     id = link.id,
                     project_id = link.project_id,
                     relationship = link.relationship,
-                    remove = ((!)actions).author_private<RemoveGoalLinkAction, GoalActionPrivate>(
-                        new GoalActionPrivate() { goal_id = goal.id })
+                    remove = ctx.actions != null
+                        ? ((!)ctx.actions).author_private<RemoveGoalLinkAction, GoalActionPrivate>(
+                            new GoalActionPrivate() { goal_id = goal.id })
+                        : null
                 };
-                var linked = yield db.query<Project>()
+                var linked = yield ctx.db.query<Project>()
                     .where(expr("id == $0", elem<int64?>(link.project_id)))
                     .first_async();
                 if (linked != null) {
-                    view.name = ((!)linked).name;
+                    link_view.name = ((!)linked).name;
                 }
-                links.add(view);
+                view.links.add(link_view);
             }
 
             bool completion_pending = false;
             bool plan_pending_open = false;
             // Plan 15 C1: the goal's blocks ARE the goal-run task's
             // blocks — one row per question, answered in place.
-            foreach (var block in yield goals.list_blocks(goal.id)) {
+            foreach (var block in yield ctx.goals.list_blocks(goal.id)) {
                 bool pending_plan = block.block_type == BLOCK_PLAN
                     && block.answer == null;
                 // The completion review may ride either kind: the web's
@@ -435,7 +573,7 @@ namespace ClankerSaddle {
                 bool pending_completion = block.answer == null
                     && (block.block_type == BLOCK_COMPLETION
                         || block.block_type == BLOCK_REVIEW);
-                var view = new GoalBlockView() {
+                var block_view = new GoalBlockView() {
                     id = block.id,
                     question = block.question,
                     block_type = block.block_type,
@@ -455,8 +593,8 @@ namespace ClankerSaddle {
                     approved = block.answer != null
                         && block.answer == NOTICE_PLAN_APPROVED
                 };
-                if (block.answer == null && actions != null) {
-                    view.answer_action = ((!)actions)
+                if (block.answer == null && ctx.actions != null) {
+                    block_view.answer_action = ((!)ctx.actions)
                         .author_private<AnswerGoalBlockAction, GoalBlockAnswerPrivate>(
                             new GoalBlockAnswerPrivate() {
                                 goal_id = goal.id,
@@ -469,31 +607,29 @@ namespace ClankerSaddle {
                 if (pending_plan) {
                     plan_pending_open = true;
                 }
-                blocks.add(view);
+                view.blocks.add(block_view);
             }
             pub.completion_pending = completion_pending;
             // Plan 15 C4: the current (unanswered) question/RFC blocks
             // render in full; the historic answered ones fold behind
             // the answered-blocks expander (the task page's pattern).
-            var open_sink = blocks_open ?? new Vector<GoalBlockView>();
-            var answered_sink = blocks_answered ?? new Vector<GoalBlockView>();
-            var block_array = blocks.to_array();
+            var block_array = view.blocks.to_array();
             for (var index = block_array.length - 1; index >= 0; index--) {
-                var view = (GoalBlockView) block_array[index];
-                if (view.answered) {
-                    answered_sink.add(view);
-                } else if (view.is_plan_review) {
+                var block_view = (GoalBlockView) block_array[index];
+                if (block_view.answered) {
+                    view.blocks_answered.add(block_view);
+                } else if (block_view.is_plan_review) {
                     // rendered by the plan section
                 } else {
                     // Every pending completion-family row renders (the
                     // raise-side cross-dedupe keeps them to one —
                     // fresh databases cannot hold pre-dedupe twins, so
                     // no render-side guard is needed).
-                    open_sink.add(view);
+                    view.blocks_open.add(block_view);
                 }
             }
             var goal_task_rows = new Vector<TaskEntity>();
-            foreach (var task in yield tasks.list_tasks_for_goal(goal.id)) {
+            foreach (var task in yield ctx.tasks.list_tasks_for_goal(goal.id)) {
                 if (task.kind == TASK_KIND_GOAL_RUN) {
                     pub.run_task_id = task.id;
                 } else {
@@ -503,10 +639,10 @@ namespace ClankerSaddle {
             // The rows consume TaskService.display_map_for directly
             // (the same computed display the queue and the dashboard
             // read) plus the batch yeehaw presence set.
-            var display_map = yield tasks.display_map_for(goal_task_rows.to_immutable_buffer());
-            var yeehaw_ids = yield tasks.yeehaw_task_ids();
+            var display_map = yield ctx.tasks.display_map_for(goal_task_rows.to_immutable_buffer());
+            var yeehaw_ids = yield ctx.tasks.yeehaw_task_ids();
             foreach (var task in goal_task_rows) {
-                var view = new GoalTaskView() {
+                var task_view = new GoalTaskView() {
                     id = task.id,
                     title = task.title,
                     status = task.status,
@@ -518,22 +654,22 @@ namespace ClankerSaddle {
                 // read straight off the task_capabilities links (no
                 // editing here).
                 var task_caps = new Vector<string>();
-                foreach (var link in yield db.query<TaskCapability>()
+                foreach (var link in yield ctx.db.query<TaskCapability>()
                         .where(expr("task_id == $0", elem<int64?>(task.id)))
                         .order_by(expr("id"))
                         .materialise_async()) {
-                    var capability = yield db.query<Capability>()
+                    var capability = yield ctx.db.query<Capability>()
                         .where(expr("id == $0", elem<int64?>(link.capability_id)))
                         .first_async();
                     if (capability != null) {
                         task_caps.add(((!)capability).name);
                     }
                 }
-                view.capabilities = task_caps.to_array();
+                task_view.capabilities = task_caps.to_array();
                 var display = display_map.get_or_default(task.id.to_string());
-                view.display = display != null ? ((!)display).status : task.status;
-                view.yeehaw = yeehaw_ids.has(task.id.to_string());
-                goal_tasks.add(view);
+                task_view.display = display != null ? ((!)display).status : task.status;
+                task_view.yeehaw = yeehaw_ids.has(task.id.to_string());
+                view.goal_tasks.add(task_view);
                 switch (task.status) {
                     case STATUS_DRAFT:
                         pub.draft_tasks++; break;
@@ -554,22 +690,18 @@ namespace ClankerSaddle {
                 }
             }
 
-            if (!full) {
-                return;
-            }
-
             // Plan 14 B1: run-control parity on the status head —
             // "Retry supervision" for a failed goal, requeue-to-resume
             // for a run stale/parked on a dead saddle (NO notify-me
             // toggle: goals notify unconditionally by design).
-            if (actions != null && pub.run_task_id > 0) {
-                var run_row = yield db.query<TaskEntity>()
+            if (ctx.actions != null && pub.run_task_id > 0) {
+                var run_row = yield ctx.db.query<TaskEntity>()
                     .where(expr("id == $0", elem<int64?>(pub.run_task_id)))
                     .first_async();
                 if (run_row != null) {
                     string run_status = ((!)run_row).status;
                     if (goal.status == GOAL_FAILED && run_status == STATUS_FAILED) {
-                        pub.retry_run = ((!)actions)
+                        pub.retry_run = ((!)ctx.actions)
                             .author_private<RetryGoalRunAction, GoalActionPrivate>(
                                 new GoalActionPrivate() { goal_id = goal.id });
                     }
@@ -578,14 +710,14 @@ namespace ClankerSaddle {
                         || run_status == STATUS_REVIEW_REQUESTED
                         || run_status == STATUS_SUSPENDED;
                     if (!pub.terminal && stale_or_parked) {
-                        var open_run = yield tasks.open_session_for_task(pub.run_task_id);
+                        var open_run = yield ctx.tasks.open_session_for_task(pub.run_task_id);
                         if (open_run != null) {
-                            var host = yield db.query<Saddle>()
+                            var host = yield ctx.db.query<Saddle>()
                                 .where(expr("id == $0",
                                     elem<int64?>(((!)open_run).saddle_id)))
                                 .first_async();
                             if (host != null && ((!)host).status == SADDLE_OFFLINE) {
-                                pub.requeue_run = ((!)actions)
+                                pub.requeue_run = ((!)ctx.actions)
                                     .author_private<RequeueGoalRunAction, GoalActionPrivate>(
                                         new GoalActionPrivate() { goal_id = goal.id });
                             }
@@ -596,8 +728,8 @@ namespace ClankerSaddle {
 
             // Plan 15 C3: the narrative derives at read time (the
             // service returns newest-first rows).
-            foreach (var row in yield goals.narrative(goal.id)) {
-                events.add(new GoalEventView() {
+            foreach (var row in yield ctx.goals.narrative(goal.id)) {
+                view.events.add(new GoalEventView() {
                     kind = row.kind,
                     text = row.text,
                     task_id = row.task_id,
@@ -609,8 +741,8 @@ namespace ClankerSaddle {
             // task's task_comments (injection rides the existing
             // delivery paths) — the agent's pushes and the human's
             // composer land on the same rows.
-            foreach (var row in yield goals.list_comments(goal.id)) {
-                comments.add(new GoalCommentView() {
+            foreach (var row in yield ctx.goals.list_comments(goal.id)) {
+                view.comments.add(new GoalCommentView() {
                     author = row.author,
                     body = row.body,
                     quote = row.quote ?? "",
@@ -624,55 +756,53 @@ namespace ClankerSaddle {
             if (pub.run_task_id > 0) {
                 // The goal agent's live progress feed (green, task-page
                 // pattern) — the run task's own progress lines.
-                if (progress != null) {
-                    foreach (var row in yield tasks.list_progress(pub.run_task_id)) {
-                        ((!)progress).add(new GoalProgressView() {
-                            id = row.id,
-                            summary = row.summary,
-                            when_display = format_stamp(row.created_at)
-                        });
-                    }
+                foreach (var row in yield ctx.tasks.list_progress(pub.run_task_id)) {
+                    view.progress.add(new GoalProgressView() {
+                        id = row.id,
+                        summary = row.summary,
+                        when_display = format_stamp(row.created_at)
+                    });
                 }
-                var session_rows = yield db.query<ClankerSaddle.Data.Session>()
+                var session_rows = yield ctx.db.query<ClankerSaddle.Data.Session>()
                     .where(expr("task_id == $0", elem<int64?>(pub.run_task_id)))
                     .order_by_desc(expr("id"))
                     .materialise_async();
-                    foreach (var session in session_rows) {
-                        string run_saddle = "";
-                        if (saddles != null) {
-                            var saddle_row = yield ((!)saddles).get_saddle(session.saddle_id);
-                            run_saddle = saddle_row != null
-                                ? ((!)saddle_row).name : @"saddle #$(session.saddle_id)";
-                        }
-                        runs.add(new GoalRunView() {
-                            id = session.id,
-                            started_display = format_stamp(session.started_at),
-                            ended_display = format_stamp(session.ended_at),
-                            open = session.ended_at == null,
-                            outcome = session.outcome ?? "",
-                            saddle_name = run_saddle,
-                            // Deep-links this run's terminal view: the
-                            // picker's client-side ?session_id=
-                            // selection lands on exactly this session.
-                            terminal_url = @"/tasks/$(pub.run_task_id)/terminal?session_id=$(session.id)"
-                        });
+                foreach (var session in session_rows) {
+                    string run_saddle = "";
+                    if (ctx.saddles != null) {
+                        var saddle_row = yield ((!)ctx.saddles).get_saddle(session.saddle_id);
+                        run_saddle = saddle_row != null
+                            ? ((!)saddle_row).name : @"saddle #$(session.saddle_id)";
                     }
+                    view.runs.add(new GoalRunView() {
+                        id = session.id,
+                        started_display = format_stamp(session.started_at),
+                        ended_display = format_stamp(session.ended_at),
+                        open = session.ended_at == null,
+                        outcome = session.outcome ?? "",
+                        saddle_name = run_saddle,
+                        // Deep-links this run's terminal view: the
+                        // picker's client-side ?session_id=
+                        // selection lands on exactly this session.
+                        terminal_url = @"/tasks/$(pub.run_task_id)/terminal?session_id=$(session.id)"
+                    });
+                }
                 // Current activity (task-page parity): the open round's
                 // hosting saddle, its health and the newest recorded
                 // activity line.
-                var open = yield tasks.open_session_for_task(pub.run_task_id);
+                var open = yield ctx.tasks.open_session_for_task(pub.run_task_id);
                 if (open != null) {
                     open_session_saddle = ((!)open).saddle_id;
-                    if (saddles != null) {
-                        var saddle_row = yield ((!)saddles).get_saddle(((!)open).saddle_id);
+                    if (ctx.saddles != null) {
+                        var saddle_row = yield ((!)ctx.saddles).get_saddle(((!)open).saddle_id);
                         if (saddle_row != null) {
                             pub.agent_saddle = ((!)saddle_row).name;
-                            if (polls != null) {
+                            if (ctx.polls != null) {
                                 string daemon_state = "";
                                 int64 uptime = 0;
                                 int64 activity = 0;
                                 int64 contact_ms = 0;
-                                ((!)polls).health_for(((!)saddle_row).id, out daemon_state,
+                                ((!)ctx.polls).health_for(((!)saddle_row).id, out daemon_state,
                                     out uptime, out activity, out contact_ms);
                                 if (daemon_state.length > 0) {
                                     pub.saddle_health = @"saddle '$(((!)saddle_row).name)' — daemon $(daemon_state)";
@@ -681,7 +811,7 @@ namespace ClankerSaddle {
                         }
                     }
                     SessionCommand? newest = null;
-                    foreach (var command in yield db.query<SessionCommand>()
+                    foreach (var command in yield ctx.db.query<SessionCommand>()
                             .where(expr("session_id == $0", elem<int64?>(((!)open).id)))
                             .order_by(expr("id"))
                             .materialise_async()) {
@@ -712,7 +842,7 @@ namespace ClankerSaddle {
             // doing right now — running the open round, or parked
             // awaiting a review / a question / its tasks.
             var activity_run = pub.run_task_id > 0
-                ? yield db.query<TaskEntity>()
+                ? yield ctx.db.query<TaskEntity>()
                     .where(expr("id == $0", elem<int64?>(pub.run_task_id)))
                     .first_async() : null;
             if (activity_run != null) {
@@ -729,7 +859,7 @@ namespace ClankerSaddle {
                         pub.current_activity = "parked awaiting the completion review";
                     } else if (plan_pending_open) {
                         pub.current_activity = "parked awaiting the plan review";
-                    } else if (open_sink.length > 0) {
+                    } else if (view.blocks_open.length > 0) {
                         pub.current_activity = "parked awaiting a question";
                     } else {
                         pub.current_activity = "parked";
@@ -744,20 +874,21 @@ namespace ClankerSaddle {
             // daemon-reported per-repo flags of the run's hosting
             // saddle (the last /status probe) — never a stored
             // snapshot. A restart with no probe yet renders none.
-            if (open_session_saddle > 0 && polls != null) {
-                string flags = ((!)polls).goal_branches_for(open_session_saddle);
+            if (open_session_saddle > 0 && ctx.polls != null) {
+                string flags = ((!)ctx.polls).goal_branches_for(open_session_saddle);
                 if (flags.length > 0) {
                     // The SAME parser the completion-accept gate reads
                     // (GoalService.parse_goal_branches) feeds the chips —
                     // gate and display cannot diverge on one payload.
                     foreach (var row in GoalService.parse_goal_branches(flags)) {
-                        push_branches.add(new GoalPushBranchView() {
+                        view.push_branches.add(new GoalPushBranchView() {
                             branch = row.label(),
                             pushed = row.pushed
                         });
                     }
                 }
             }
+            return view;
         }
 
         /** A flattened, clipped activity line (the current-activity display). */
@@ -787,20 +918,12 @@ namespace ClankerSaddle {
                 return (!)guard;
             }
 
+            var ctx = new GoalViewContext(goal_service, task_service, project_service,
+                db, action_registry);
             var rows = new JsonArray();
             foreach (var goal in yield goal_service.list_goals()) {
-                var pub = new GoalPublic();
-                var links = new Vector<GoalLinkView>();
-                var blocks = new Vector<GoalBlockView>();
-                var goal_tasks = new Vector<GoalTaskView>();
-                var events = new Vector<GoalEventView>();
-                var comments = new Vector<GoalCommentView>();
-                var runs = new Vector<GoalRunView>();
-                var push_branches = new Vector<GoalPushBranchView>();
-                yield GoalState.populate(goal, goal_service, task_service, db,
-                    action_registry, false, pub, links, blocks, goal_tasks,
-                    events, comments, runs, push_branches);
-                var row = (PropertyDictionary) GObjectMapping.to_properties(pub);
+                var view = yield GoalState.summary_view(goal, ctx);
+                var row = (PropertyDictionary) GObjectMapping.to_properties(view.pub);
                 row["plan_pending"] = new JsonElement.from_element(
                     new NativeElement<bool>(goal.status == GOAL_PLAN_REVIEW));
                 rows.add(new JsonElement.from_properties(row));
@@ -857,9 +980,9 @@ namespace ClankerSaddle {
             }
 
             int64 goal_id = int64.parse(request.route("goal_id") ?? "0");
-            var state = yield GoalState.build(goal_id, goal_service, task_service,
-                project_service, db, action_registry, state_service,
-                saddle_service, poll_state, rule_service);
+            var ctx = new GoalViewContext(goal_service, task_service, project_service,
+                db, action_registry, saddle_service, poll_state, rule_service);
+            var state = yield GoalState.build(goal_id, ctx, state_service);
             return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
         }
     }