Pārlūkot izejas kodu

Merge branch 'task/8-33' into goal/toto-batch-1

saddle-Clanker-Saddle 1 mēnesi atpakaļ
vecāks
revīzija
f3a3ea8372

+ 18 - 0
README.md

@@ -206,6 +206,13 @@ transitions) and `POST /api/saddle/chunks` (raw PTY batches) — every
 `/api/saddle/*` call is authenticated by the per-saddle bearer token
 (removing the saddle revokes it).
 
+A goal's **involved projects** start as a one-time snapshot of the
+primary project's linked projects: creating a goal copies the project's
+co-developed/reference links into the goal's own link set, where you
+prune or extend them before starting. Links added to the project later
+never propagate to existing goals — the goal's link set is deliberately
+frozen at creation (the project page remains the long-term graph).
+
 ## Users, roles and your profile
 
 Two roles decide what a logged-in user can do (the account model is
@@ -345,6 +352,17 @@ loudly, never silently claimed as delivered), endpoints answering
 rejection) keep the subscription row and retry, since the endpoint
 may recover.
 
+## Stacks
+
+**Stacks** (`/stacks`) are named collections of reference projects:
+build the collection once (projects with their relationships —
+co-developed or reference), then **Apply stack** on a project page or a
+goal page attaches the whole collection to that project's or goal's
+linked projects in one click. Applying skips exact duplicates (same
+project, same relationship), so re-applying after the collection grows
+is safe; a different relationship on the same project coexists.
+Deleting a stack never unlinks what it already applied.
+
 ## Rules and project notes
 
 **Rules** (`/rules`) are reusable instructions dispatched with every task

+ 17 - 0
meson.build

@@ -104,6 +104,18 @@ saddles_page = custom_target('saddles-page',
     command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'SaddlesPage', '--ns', 'ClankerSaddle', '@INPUT@'],
     depend_files: template_deps
 )
+stacks_page = custom_target('stacks-page',
+    input: 'src/pages/stacks.html',
+    output: 'StacksPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'StacksPage', '--ns', 'ClankerSaddle', '@INPUT@'],
+    depend_files: template_deps
+)
+stack_detail_page = custom_target('stack-detail-page',
+    input: 'src/pages/stack_detail.html',
+    output: 'StackDetailPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'StackDetailPage', '--ns', 'ClankerSaddle', '@INPUT@'],
+    depend_files: template_deps
+)
 rules_page = custom_target('rules-page',
     input: 'src/pages/rules.html',
     output: 'RulesPage.vala',
@@ -170,6 +182,7 @@ app_sources = files(
     'src/Services/StatePulse.vala',
     'src/Services/Dispatcher.vala',
     'src/Services/RuleService.vala',
+    'src/Services/StackService.vala',
     'src/Services/CapabilityService.vala',
     'src/Services/TimelineService.vala',
     'src/Services/PushService.vala',
@@ -184,6 +197,7 @@ app_sources = files(
     'src/actions/DashboardActions.vala',
     'src/actions/SaddleActions.vala',
     'src/actions/RuleActions.vala',
+    'src/actions/StackActions.vala',
     'src/actions/CapabilityActions.vala',
     'src/actions/UserActions.vala',
     'src/actions/ProfileActions.vala',
@@ -197,6 +211,7 @@ app_sources = files(
     'src/entrypoints/GoalDetailEntrypoint.vala',
     'src/entrypoints/SaddlesEntrypoint.vala',
     'src/entrypoints/RulesEntrypoint.vala',
+    'src/entrypoints/StacksEntrypoint.vala',
     'src/entrypoints/UsersEntrypoint.vala',
     'src/entrypoints/ProfileEntrypoint.vala',
     # spry:sources-end
@@ -219,6 +234,8 @@ app_generated = [
     goal_detail_page,
     saddles_page,
     rules_page,
+    stacks_page,
+    stack_detail_page,
     users_page,
     profile_page,
     # spry:generated-end

+ 45 - 0
src/Data/Entities.vala

@@ -116,6 +116,51 @@ namespace ClankerSaddle.Data {
         }
     }
 
+    /**
+     * A named bundle of reference/co-developed projects (goal 8 task
+     * 2): applied one-click to a project's or a goal's link set so a
+     * fleet's common reference collection needs one click, not N.
+     * Stored in the `stacks` table.
+     */
+    public class Stack : Object {
+
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
+        public string? summary { get; set; default = null; }
+        public DateTime? created_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<Stack> cfg) {
+            cfg.table("stacks")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<string>("name", o => o.name, (o, v) => o.name = v)
+                .column<string>("summary", o => o.summary, (o, v) => o.summary = v)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
+        }
+    }
+
+    /**
+     * One project of a {@link Stack} with the relationship it is
+     * applied as ('co-developed' or 'reference'). Stored in the
+     * `stack_projects` table.
+     */
+    public class StackProject : Object {
+
+        public int64 id { get; set; }
+        public int64 stack_id { get; set; }
+        public int64 project_id { get; set; }
+        public string relationship { get; set; default = "reference"; }
+        public DateTime? created_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<StackProject> cfg) {
+            cfg.table("stack_projects")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("stack_id", o => o.stack_id, (o, v) => o.stack_id = v)
+                .column<int64?>("project_id", o => o.project_id, (o, v) => o.project_id = v)
+                .column<string>("relationship", o => o.relationship, (o, v) => o.relationship = v)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
+        }
+    }
+
     /**
      * One row of the plan-17 unified event ledger (`events`): daemon
      * events carry their session and its per-session monotonic `seq`

+ 36 - 0
src/Data/Migrations/M0001_Initial.vala

@@ -57,6 +57,40 @@ namespace ClankerSaddle.Data.Migrations {
                     .not_null();
             });
 
+            // Stacks (goal 8 task 2): a named bundle of reference
+            // projects applied one-click to a project's or a goal's
+            // link set — the common reference collections a fleet of
+            // repos always wants attached.
+            b.create_table("stacks", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<string>("name")
+                    .not_null()
+                    .unique();
+                t.column<string>("summary");
+                t.column<DateTime?>("created_at");
+            });
+
+            b.create_table("stack_projects", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("stack_id")
+                    .not_null()
+                    .references("stacks", "id")
+                    .indexed();
+                t.column<int64?>("project_id")
+                    .not_null()
+                    .references("projects", "id")
+                    .indexed();
+                t.column<string>("relationship")
+                    .not_null()
+                    .default_value<string>("reference");
+                t.column<DateTime?>("created_at");
+                t.unique("uq_stack_projects_pair", {"stack_id", "project_id"});
+            });
+
             b.create_table("goals", (t) => {
                 t.column<int64?>("id")
                     .auto_increment()
@@ -394,6 +428,8 @@ namespace ClankerSaddle.Data.Migrations {
             b.drop_table("goal_links");
             b.drop_table("goals");
             b.drop_table("project_links");
+            b.drop_table("stack_projects");
+            b.drop_table("stacks");
             b.drop_table("projects");
         }
     }

+ 29 - 0
src/Services/GoalService.vala

@@ -169,12 +169,41 @@ namespace ClankerSaddle.Services {
                 created_at = new DateTime.now_local()
             };
             yield db.insert_async<Goal>(goal);
+            yield prefill_links_from_project(goal, (!)project);
             yield append_goal_event(goal.id, EVENT_STATE_CHANGE,
                 @"Goal created (branch $(trimmed_branch))", GOAL_DRAFT);
             yield pulse.bump(0, "goal created");
             return goal;
         }
 
+        /**
+         * Goal 8 task 2 A: pre-fills the new goal's links from the
+         * primary project's `project_links` — a ONE-TIME snapshot at
+         * creation, not a runtime union. `goal_links` stays the
+         * single, user-editable source of truth (the goal page's
+         * add/remove UI prunes what the snapshot carried), and links
+         * added to the project later never propagate — a goal may
+         * deliberately exclude a co-developed repo by removing the
+         * pre-filled row. The goal-create form carries no link fields,
+         * so the pre-fill is unconditional. Same linked project, same
+         * relationship.
+         */
+        private async void prefill_links_from_project(Goal goal, Project project) throws Error {
+            foreach (var link in yield db.query<ProjectLink>()
+                    .where(expr("parent_project_id == $0",
+                        elem<int64?>(project.id)))
+                    .materialise_async()) {
+                if (link.linked_project_id == goal.project_id) {
+                    continue;
+                }
+                yield db.insert_async<GoalLink>(new GoalLink() {
+                    goal_id = goal.id,
+                    project_id = link.linked_project_id,
+                    relationship = link.relationship
+                });
+            }
+        }
+
         /** Loads the goal identified by `id`, or null. */
         public async Goal? get_goal(int64 id) throws Error {
             return yield db.query<Goal>()

+ 266 - 0
src/Services/StackService.vala

@@ -0,0 +1,266 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillSql.Orm;
+using Inversion;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Services {
+
+    /**
+     * Stack storage (goal 8 task 2 B): CRUD over `stacks`, the stack's
+     * project members in `stack_projects` (relationship 'co-developed'
+     * or 'reference', validated like the project link graph), and the
+     * two apply paths — a stack's members land as `project_links` rows
+     * on a project or as `goal_links` rows on a goal, skipping exact
+     * duplicates (same target, same relationship) so re-applying is
+     * idempotent.
+     */
+    public class StackService : Object {
+
+        private OrmSession db = inject<OrmSession>();
+        private ProjectService projects = inject<ProjectService>();
+        private GoalService goals = inject<GoalService>();
+        private StatePulse pulse = inject<StatePulse>();
+
+        /** Whether `relationship` is a valid stack-member relationship. */
+        private void require_valid_relationship(string relationship) throws Error {
+            if (relationship != LINK_CO_DEVELOPED && relationship != LINK_REFERENCE) {
+                throw new Error(Quark.from_string("clanker-saddle"), 90,
+                    @"Relationship must be '$(LINK_CO_DEVELOPED)' or '$(LINK_REFERENCE)'");
+            }
+        }
+
+        /** Inserts a stack; `summary` may be empty. */
+        public async Stack create_stack(string name, string? summary) throws Error {
+            string trimmed = name.strip();
+            if (trimmed.length == 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 91,
+                    "A stack name is required");
+            }
+            var existing = yield get_stack_by_name(trimmed);
+            if (existing != null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 92,
+                    @"A stack named '$(trimmed)' already exists");
+            }
+            var stack = new Stack() {
+                name = trimmed,
+                summary = summary != null && summary.strip().length > 0
+                    ? summary.strip() : null,
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<Stack>(stack);
+            yield pulse.bump(0, "stack created");
+            return stack;
+        }
+
+        /** Loads the stack identified by `id`, or null. */
+        public async Stack? get_stack(int64 id) throws Error {
+            return yield db.query<Stack>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+        }
+
+        /** Loads the stack identified by `name`, or null. */
+        public async Stack? get_stack_by_name(string name) throws Error {
+            return yield db.query<Stack>()
+                .where(expr("name == $0", elem(name)))
+                .first_async();
+        }
+
+        /** Every stack, newest first. */
+        public async ImmutableLot<Stack> list_stacks() throws Error {
+            return yield db.query<Stack>()
+                .order_by_desc(expr("id"))
+                .materialise_async();
+        }
+
+        /**
+         * Saves the editable fields of the stack identified by `id`;
+         * null/empty arguments keep their current values. Returns the
+         * saved stack, or null when no such stack exists.
+         */
+        public async Stack? update_stack(int64 id, string? name, string? summary) throws Error {
+            var stack = yield get_stack(id);
+            if (stack == null) {
+                return null;
+            }
+            if (name != null && name.strip().length > 0) {
+                string trimmed = name.strip();
+                var clash = yield get_stack_by_name(trimmed);
+                if (clash != null && ((!)clash).id != id) {
+                    throw new Error(Quark.from_string("clanker-saddle"), 93,
+                        @"A stack named '$(trimmed)' already exists");
+                }
+                ((!)stack).name = trimmed;
+            }
+            if (summary != null) {
+                ((!)stack).summary = summary.strip().length > 0 ? summary.strip() : null;
+            }
+            yield db.update_async<Stack>((!)stack);
+            yield pulse.bump(0, "stack saved");
+            return stack;
+        }
+
+        /** Deletes the stack and its members. */
+        public async void delete_stack(int64 id) throws Error {
+            var stack = yield get_stack(id);
+            if (stack == null) {
+                return;
+            }
+            foreach (var member in yield list_members(id)) {
+                yield db.delete_async<StackProject>(member);
+            }
+            yield db.delete_async<Stack>((!)stack);
+            yield pulse.bump(0, "stack deleted");
+        }
+
+        /** The stack's member rows, oldest first. */
+        public async ImmutableLot<StackProject> list_members(int64 stack_id) throws Error {
+            return yield db.query<StackProject>()
+                .where(expr("stack_id == $0", elem<int64?>(stack_id)))
+                .order_by(expr("id"))
+                .materialise_async();
+        }
+
+        /**
+         * Adds a project to the stack with `relationship`;
+         * duplicates (same stack, same project) are rejected.
+         */
+        public async StackProject add_member(int64 stack_id, int64 project_id,
+                string relationship) throws Error {
+            require_valid_relationship(relationship);
+            var stack = yield get_stack(stack_id);
+            if (stack == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 94,
+                    @"No stack exists with id $(stack_id)");
+            }
+            var project = yield projects.get_project(project_id);
+            if (project == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 95,
+                    @"No project exists with id $(project_id)");
+            }
+            var existing = yield db.query<StackProject>()
+                .where(expr("stack_id == $0 && project_id == $1",
+                    elem<int64?>(stack_id), elem<int64?>(project_id)))
+                .materialise_async();
+            if (existing.length > 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 96,
+                    "That project is already in the stack");
+            }
+            var member = new StackProject() {
+                stack_id = stack_id,
+                project_id = project_id,
+                relationship = relationship,
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<StackProject>(member);
+            yield pulse.bump(0, "stack member added");
+            return member;
+        }
+
+        /** Removes the stack member identified by `id`. */
+        public async void remove_member(int64 member_id) throws Error {
+            var member = yield db.query<StackProject>()
+                .where(expr("id == $0", elem<int64?>(member_id)))
+                .first_async();
+            if (member != null) {
+                yield db.delete_async<StackProject>((!)member);
+                yield pulse.bump(0, "stack member removed");
+            }
+        }
+
+        /**
+         * Applies the stack to a project: every member becomes a
+         * `project_links` row on `parent_id`, same relationship, as
+         * long as the parent does not already carry that exact pair
+         * (same target AND same relationship — a different
+         * relationship on the same target stays). Returns how many
+         * links landed. Never throws on duplicates: re-applying is
+         * idempotent by design.
+         */
+        public async int64 apply_to_project(int64 stack_id, int64 parent_id) throws Error {
+            var stack = yield require_stack(stack_id);
+            var parent = yield projects.get_project(parent_id);
+            if (parent == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 97,
+                    @"No project exists with id $(parent_id)");
+            }
+            int64 added = 0;
+            foreach (var member in yield list_members(stack_id)) {
+                if (member.project_id == parent_id) {
+                    continue;  // a project never links to itself
+                }
+                bool duplicate = false;
+                foreach (var link in yield projects.list_links(parent_id)) {
+                    if (link.linked_project_id == member.project_id
+                            && link.relationship == member.relationship) {
+                        duplicate = true;
+                        break;
+                    }
+                }
+                if (duplicate) {
+                    continue;
+                }
+                yield db.insert_async<ProjectLink>(new ProjectLink() {
+                    parent_project_id = parent_id,
+                    linked_project_id = member.project_id,
+                    relationship = member.relationship
+                });
+                added++;
+            }
+            yield pulse.bump(0, @"stack $(((!)stack).name) applied to project");
+            return added;
+        }
+
+        /**
+         * Applies the stack to a goal: every member becomes a
+         * `goal_links` row on `goal_id`, same relationship, skipping
+         * exact duplicates. The primary project is implicitly linked
+         * and never duplicated. Returns how many links landed.
+         */
+        public async int64 apply_to_goal(int64 stack_id, int64 goal_id) throws Error {
+            var stack = yield require_stack(stack_id);
+            var goal = yield goals.get_goal(goal_id);
+            if (goal == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 98,
+                    @"No goal exists with id $(goal_id)");
+            }
+            int64 added = 0;
+            foreach (var member in yield list_members(stack_id)) {
+                if (member.project_id == ((!)goal).project_id) {
+                    continue;  // the primary project is implicitly linked
+                }
+                bool duplicate = false;
+                foreach (var link in yield goals.list_links(goal_id)) {
+                    if (link.project_id == member.project_id
+                            && link.relationship == member.relationship) {
+                        duplicate = true;
+                        break;
+                    }
+                }
+                if (duplicate) {
+                    continue;
+                }
+                yield db.insert_async<GoalLink>(new GoalLink() {
+                    goal_id = goal_id,
+                    project_id = member.project_id,
+                    relationship = member.relationship
+                });
+                added++;
+            }
+            yield pulse.bump(0, @"stack $(((!)stack).name) applied to goal");
+            return added;
+        }
+
+        /** Loads the stack or throws (the apply paths' guard). */
+        private async Stack require_stack(int64 id) throws Error {
+            var stack = yield get_stack(id);
+            if (stack == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 94,
+                    @"No stack exists with id $(id)");
+            }
+            return (!)stack;
+        }
+    }
+}

+ 3 - 1
src/actions/GoalActions.vala

@@ -52,6 +52,7 @@ namespace ClankerSaddle {
         protected SaddleService saddle_service = inject<SaddleService>();
         protected SaddlePollState poll_state = inject<SaddlePollState>();
         protected RuleService rule_service = inject<RuleService>();
+        protected StackService stack_service = inject<StackService>();
         protected InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         protected UserService users = inject<UserService>();
 
@@ -62,7 +63,8 @@ namespace ClankerSaddle {
          */
         protected async Statum.Slot goal_detail_slot(int64 goal_id) throws GLib.Error {
             var ctx = new GoalViewContext(goal_service, task_service, project_service,
-                event_service, db, action_registry, saddle_service, poll_state, rule_service);
+                event_service, db, action_registry, saddle_service, poll_state, rule_service,
+                stack_service);
             return yield GoalState.build(goal_id, ctx, state_service);
         }
     }

+ 274 - 0
src/actions/StackActions.vala

@@ -0,0 +1,274 @@
+using Inversion;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** Sealed stack id carried by the stack actions. */
+    public class StackActionPrivate : Object {
+        public int64 stack_id { get; set; }
+    }
+
+    /** Sealed stack-member identity carried by {@link RemoveStackMemberAction}. */
+    public class StackMemberActionPrivate : Object {
+        public int64 stack_id { get; set; }
+        public int64 member_id { get; set; }
+    }
+
+    /** Sealed stack + project identity for {@link ApplyStackToProjectAction}. */
+    public class StackProjectApplyPrivate : Object {
+        public int64 stack_id { get; set; }
+        public int64 project_id { get; set; }
+    }
+
+    /** Sealed stack + goal identity for {@link ApplyStackToGoalAction}. */
+    public class StackGoalApplyPrivate : Object {
+        public int64 stack_id { get; set; }
+        public int64 goal_id { get; set; }
+    }
+
+    /**
+     * Creates a stack from the stacks form (name required, unique;
+     * summary optional).
+     */
+    public class CreateStackAction : StatumAction {
+
+        private StackService stacks = inject<StackService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            string name = Forms.field(request, "name");
+            if (name.length == 0) {
+                return directives().notify("error", "A stack name is required").navigate("/stacks");
+            }
+
+            try {
+                var stack = yield stacks.create_stack(name,
+                    Forms.field(request, "summary"));
+                return directives().notify("success", @"Stack '$(stack.name)' created")
+                    .navigate(@"/stacks/$(stack.id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not create the stack: $(e.message)").navigate("/stacks");
+            }
+        }
+    }
+
+    /**
+     * Saves a stack's editable fields (name, summary); the id comes
+     * from the sealed {@link StackActionPrivate} blob (the hidden form
+     * field is a fallback). An empty summary clears the stored one.
+     */
+    public class UpdateStackAction : TypedStatumAction<StackActionPrivate> {
+
+        private StackService stacks = inject<StackService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(StackActionPrivate); } }
+
+        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;
+            int64 stack_id = ctx != null ? ((!)ctx).stack_id : Forms.integer(request, "stack_id", 0);
+            if (stack_id <= 0) {
+                return directives().navigate("/stacks");
+            }
+
+            try {
+                yield stacks.update_stack(stack_id,
+                    Forms.optional(request, "name"),
+                    request.form != null ? Forms.field(request, "summary") : null);
+                return directives().notify("success", "Stack saved")
+                    .navigate(@"/stacks/$(stack_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not save the stack: $(e.message)")
+                    .navigate(@"/stacks/$(stack_id)");
+            }
+        }
+    }
+
+    /** Deletes a stack; the id comes from the sealed private blob. */
+    public class DeleteStackAction : TypedStatumAction<StackActionPrivate> {
+
+        private StackService stacks = inject<StackService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(StackActionPrivate); } }
+
+        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("/stacks");
+            }
+
+            try {
+                yield stacks.delete_stack(((!)ctx).stack_id);
+                return directives().notify("success", "Stack deleted").navigate("/stacks");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not delete the stack: $(e.message)")
+                    .navigate("/stacks/$(((!)ctx).stack_id)");
+            }
+        }
+    }
+
+    /**
+     * Adds a project to a stack; the stack id is sealed, the target
+     * and relationship come from the form selects.
+     */
+    public class AddStackMemberAction : TypedStatumAction<StackActionPrivate> {
+
+        private StackService stacks = inject<StackService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(StackActionPrivate); } }
+
+        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;
+            int64 stack_id = ctx != null ? ((!)ctx).stack_id : Forms.integer(request, "stack_id", 0);
+            int64 project_id = Forms.integer(request, "project_id", 0);
+            if (stack_id <= 0 || project_id <= 0) {
+                return directives().notify("error", "Pick a project to add").navigate(@"/stacks/$(stack_id)");
+            }
+
+            try {
+                yield stacks.add_member(stack_id, project_id,
+                    Forms.field(request, "relationship"));
+                return directives().notify("success", "Project added to the stack")
+                    .navigate(@"/stacks/$(stack_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not add the project: $(e.message)")
+                    .navigate(@"/stacks/$(stack_id)");
+            }
+        }
+    }
+
+    /**
+     * Removes a stack member; both ids come from the sealed private
+     * blob.
+     */
+    public class RemoveStackMemberAction : TypedStatumAction<StackMemberActionPrivate> {
+
+        private StackService stacks = inject<StackService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(StackMemberActionPrivate); } }
+
+        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("/stacks");
+            }
+
+            try {
+                yield stacks.remove_member(((!)ctx).member_id);
+                return directives().notify("success", "Project removed from the stack")
+                    .navigate(@"/stacks/$(((!)ctx).stack_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not remove the project: $(e.message)")
+                    .navigate(@"/stacks/$(((!)ctx).stack_id)");
+            }
+        }
+    }
+
+    /**
+     * Applies a stack to a project (the project page's "Apply stack"
+     * action): the stack's members land as project_links rows,
+     * skipping exact duplicates. The stack comes from the form select;
+     * the project id is sealed.
+     */
+    public class ApplyStackToProjectAction : TypedStatumAction<ProjectActionPrivate> {
+
+        private StackService stacks = inject<StackService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(ProjectActionPrivate); } }
+
+        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;
+            int64 project_id = ctx != null ? ((!)ctx).project_id : Forms.integer(request, "project_id", 0);
+            int64 stack_id = Forms.integer(request, "stack_id", 0);
+            if (project_id <= 0 || stack_id <= 0) {
+                return directives().notify("error", "Pick a stack to apply").navigate(@"/projects/$(project_id)");
+            }
+
+            try {
+                int64 added = yield stacks.apply_to_project(stack_id, project_id);
+                return directives().notify("success",
+                        added > 0
+                            ? @"Stack applied — $(added) link(s) added"
+                            : "Stack applied — nothing new to add")
+                    .navigate(@"/projects/$(project_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not apply the stack: $(e.message)")
+                    .navigate(@"/projects/$(project_id)");
+            }
+        }
+    }
+
+    /**
+     * Applies a stack to a goal (the goal page's "Apply stack"
+     * action): the stack's members land as goal_links rows, skipping
+     * exact duplicates. The stack comes from the form select; the
+     * goal id is sealed.
+     */
+    public class ApplyStackToGoalAction : SealedGoalAction {
+
+        private StackService stacks = inject<StackService>();
+        private UserService users = 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 goal_id = sealed_goal_id();
+            int64 stack_id = Forms.integer(request, "stack_id", 0);
+            if (goal_id <= 0 || stack_id <= 0) {
+                return directives().notify("error", "Pick a stack to apply").navigate(@"/goals/$(goal_id)");
+            }
+
+            try {
+                int64 added = yield stacks.apply_to_goal(stack_id, goal_id);
+                return directives().notify("success",
+                        added > 0
+                            ? @"Stack applied — $(added) link(s) added"
+                            : "Stack applied — nothing new to add")
+                    .navigate(@"/goals/$(goal_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not apply the stack: $(e.message)")
+                    .navigate(@"/goals/$(goal_id)");
+            }
+        }
+    }
+}

+ 32 - 2
src/entrypoints/GoalDetailEntrypoint.vala

@@ -213,6 +213,13 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? force_wake { get; set; }
         public Statum.Model.ActionDto? comment { get; set; }
         public Statum.Model.ActionDto? add_link { get; set; }
+        /**
+         * Goal 8 task 2 B: applies a stack's members as goal links.
+         * Authored while the goal is not terminal (the involved set
+         * is frozen once complete/failed/cancelled).
+         */
+        public Statum.Model.ActionDto? apply_stack { get; set; }
+        public Vector<StackOptionView> stack_options { get; set; default = new Vector<StackOptionView>(); }
         /** The sealed GET refresh action (live pulse rehydration). */
         public Statum.Model.ActionDto? refresh { get; set; }
     }
@@ -234,12 +241,14 @@ namespace ClankerSaddle {
         public SaddleService? saddles { get; set; default = null; }
         public SaddlePollState? polls { get; set; default = null; }
         public RuleService? rules { get; set; default = null; }
+        public StackService? stacks { get; set; default = null; }
 
         public GoalViewContext(GoalService goals, TaskService tasks,
                 ProjectService projects, EventService events,
                 InvercargillSql.Orm.OrmSession db,
                 ActionRegistry? actions = null, SaddleService? saddles = null,
-                SaddlePollState? polls = null, RuleService? rules = null) {
+                SaddlePollState? polls = null, RuleService? rules = null,
+                StackService? stacks = null) {
             this.goals = goals;
             this.tasks = tasks;
             this.projects = projects;
@@ -249,6 +258,7 @@ namespace ClankerSaddle {
             this.saddles = saddles;
             this.polls = polls;
             this.rules = rules;
+            this.stacks = stacks;
         }
     }
 
@@ -316,6 +326,16 @@ namespace ClankerSaddle {
                         });
                     }
                 }
+                // Goal 8 task 2 B: the stacks appliable to this goal.
+                if (ctx.stacks != null) {
+                    foreach (var stack in yield ((!)ctx.stacks).list_stacks()) {
+                        view.pub.stack_options.add(new StackOptionView() {
+                            id = stack.id,
+                            name = stack.name,
+                            member_count = (yield ((!)ctx.stacks).list_members(stack.id)).length
+                        });
+                    }
+                }
                 // Plan 14 B5: the goal's effective rules — the set a
                 // dispatch ships for the run (and every worker):
                 // run-task rules, the primary project's, then the
@@ -380,6 +400,8 @@ namespace ClankerSaddle {
                 effective_rules.to_immutable_buffer());
             pub_root["project_options"] = StateJson.list<GoalProjectOptionView>(
                 project_options.to_immutable_buffer());
+            pub_root["stack_options"] = StateJson.list<StackOptionView>(
+                view.pub.stack_options.to_immutable_buffer());
             pub_root["plan_pending_block"] = plan_pending != null
                 ? new JsonElement.from_properties(
                     (PropertyDictionary) GObjectMapping.to_properties((!)plan_pending))
@@ -444,6 +466,9 @@ namespace ClankerSaddle {
                 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 (!pub.terminal) {
+                    pub.apply_stack = ((!)ctx.actions).author_private<ApplyStackToGoalAction, GoalActionPrivate>(sealed);
+                }
                 if (goal.status == GOAL_DRAFT) {
                     pub.start = ((!)ctx.actions).author_private<StartGoalAction, GoalActionPrivate>(sealed);
                     pub.update = ((!)ctx.actions).author_private<UpdateDraftGoalAction, GoalActionPrivate>(sealed);
@@ -543,6 +568,9 @@ namespace ClankerSaddle {
                 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 (!pub.terminal) {
+                    pub.apply_stack = ((!)ctx.actions).author_private<ApplyStackToGoalAction, GoalActionPrivate>(sealed);
+                }
                 if (goal.status == GOAL_DRAFT) {
                     pub.start = ((!)ctx.actions).author_private<StartGoalAction, GoalActionPrivate>(sealed);
                     pub.update = ((!)ctx.actions).author_private<UpdateDraftGoalAction, GoalActionPrivate>(sealed);
@@ -1031,6 +1059,7 @@ namespace ClankerSaddle {
         private SaddleService saddle_service = Inversion.inject<SaddleService>();
         private SaddlePollState poll_state = Inversion.inject<SaddlePollState>();
         private RuleService rule_service = Inversion.inject<RuleService>();
+        private StackService stack_service = Inversion.inject<StackService>();
         private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private UserService users = Inversion.inject<UserService>();
 
@@ -1042,7 +1071,8 @@ namespace ClankerSaddle {
 
             int64 goal_id = int64.parse(request.route("goal_id") ?? "0");
             var ctx = new GoalViewContext(goal_service, task_service, project_service,
-                event_service, db, action_registry, saddle_service, poll_state, rule_service);
+                event_service, db, action_registry, saddle_service, poll_state, rule_service,
+                stack_service);
             var state = yield GoalState.build(goal_id, ctx, state_service);
             return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
         }

+ 20 - 1
src/entrypoints/ProjectsEntrypoint.vala

@@ -112,6 +112,13 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? detach { get; set; }
     }
 
+    /** A stack option in the apply select (project and goal pages). */
+    public class StackOptionView : Object {
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
+        public int64 member_count { get; set; }
+    }
+
     /** A rule option in the attach selects (project and task pages). */
     public class RuleOptionView : Object {
         public int64 id { get; set; }
@@ -144,6 +151,7 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? update { get; set; }
         public Statum.Model.ActionDto? add_link { get; set; }
         public Statum.Model.ActionDto? attach_rule { get; set; }
+        public Statum.Model.ActionDto? apply_stack { get; set; }
     }
 
     /**
@@ -157,6 +165,7 @@ namespace ClankerSaddle {
         private ProjectService project_service = Inversion.inject<ProjectService>();
         private TaskService task_service = Inversion.inject<TaskService>();
         private RuleService rule_service = Inversion.inject<RuleService>();
+        private StackService stack_service = Inversion.inject<StackService>();
         private UserService users = Inversion.inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -183,7 +192,8 @@ namespace ClankerSaddle {
                 created_display = format_stamp(((!)project).created_at),
                 update = action_registry.author_private<UpdateProjectAction, ProjectActionPrivate>(sealed),
                 add_link = action_registry.author_private<AddProjectLinkAction, ProjectActionPrivate>(sealed),
-                attach_rule = action_registry.author_private<AttachProjectRuleAction, ProjectActionPrivate>(sealed)
+                attach_rule = action_registry.author_private<AttachProjectRuleAction, ProjectActionPrivate>(sealed),
+                apply_stack = action_registry.author_private<ApplyStackToProjectAction, ProjectActionPrivate>(sealed)
             };
 
             var links = new Vector<ProjectLinkView>();
@@ -288,6 +298,15 @@ namespace ClankerSaddle {
             props["tasks"] = StateJson.list<ProjectTaskRowView>(task_rows.to_immutable_buffer());
             props["rules"] = StateJson.list<ProjectRuleRowView>(rule_rows.to_immutable_buffer());
             props["rule_options"] = StateJson.list<RuleOptionView>(rule_options.to_immutable_buffer());
+            var stack_options = new Vector<StackOptionView>();
+            foreach (var stack in yield stack_service.list_stacks()) {
+                stack_options.add(new StackOptionView() {
+                    id = stack.id,
+                    name = stack.name,
+                    member_count = (yield stack_service.list_members(stack.id)).length
+                });
+            }
+            props["stack_options"] = StateJson.list<StackOptionView>(stack_options.to_immutable_buffer());
             props["notes"] = StateJson.list<ProjectNoteRowView>(note_rows.to_immutable_buffer());
 
             var slot = state_service.new_slot(Scope.PAGE, new State() {

+ 170 - 0
src/entrypoints/StacksEntrypoint.vala

@@ -0,0 +1,170 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** A stack row on the stacks page, with its member names. */
+    public class StackRowView : Object {
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
+        public string summary { get; set; default = ""; }
+        public string created_display { get; set; default = ""; }
+        public int64 member_count { get; set; }
+    }
+
+    /** Public data of the `stacks` slot. */
+    public class StacksPublic : Object {
+        public Statum.Model.ActionDto create { get; set; }
+    }
+
+    /**
+     * Entrypoint for /stacks (goal 8 task 2 B): every stack with its
+     * member count and a link to its page, plus the create form. A
+     * stack is a named bundle of reference projects applied one-click
+     * to a project's or a goal's link set.
+     */
+    public class StacksEntrypoint : StatumEntrypoint {
+
+        private StackService stack_service = Inversion.inject<StackService>();
+        private ProjectService project_service = Inversion.inject<ProjectService>();
+        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;
+            }
+
+            var rows = new Vector<StackRowView>();
+            foreach (var stack in yield stack_service.list_stacks()) {
+                rows.add(new StackRowView() {
+                    id = stack.id,
+                    name = stack.name,
+                    summary = stack.summary ?? "",
+                    created_display = format_stamp(stack.created_at),
+                    member_count = (yield stack_service.list_members(stack.id)).length
+                });
+            }
+
+            var pub = new StacksPublic();
+            pub.create = action_registry.author<CreateStackAction>();
+
+            var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
+            props["stacks"] = StateJson.list<StackRowView>(rows.to_immutable_buffer());
+
+            var slot = state_service.new_slot(Scope.PAGE, new State() {
+                type_name = "stacks",
+                public_data = props,
+                private_data = new PropertyDictionary()
+            });
+            return directives().set(slot);
+        }
+    }
+
+    /** A member project of a stack, with its remove action. */
+    public class StackMemberView : Object {
+        public int64 member_id { get; set; }
+        public int64 project_id { get; set; }
+        public string name { get; set; default = ""; }
+        public string git_url { get; set; default = ""; }
+        public string relationship { get; set; default = ""; }
+        public Statum.Model.ActionDto? remove { get; set; }
+    }
+
+    /** A project option in the member-add select. */
+    public class StackProjectOptionView : Object {
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
+    }
+
+    /** Public data of the `stack` slot. */
+    public class StackDetailPublic : Object {
+        public bool exists { get; set; }
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
+        public string summary { get; set; default = ""; }
+        public string created_display { get; set; default = ""; }
+        public Statum.Model.ActionDto? update { get; set; }
+        public Statum.Model.ActionDto? add_member { get; set; }
+        public Statum.Model.ActionDto? delete { get; set; }
+    }
+
+    /**
+     * Entrypoint for /stacks/{stack_id}: the stack's editable card,
+     * its member projects with relationship badges and remove actions,
+     * and the add-member form (every project, relationship select).
+     */
+    public class StackDetailEntrypoint : StatumEntrypoint {
+
+        private StackService stack_service = Inversion.inject<StackService>();
+        private ProjectService project_service = Inversion.inject<ProjectService>();
+        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 stack_id = int64.parse(request.route("stack_id") ?? "0");
+            var stack = yield stack_service.get_stack(stack_id);
+            if (stack == null) {
+                return directives().set_typed<StackDetailPublic>("stack", Scope.PAGE,
+                    new StackDetailPublic() { exists = false });
+            }
+
+            var sealed = new StackActionPrivate() { stack_id = stack_id };
+            var pub = new StackDetailPublic() {
+                exists = true,
+                id = ((!)stack).id,
+                name = ((!)stack).name,
+                summary = ((!)stack).summary ?? "",
+                created_display = format_stamp(((!)stack).created_at),
+                update = action_registry.author_private<UpdateStackAction, StackActionPrivate>(sealed),
+                add_member = action_registry.author_private<AddStackMemberAction, StackActionPrivate>(sealed),
+                delete = action_registry.author_private<DeleteStackAction, StackActionPrivate>(sealed)
+            };
+
+            var members = new Vector<StackMemberView>();
+            foreach (var member in yield stack_service.list_members(stack_id)) {
+                var project = yield project_service.get_project(member.project_id);
+                if (project == null) {
+                    continue;
+                }
+                members.add(new StackMemberView() {
+                    member_id = member.id,
+                    project_id = member.project_id,
+                    name = ((!)project).name,
+                    git_url = ((!)project).git_url,
+                    relationship = member.relationship,
+                    remove = action_registry.author_private<RemoveStackMemberAction, StackMemberActionPrivate>(
+                        new StackMemberActionPrivate() { stack_id = stack_id, member_id = member.id })
+                });
+            }
+
+            var options = new Vector<StackProjectOptionView>();
+            foreach (var project in yield project_service.list_projects()) {
+                options.add(new StackProjectOptionView() {
+                    id = project.id,
+                    name = project.name
+                });
+            }
+
+            var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
+            props["members"] = StateJson.list<StackMemberView>(members.to_immutable_buffer());
+            props["options"] = StateJson.list<StackProjectOptionView>(options.to_immutable_buffer());
+
+            var slot = state_service.new_slot(Scope.PAGE, new State() {
+                type_name = "stack",
+                public_data = props,
+                private_data = new PropertyDictionary()
+            });
+            return directives().set(slot);
+        }
+    }
+}

+ 12 - 0
src/main.vala

@@ -62,12 +62,15 @@ int main(string[] args) {
         db.add_entity<TaskNotifyPref>(TaskNotifyPref.entity_mapping);
         db.add_entity<Goal>(Goal.entity_mapping);
         db.add_entity<GoalLink>(GoalLink.entity_mapping);
+        db.add_entity<Stack>(Stack.entity_mapping);
+        db.add_entity<StackProject>(StackProject.entity_mapping);
         db.migrate_on_startup();
 
         application.add_scoped<ProjectService>();
         application.add_scoped<TaskService>();
         application.add_scoped<EventService>();
         application.add_scoped<GoalService>();
+        application.add_scoped<StackService>();
         application.add_scoped<SaddleService>();
         application.add_scoped<LifecycleService>();
         application.add_scoped<RuleService>();
@@ -103,6 +106,8 @@ int main(string[] args) {
         statum.add_page<GoalDetailPage, GoalDetailEntrypoint>();
         statum.add_page<SaddlesPage, SaddlesEntrypoint>();
         statum.add_page<RulesPage, RulesEntrypoint>();
+        statum.add_page<StacksPage, StacksEntrypoint>();
+        statum.add_page<StackDetailPage, StackDetailEntrypoint>();
         statum.add_page<UsersPage, UsersEntrypoint>();
         statum.add_page<ProfilePage, ProfileEntrypoint>();
         // spry:pages-end
@@ -157,6 +162,13 @@ int main(string[] args) {
         statum.action<AttachTaskRuleAction>();
         statum.action<DetachTaskRuleAction>();
         statum.action<DeleteProjectNoteAction>();
+        statum.action<CreateStackAction>();
+        statum.action<UpdateStackAction>();
+        statum.action<DeleteStackAction>();
+        statum.action<AddStackMemberAction>();
+        statum.action<RemoveStackMemberAction>();
+        statum.action<ApplyStackToProjectAction>();
+        statum.action<ApplyStackToGoalAction>();
         statum.action<CreateCapabilityAction>();
         statum.action<DeleteCapabilityAction>();
         statum.action<AttachSaddleCapabilityAction>();

+ 10 - 0
src/pages/goal_detail.html

@@ -422,6 +422,7 @@
                           stm-text="link.name + ' (' + link.relationship + ')'">link</span>
                     <button type="button" class="inline" stm-action="link.remove"
                             stm-confirm="Remove this link?">&times;</button>
+                    <input type="hidden" name="link_id" stm-attribute.value="link.id">
                 </span></p>
                 <p stm-if="goal.links.length == 0" class="muted">Only the primary project.</p>
                 <details stm-if="!goal.terminal">
@@ -437,6 +438,15 @@
                         </select>
                         <button type="submit">Link</button>
                     </form>
+                    <form class="inline" stm-action="goal.apply_stack"
+                          stm-if="goal.stack_options.length > 0" style="margin-top: 0.5em;">
+                        <label for="goal-stack">Apply stack</label>
+                        <select id="goal-stack" name="stack_id" required>
+                            <option stm-for-stack-in="goal.stack_options" stm-attribute.value="stack.id"
+                                     stm-text="stack.name + ' (' + stack.member_count + ')'">stack</option>
+                        </select>
+                        <button type="submit">Apply</button>
+                    </form>
                 </details>
             </section>
 

+ 1 - 0
src/pages/main.html

@@ -452,6 +452,7 @@
             <a href="/goals">Goals</a>
             <a href="/saddles">Saddles</a>
             <a href="/rules">Rules</a>
+            <a href="/stacks">Stacks</a>
             <!-- The users link is display-guarded (the page's entrypoint
                  guard is authoritative); auth.permissions rides the
                  signed auth slot, so an admin sees it everywhere. -->

+ 11 - 0
src/pages/project_detail.html

@@ -62,6 +62,17 @@
                 <button type="submit">Add link</button>
             </form>
 
+            <form class="inline" stm-action="project.apply_stack" stm-if="project.stack_options.length > 0">
+                <label for="stack-target">Apply stack</label>
+                <select id="stack-target" name="stack_id" required>
+                    <option stm-for-stack-in="project.stack_options" stm-attribute.value="stack.id"
+                             stm-text="stack.name + ' (' + stack.member_count + ')'">stack</option>
+                </select>
+                <button type="submit">Apply</button>
+            </form>
+            <p class="muted" stm-if="project.stack_options.length == 0">Manage reference
+                collections on the <a href="/stacks">stacks page</a> and apply them here.</p>
+
             <h3>Rules</h3>
             <p class="muted">Attached rules ride along with every task dispatched for this project.</p>
             <div stm-for-rule-in="project.rules" stm-key="rule.link_id" class="card">

+ 67 - 0
src/pages/stack_detail.html

@@ -0,0 +1,67 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/stacks/{stack_id}</pstm-uri>
+    <pstm-template>main</pstm-template>
+</head>
+<body>
+    <section>
+        <h2 stm-if="stack != null && stack.exists" stm-text="stack.name">Stack</h2>
+        <p stm-if="stack != null && !stack.exists" class="muted">Stack not found.</p>
+
+        <div stm-if="stack != null && stack.exists">
+            <p><a href="/stacks">All stacks</a></p>
+            <div class="cards">
+                <section class="card">
+                    <h3>Details</h3>
+                    <p class="muted" stm-if="stack.summary.length > 0" stm-text="stack.summary">summary</p>
+                    <p class="muted">Created <span stm-text="stack.created_display">when</span></p>
+                </section>
+
+                <section class="card">
+                    <h3>Edit</h3>
+                    <form stm-action="stack.update">
+                        <input type="hidden" name="stack_id" stm-attribute.value="stack.id">
+                        <div class="form-row"><label>Name</label>
+                            <input type="text" name="name" stm-attribute.value="stack.name"></div>
+                        <div class="form-row"><label>Summary</label>
+                            <input type="text" name="summary" stm-attribute.value="stack.summary"></div>
+                        <button type="submit">Save</button>
+                    </form>
+                </section>
+            </div>
+
+            <h3>Projects in this stack</h3>
+            <p class="muted">Applying the stack adds each of these to a project's or a goal's
+                linked projects with the relationship shown here.</p>
+            <table>
+                <thead><tr><th>Project</th><th>Git URL</th><th>Relationship</th><th></th></tr></thead>
+                <tbody>
+                    <tr stm-for-member-in="stack.members">
+                        <td><a stm-attribute.href="'/projects/' + member.project_id" stm-text="member.name">name</a></td>
+                        <td stm-text="member.git_url">git_url</td>
+                        <td><span stm-attribute.class="'badge badge-' + member.relationship" stm-text="member.relationship">relationship</span></td>
+                        <td><button type="button" stm-action="member.remove" stm-confirm="Remove this project from the stack?">Remove</button></td>
+                    </tr>
+                    <tr stm-if="stack.members.length == 0"><td colspan="4" class="muted">No projects in this stack yet.</td></tr>
+                </tbody>
+            </table>
+            <form class="inline" stm-action="stack.add_member" stm-if="stack.options.length > 0">
+                <label for="member-target">Add project</label>
+                <select id="member-target" name="project_id" required>
+                    <option stm-for-option-in="stack.options" stm-attribute.value="option.id" stm-text="option.name">project</option>
+                </select>
+                <select name="relationship">
+                    <option value="reference" selected>reference</option>
+                    <option value="co-developed">co-developed</option>
+                </select>
+                <button type="submit">Add</button>
+            </form>
+
+            <form class="inline">
+                <button type="button" stm-action="stack.delete" stm-confirm="Delete this stack? Its projects keep any links already applied.">Delete stack</button>
+            </form>
+        </div>
+    </section>
+</body>
+</html>

+ 42 - 0
src/pages/stacks.html

@@ -0,0 +1,42 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/stacks</pstm-uri>
+    <pstm-template>main</pstm-template>
+</head>
+<body>
+    <section>
+        <h2>Stacks</h2>
+        <p class="muted">Named bundles of reference projects — apply one to a project or a
+            goal to attach its whole collection of links in a single click.</p>
+        <p stm-if="stacks == null">Loading…</p>
+
+        <div stm-if="stacks != null">
+            <details>
+                <summary><strong>New stack</strong></summary>
+                <form stm-action="stacks.create">
+                    <div class="form-row"><label>Name</label>
+                        <input type="text" name="name" required placeholder="Web stack references"></div>
+                    <div class="form-row"><label>Summary <span class="muted">(optional)</span></label>
+                        <input type="text" name="summary" placeholder="What this collection is for"></div>
+                    <button type="submit">Create stack</button>
+                </form>
+            </details>
+
+            <table stm-if="stacks.stacks.length > 0">
+                <thead><tr><th>#</th><th>Name</th><th>Projects</th><th>Summary</th><th>Created</th></tr></thead>
+                <tbody>
+                    <tr stm-for-stack-in="stacks.stacks">
+                        <td stm-text="stack.id">id</td>
+                        <td><a stm-attribute.href="'/stacks/' + stack.id" stm-text="stack.name">name</a></td>
+                        <td stm-text="stack.member_count">0</td>
+                        <td class="muted" stm-text="stack.summary">summary</td>
+                        <td class="muted" stm-text="stack.created_display">when</td>
+                    </tr>
+                </tbody>
+            </table>
+            <p stm-if="stacks.stacks.length == 0" class="muted">No stacks yet.</p>
+        </div>
+    </section>
+</body>
+</html>

+ 2 - 1
test/bundles/__init__.py

@@ -4,7 +4,7 @@ the modules' star-imported runtime globals are the shard's own). The
 order here is the parallel runner's shard order."""
 
 from . import (allocation, answers, dispatch, goalui, goals, lineage,
-               lifecycle, pages, push, replay, subtasks, users)
+               lifecycle, pages, push, replay, stacks, subtasks, users)
 
 BUNDLES = {
     "pages": pages,
@@ -19,6 +19,7 @@ BUNDLES = {
     "replay": replay,
     "goalui": goalui,
     "users": users,
+    "stacks": stacks,
 }
 
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",

+ 316 - 0
test/bundles/stacks.py

@@ -0,0 +1,316 @@
+"""The stacks bundle (goal 8 task 2): the goal link pre-fill from the
+primary project's project_links, and the stacks feature — a named
+collection of reference projects applied one-click to a project's or a
+goal's link set (stacks/stack_projects tables, /stacks pages, the apply
+actions with exact-duplicate skipping)."""
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+
+
+def run(ctx):
+    auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
+
+    def create_goal(fields):
+        act(auth, slot_public(auth, "/goals", "goals")["create"], fields)
+        rows = [g for g in slot_public(auth, "/goals", "goals")["goals"]
+                if g["title"] == fields["title"]]
+        return int(rows[0]["id"])
+
+    def stack_state(stack_id):
+        return slot_public(auth, "/stacks/%d" % stack_id, "stack")
+
+    def link_rows(goal_id):
+        conn = db()
+        try:
+            return conn.execute(
+                "select gl.project_id, gl.relationship from goal_links gl"
+                " where gl.goal_id = ? order by gl.id", (goal_id,)).fetchall()
+        finally:
+            conn.close()
+
+    def project_link_rows(parent_id):
+        conn = db()
+        try:
+            return conn.execute(
+                "select linked_project_id, relationship from project_links"
+                " where parent_project_id = ? order by id", (parent_id,)).fetchall()
+        finally:
+            conn.close()
+
+    print("== goal 8 task 2 A: goal links pre-fill from the project ==")
+    # Two satellites around the demo project: one co-developed, one
+    # reference. The goal created on demo must inherit both.
+    conn = db()
+    conn.execute("insert into projects (name, git_url, default_branch, created_at)"
+                 " values ('stacks-codev', 'https://example.com/codev.git', 'main',"
+                 " strftime('%s','now'))")
+    conn.execute("insert into projects (name, git_url, default_branch, created_at)"
+                 " values ('stacks-ref', 'https://example.com/ref.git', 'main',"
+                 " strftime('%s','now'))")
+    conn.commit()
+    codev_id = int(conn.execute(
+        "select id from projects where name = 'stacks-codev'").fetchone()[0])
+    ref_id = int(conn.execute(
+        "select id from projects where name = 'stacks-ref'").fetchone()[0])
+    # A third project linked to the project but AFTER goal creation —
+    # it must NOT propagate (the pre-fill is a one-time snapshot).
+    conn.execute("insert into projects (name, git_url, default_branch, created_at)"
+                 " values ('stacks-late', 'https://example.com/late.git', 'main',"
+                 " strftime('%s','now'))")
+    conn.commit()
+    late_id = int(conn.execute(
+        "select id from projects where name = 'stacks-late'").fetchone()[0])
+    conn.execute("insert into project_links (parent_project_id, linked_project_id,"
+                 " relationship) values (1, ?, 'co-developed')", (codev_id,))
+    conn.execute("insert into project_links (parent_project_id, linked_project_id,"
+                 " relationship) values (1, ?, 'reference')", (ref_id,))
+    conn.commit()
+    conn.close()
+
+    goal = create_goal({"project_id": "1", "title": "Prefill probe",
+                        "brief": "Check the link pre-fill.",
+                        "branch_name": "goal/prefill", "priority": "3"})
+    check(set(link_rows(goal)) == {(codev_id, "co-developed"), (ref_id, "reference")},
+          "a goal created on a linked project pre-fills goal_links from"
+          " project_links (same targets, same relationships)")
+    state = goal_state(goal)
+    check(sorted(link["name"] for link in state["links"]) == ["stacks-codev", "stacks-ref"],
+          "the goal page lists the pre-filled links")
+    check(state["links"][0].get("remove") is not None,
+          "the pre-filled links are prunable like manual ones")
+
+    # The late link must not propagate into the existing goal.
+    conn = db()
+    conn.execute("insert into project_links (parent_project_id, linked_project_id,"
+                 " relationship) values (1, ?, 'reference')", (late_id,))
+    conn.commit()
+    conn.close()
+    check(all(row[0] != late_id for row in link_rows(goal)),
+          "a project link added AFTER creation never propagates (snapshot)")
+
+    # A NEW goal created after the late link lands DOES carry it.
+    goal2 = create_goal({"project_id": "1", "title": "Prefill probe 2",
+                         "brief": "Snapshot at creation time.",
+                         "branch_name": "goal/prefill2", "priority": "3"})
+    check(set(link_rows(goal2)) == {(codev_id, "co-developed"),
+                                    (ref_id, "reference"),
+                                    (late_id, "reference")},
+          "a goal created after a new project link snapshots that link too")
+
+    # Self-links in the project graph are never copied (a project link
+    # to the goal's own primary is redundant — it is implicit).
+    conn = db()
+    conn.execute("insert into projects (name, git_url, default_branch, created_at)"
+                 " values ('stacks-self', 'https://example.com/self.git', 'main',"
+                 " strftime('%s','now'))")
+    conn.commit()
+    conn.close()
+    goal3 = create_goal({"project_id": "1", "title": "No self links",
+                         "brief": "The primary is implicit.",
+                         "branch_name": "goal/prefill3", "priority": "3"})
+    check(all(row[0] != 1 for row in link_rows(goal3)),
+          "the primary project is never duplicated as an explicit link")
+
+    print("== the pre-filled links ride the assignment payload ==")
+    # Removing one pre-filled link must remove it from the payload —
+    # goal_links stays the single source of truth after the snapshot.
+    state = goal_state(goal)
+    codev_link = [link for link in state["links"] if link["name"] == "stacks-codev"][0]
+    act(auth, codev_link["remove"], {"link_id": str(codev_link["id"])})
+    check(set(link_rows(goal)) == {(ref_id, "reference")},
+          "removing a pre-filled goal link removes the row")
+    fake.goal_script = [("suspend",)]
+    act(auth, goal_state(goal)["start"], {})
+    check(wait_for(lambda: next(
+        (True for row in db().execute(
+            "select id from tasks where goal_id = ? and kind = 'goal_run'",
+            (goal,)).fetchall()), False) or False,
+        timeout=30, what="goal run task creation") is not None,
+        "start creates the goal-run task")
+    conn = db()
+    run_task = int(conn.execute(
+        "select id from tasks where goal_id = ? and kind = 'goal_run'",
+        (goal,)).fetchone()[0])
+    conn.close()
+
+    def assigned_payload():
+        for payload in reversed(fake.goal_assignments):
+            if (payload.get("task") or {}).get("id") == run_task:
+                return payload
+        return None
+
+    check(wait_for(lambda: assigned_payload() is not None or False,
+                   timeout=60, what="goal run assignment") is not None,
+          "the fake saddle captures the goal-run assignment")
+    payload = assigned_payload()
+    codev_names = [p["name"] for p in payload["task"]["co_developed"]]
+    ref_names = [p["name"] for p in payload["task"]["reference"]]
+    check("demo" in codev_names and "stacks-codev" not in codev_names,
+          "the assignment payload's co-developed carries the primary and the"
+          " surviving pre-filled link (the removed one is gone)")
+    check(ref_names == ["stacks-ref"],
+          "the assignment payload's reference set comes from the goal's links")
+
+    print("== stacks: CRUD ==")
+    stacks_state = slot_public(auth, "/stacks", "stacks")
+    check(stacks_state.get("create") is not None and stacks_state["stacks"] == [],
+          "the stacks page starts empty with a create action")
+    act(auth, stacks_state["create"],
+        {"name": "web-stack-refs", "summary": "The usual reference collection"})
+    stacks = slot_public(auth, "/stacks", "stacks")["stacks"]
+    check(len(stacks) == 1 and stacks[0]["name"] == "web-stack-refs"
+          and int(stacks[0]["member_count"]) == 0,
+          "a stack lands in the list with its (empty) member count")
+    stack_id = int(stacks[0]["id"])
+
+    detail = stack_state(stack_id)
+    check(detail["exists"] and detail["name"] == "web-stack-refs"
+          and detail["members"] == [] and detail.get("update") is not None
+          and detail.get("add_member") is not None and detail.get("delete") is not None,
+          "the stack page exposes edit, add-member and delete actions")
+    check(any(option["name"] == "demo" for option in detail["options"]),
+          "every project is offerable as a stack member")
+
+    # Duplicate name refused.
+    dup = act(auth, slot_public(auth, "/stacks", "stacks")["create"],
+              {"name": "web-stack-refs"})
+    check(any(d.get("type") == "notify" and d.get("kind") == "error"
+              for d in dup),
+          "a duplicate stack name refuses")
+
+    # Rename + summary clear.
+    act(auth, detail["update"], {"name": "web-stack-refs-2", "summary": ""})
+    detail = stack_state(stack_id)
+    check(detail["name"] == "web-stack-refs-2" and detail["summary"] == "",
+          "a stack renames and its summary clears")
+
+    print("== stacks: members ==")
+    detail = stack_state(stack_id)
+    act(auth, detail["add_member"],
+        {"project_id": str(codev_id), "relationship": "co-developed"})
+    act(auth, stack_state(stack_id)["add_member"],
+        {"project_id": str(ref_id), "relationship": "reference"})
+    detail = stack_state(stack_id)
+    member_names = [(m["name"], m["relationship"]) for m in detail["members"]]
+    check(member_names == [("stacks-codev", "co-developed"),
+                           ("stacks-ref", "reference")],
+          "members land with their relationships")
+    dup = act(auth, stack_state(stack_id)["add_member"],
+              {"project_id": str(ref_id), "relationship": "reference"})
+    check(any(d.get("type") == "notify" and d.get("kind") == "error"
+              and "already" in (d.get("message") or "") for d in dup),
+          "adding the same project twice refuses")
+    bad = act(auth, stack_state(stack_id)["add_member"],
+              {"project_id": str(late_id), "relationship": "sponsor"})
+    check(any(d.get("type") == "notify" and d.get("kind") == "error"
+              for d in bad),
+          "an unknown relationship refuses")
+    detail = stack_state(stack_id)
+    check(len(detail["members"]) == 2,
+          "the refused additions left the stack untouched")
+
+    print("== stacks: apply to a project ==")
+    conn = db()
+    conn.execute("insert into projects (name, git_url, default_branch, created_at)"
+                 " values ('stacks-target', 'https://example.com/target.git', 'main',"
+                 " strftime('%s','now'))")
+    conn.commit()
+    conn.close()
+    target_id = int(db().execute(
+        "select id from projects where name = 'stacks-target'"
+    ).fetchone()[0])
+
+    project_state = slot_public(auth, "/projects/%d" % target_id, "project")
+    check(project_state.get("apply_stack") is not None
+          and any(opt["name"] == "web-stack-refs-2" for opt in project_state["stack_options"]),
+          "the project page offers Apply stack with the stacks as options")
+    result = act(auth, project_state["apply_stack"], {"stack_id": str(stack_id)})
+    check("2 link" in (action_notify(result) or ""),
+          "applying reports how many links landed")
+    check(set(project_link_rows(target_id)) == {(codev_id, "co-developed"),
+                                                (ref_id, "reference")},
+          "the stack's members became project_links rows")
+
+    # Re-apply: exact duplicates skipped, nothing duplicated.
+    result = act(auth, slot_public(auth, "/projects/%d" % target_id,
+                                   "project")["apply_stack"],
+                 {"stack_id": str(stack_id)})
+    check("nothing new" in (action_notify(result) or ""),
+          "re-applying skips exact duplicates")
+    check(set(project_link_rows(target_id)) == {(codev_id, "co-developed"),
+                                                (ref_id, "reference")},
+          "no duplicate project_links rows appear")
+
+    # A pre-existing DIFFERENT relationship on the same target is not a
+    # duplicate: applying adds the stack's own relationship alongside.
+    conn = db()
+    conn.execute("insert into project_links (parent_project_id, linked_project_id,"
+                 " relationship) values (?, ?, 'co-developed')",
+                 (target_id, ref_id))
+    conn.commit()
+    conn.close()
+    act(auth, slot_public(auth, "/projects/%d" % target_id, "project")["apply_stack"],
+        {"stack_id": str(stack_id)})
+    check((ref_id, "reference") in project_link_rows(target_id)
+          and project_link_rows(target_id).count((ref_id, "co-developed")) == 1,
+          "a different relationship on the same target coexists (exact pairs skip)")
+
+    print("== stacks: apply to a goal ==")
+    # A goal on a LINK-FREE host project — the pre-fill seeds nothing,
+    # so the stack's members are exactly what applying must land.
+    conn = db()
+    conn.execute("insert into projects (name, git_url, default_branch, created_at)"
+                 " values ('stacks-goalhost', 'https://example.com/goalhost.git',"
+                 " 'main', strftime('%s','now'))")
+    conn.commit()
+    conn.close()
+    host_id = int(db().execute(
+        "select id from projects where name = 'stacks-goalhost'").fetchone()[0])
+    goal4 = create_goal({"project_id": str(host_id), "title": "Stack target",
+                         "brief": "Apply a stack here.",
+                         "branch_name": "goal/stacktarget", "priority": "3"})
+    check(link_rows(goal4) == [],
+          "a goal on a link-free project pre-fills nothing")
+    gstate = goal_state(goal4)
+    check(gstate.get("apply_stack") is not None
+          and any(opt["name"] == "web-stack-refs-2" for opt in gstate["stack_options"]),
+          "the goal page offers Apply stack with the stacks as options")
+    result = act(auth, gstate["apply_stack"], {"stack_id": str(stack_id)})
+    check("2 link" in (action_notify(result) or ""),
+          "applying to the goal reports the links landed")
+    check(set(link_rows(goal4)) == {(codev_id, "co-developed"),
+                                    (ref_id, "reference")},
+          "the stack's members became goal_links rows")
+
+    # Re-apply: idempotent.
+    result = act(auth, goal_state(goal4)["apply_stack"], {"stack_id": str(stack_id)})
+    check("nothing new" in (action_notify(result) or ""),
+          "re-applying to the goal skips exact duplicates")
+    check(set(link_rows(goal4)) == {(codev_id, "co-developed"),
+                                    (ref_id, "reference")},
+          "no duplicate goal_links rows appear")
+
+    # Removing a stack member then re-applying adds only the newcomer.
+    detail = stack_state(stack_id)
+    ref_view = [m for m in detail["members"]
+                if int(m["project_id"]) == ref_id][0]
+    act(auth, ref_view["remove"], {})
+    check(len(stack_state(stack_id)["members"]) == 1,
+          "removing a member shrinks the stack")
+    act(auth, goal_state(goal4)["apply_stack"], {"stack_id": str(stack_id)})
+    check(set(link_rows(goal4)) == {(codev_id, "co-developed"),
+                                    (ref_id, "reference")},
+          "removing a stack member never unlinks what was already applied")
+
+    print("== stacks: delete ==")
+    conn = db()
+    act(auth, slot_public(auth, "/stacks/%d" % stack_id, "stack")["delete"], {})
+    conn.close()
+    check(slot_public(auth, "/stacks", "stacks")["stacks"] == [],
+          "deleting a stack empties the list")
+    conn = db()
+    leftover = conn.execute("select count(*) from stack_projects").fetchone()[0]
+    conn.close()
+    check(leftover == 0, "the stack's member rows die with it")
+    check(set(link_rows(goal4)) == {(codev_id, "co-developed"),
+                                    (ref_id, "reference")},
+          "deleting a stack keeps every link it already applied")

+ 1 - 1
test/harness.py

@@ -1314,7 +1314,7 @@ def start_bundle(name):
 
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
                  "allocation", "lifecycle", "answers", "push", "goals",
-                 "replay", "goalui", "forcewake", "users")
+                 "replay", "goalui", "forcewake", "users", "stacks")
 
 
 def run_bundle(name):