diff --git a/engine/app/controllers/coplan/api/v1/plans_controller.rb b/engine/app/controllers/coplan/api/v1/plans_controller.rb index a00ebbe..85e6bde 100644 --- a/engine/app/controllers/coplan/api/v1/plans_controller.rb +++ b/engine/app/controllers/coplan/api/v1/plans_controller.rb @@ -34,13 +34,8 @@ def show def create if params[:plan_type].present? - plan_type = PlanType.find_by_name(params[:plan_type]) - unless plan_type - available = PlanType.order(:name).pluck(:name) - message = "Unknown plan_type \"#{params[:plan_type]}\"." - message += " Available types: #{available.map { |n| "\"#{n}\"" }.join(", ")}." if available.any? - return render json: { error: message }, status: :unprocessable_content - end + plan_type = resolve_plan_type_param + return if performed? # resolve_plan_type_param rendered an error end # Plans are born published; `"visibility": "draft"` is the opt-in @@ -117,11 +112,19 @@ def update return if performed? # visibility_params_for_update rendered an error permitted.merge!(visibility_updates) + if params.key?(:plan_type) + new_plan_type = resolve_plan_type_param + return if performed? # resolve_plan_type_param rendered an error + permitted[:plan_type] = new_plan_type + end + # Snapshot before-state so LogEvent can record meaningful diffs. old_title = @plan.title old_visibility = @plan.visibility old_archived = @plan.archived? old_tag_names = @plan.tag_names + old_plan_type = @plan.plan_type + tags_changed_by_retype = false # Folder resolution (which may create folders via folder_path in # the caller's library), the placement move, and the plan update @@ -141,9 +144,28 @@ def update @plan.tag_names = params[:tags] if params.key?(:tags) @plan.update!(permitted) + + # A retype adopts the new type's default_tags (union — existing + # tags are never removed), mirroring what create does. After + # update! so an invalid update never writes tags. + if new_plan_type && new_plan_type != old_plan_type + merged_tags = @plan.tag_names | new_plan_type.default_tags.to_a + if merged_tags != @plan.tag_names + @plan.tag_names = merged_tags + tags_changed_by_retype = true + end + end end return if performed? # placement error rendered inside the transaction + if new_plan_type && @plan.saved_change_to_plan_type_id? + Plans::LogEvent.call( + plan: @plan, actor: current_user, event_type: "plan_type_changed", + before: old_plan_type&.name, after: new_plan_type.name, + actor_type: api_author_type, actor_id: api_user_id, agent_name: api_agent_name, api_token_id: api_token_id + ) + end + if @plan.saved_changes? Broadcaster.replace_to(@plan, target: "plan-header", partial: "coplan/plans/header", locals: { plan: @plan }) end @@ -179,7 +201,7 @@ def update ) end - if params.key?(:tags) + if params.key?(:tags) || tags_changed_by_retype new_tag_names = @plan.tag_names (new_tag_names - old_tag_names).each do |added| Plans::LogEvent.call( @@ -328,6 +350,25 @@ def visibility_params_for_update updates end + # Resolves the `plan_type` param (a type name, case-insensitive) to a + # PlanType. Every plan has a type, so a blank or unknown name is a + # 422 listing the valid names — the error is the agent's discovery + # path when it guesses. Renders and returns nil on bad input. + def resolve_plan_type_param + plan_type = PlanType.find_by_name(params[:plan_type]) if params[:plan_type].present? + return plan_type if plan_type + + available = PlanType.order(:name).pluck(:name) + message = if params[:plan_type].present? + "Unknown plan_type \"#{params[:plan_type]}\"." + else + "plan_type cannot be blank — every plan has a type." + end + message += " Available types: #{available.map { |n| "\"#{n}\"" }.join(", ")}." if available.any? + render json: { error: message }, status: :unprocessable_content + nil + end + # Resolves `folder_id` / `folder_path` update params to a Folder (or # nil to unfile). `folder_path` finds-or-creates the hierarchy in the # caller's own library, which is what lets an agent organize a diff --git a/engine/app/views/coplan/agent_instructions/show.text.erb b/engine/app/views/coplan/agent_instructions/show.text.erb index b7fa8ce..ce5d615 100644 --- a/engine/app/views/coplan/agent_instructions/show.text.erb +++ b/engine/app/views/coplan/agent_instructions/show.text.erb @@ -152,7 +152,7 @@ Update plan metadata (title, visibility, tags). Only fields included in the requ "<%= @base %>/api/v1/plans/$PLAN_ID" | jq . ``` -Allowed fields: `title` (string), `visibility` (`"published"` — see [Visibility & Archiving](#visibility--archiving)), `archived` (boolean), `tags` (array of strings), `references` (array — see [References](#references) below). +Allowed fields: `title` (string), `visibility` (`"published"` — see [Visibility & Archiving](#visibility--archiving)), `archived` (boolean), `plan_type` (string — retype the plan; unknown names get a 422 listing the valid ones, and the new type's `default_tags` are added), `tags` (array of strings), `references` (array — see [References](#references) below). ### List Tags @@ -257,6 +257,7 @@ Returns every type with `name`, `description`, `default_tags`, and `template_con - Pick the **most specific** type that fits before creating a plan. Plans created without an explicit type get **General** — acceptable only when you've reviewed the list and nothing fits. - **Read the type's `template_content` and structure your document against it** — keep its sections, fill them with substance, drop a section only when it's genuinely inapplicable. The template is the type's contract with its readers. - The type's `default_tags` are applied automatically on create; add your own on top with `tags`. +- **Retype a plan when it outgrows its type** — a brainstorm that firmed up into a real design, say — with `PATCH /api/v1/plans/:id` and `{"plan_type": "..."}`. Restructure the content against the new type's template in the same session. <% if @plan_types.any? %> **Available plan types:** diff --git a/spec/requests/api/v1/plans_spec.rb b/spec/requests/api/v1/plans_spec.rb index ae5b548..6ad482c 100644 --- a/spec/requests/api/v1/plans_spec.rb +++ b/spec/requests/api/v1/plans_spec.rb @@ -172,6 +172,64 @@ end end + describe "retyping via update" do + let!(:scratchpad) { create(:plan_type, name: "Scratchpad", default_tags: []) } + let!(:design) { create(:plan_type, name: "Engineering Design", default_tags: ["design"]) } + + it "changes the plan's type, adopts default_tags, and logs events" do + typed_plan = create(:plan, :considering, created_by_user: alice, plan_type: scratchpad) + + patch api_v1_plan_path(typed_plan), params: { plan_type: "engineering design" }, headers: headers, as: :json + + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + expect(body["plan_type_name"]).to eq("Engineering Design") + expect(body["tags"]).to include("design") + + type_event = typed_plan.plan_events.find_by(event_type: "plan_type_changed") + expect(type_event.before_value).to eq("Scratchpad") + expect(type_event.after_value).to eq("Engineering Design") + expect(typed_plan.plan_events.where(event_type: "tag_added", after_value: "design")).to exist + end + + it "keeps existing tags on retype" do + typed_plan = create(:plan, :considering, created_by_user: alice, plan_type: scratchpad) + typed_plan.tag_names = ["pricing"] + + patch api_v1_plan_path(typed_plan), params: { plan_type: "Engineering Design" }, headers: headers, as: :json + + expect(JSON.parse(response.body)["tags"]).to match_array(["pricing", "design"]) + end + + it "is a no-op event-wise when the type is unchanged" do + typed_plan = create(:plan, :considering, created_by_user: alice, plan_type: design) + + patch api_v1_plan_path(typed_plan), params: { plan_type: "Engineering Design" }, headers: headers, as: :json + + expect(response).to have_http_status(:success) + expect(typed_plan.plan_events.where(event_type: "plan_type_changed")).not_to exist + end + + it "rejects an unknown plan_type with the valid names" do + typed_plan = create(:plan, :considering, created_by_user: alice, plan_type: scratchpad) + + patch api_v1_plan_path(typed_plan), params: { plan_type: "nope" }, headers: headers, as: :json + + expect(response).to have_http_status(:unprocessable_content) + expect(JSON.parse(response.body)["error"]).to include("Scratchpad") + expect(typed_plan.reload.plan_type).to eq(scratchpad) + end + + it "rejects a blank plan_type — every plan has a type" do + typed_plan = create(:plan, :considering, created_by_user: alice, plan_type: scratchpad) + + patch api_v1_plan_path(typed_plan), params: { plan_type: "" }, headers: headers, as: :json + + expect(response).to have_http_status(:unprocessable_content) + expect(JSON.parse(response.body)["error"]).to include("cannot be blank") + end + end + describe "tags on create" do it "applies the plan type's default_tags" do create(:plan_type, name: "design-doc", default_tags: ["design", "architecture"])