Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions app/admin/library_events.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
ActiveAdmin.register CoPlan::LibraryEvent, as: "LibraryEvent" do
actions :index, :show

index do
selectable_column
id_column
column :library
column :event_type
column :before_value
column :after_value
column :actor_type
column :actor_user
column :created_at
actions
end

filter :library
filter :event_type, as: :select, collection: CoPlan::LibraryEvent::EVENT_TYPES
filter :actor_type, as: :select, collection: CoPlan::LibraryEvent::ACTOR_TYPES
filter :created_at

show do
attributes_table do
row :id
row :library
row :event_type
row :plan_id
row :folder_id
row :before_value
row :after_value
row :actor_type
row :actor_user
row :metadata
row :created_at
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# This migration comes from co_plan (originally 20260813000000)
class AddDescriptionToCoplanFolders < ActiveRecord::Migration[8.1]
def change
# A short human/agent-readable statement of what belongs in the folder
# (e.g. "Active Q3 work — move to Done when shipped"). Surfaced in the
# library overview API so agents can organize by meaning, not just name.
add_column :coplan_folders, :description, :string, limit: 255
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# This migration comes from co_plan (originally 20260813000001)
class CreateCoplanLibraryEvents < ActiveRecord::Migration[8.1]
def change
# Append-only audit log for a library's organization: who filed/moved/
# removed which plan, and who created/renamed/moved/deleted folders —
# with actor_type distinguishing humans from agents. Mirrors
# coplan_plan_events, but scoped to the library (the shelf), not the
# plan (the document).
#
# plan_id / folder_id are deliberately not foreign keys: audit rows must
# survive the deletion of what they describe. Paths and titles are
# denormalized into before/after/metadata so the log stays readable.
create_table :coplan_library_events, id: { type: :string, limit: 36 } do |t|
t.string :library_id, limit: 36, null: false
t.string :actor_id, limit: 36
t.string :actor_type, null: false
t.string :event_type, null: false
t.string :plan_id, limit: 36
t.string :folder_id, limit: 36
# Groups every event applied by one bulk organize call, so a
# 2,000-move run reads as one entry point in the log, not noise.
t.string :run_id, limit: 36
t.text :before_value
t.text :after_value
t.json :metadata
t.datetime :created_at, null: false

t.index [ :library_id, :created_at ]
t.index :plan_id
t.index :event_type
t.index :run_id
end

add_foreign_key :coplan_library_events, :coplan_libraries, column: :library_id
end
end
57 changes: 56 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions engine/app/controllers/coplan/agent_instructions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ def show
end
end

# Sub-instructions: the library-organizing guide, linked from the main
# doc and from library API responses. Markdown-only — it's fetched by
# agents mid-task, not browsed by humans (the main doc has the pretty
# HTML front door).
def organizing
@auth_instructions = CoPlan.configuration.agent_auth_instructions
@curl = CoPlan.configuration.agent_curl_prefix
@base = "#{request.base_url}#{root_path.chomp("/")}"
render layout: false, content_type: "text/markdown", formats: [:text]
end

private

def prefers_html?
Expand Down
43 changes: 43 additions & 0 deletions engine/app/controllers/coplan/api/v1/folders_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,15 @@ def create

folder = Folder.create!(
name: params[:name],
description: params[:description],
parent: parent,
library: library,
created_by_user: current_user
)
Libraries::LogEvent.call(
library: library, actor: current_user, actor_type: api_author_type,
event_type: "folder_created", folder: folder, after: folder.path
)
render json: folder_json(folder), status: :created
rescue ActiveRecord::RecordInvalid => e
render json: { error: e.record.errors.full_messages.join(", ") }, status: :unprocessable_content
Expand All @@ -59,6 +64,7 @@ def update

attrs = {}
attrs[:name] = params[:name] if params.key?(:name)
attrs[:description] = params[:description] if params.key?(:description)
if params.key?(:parent_id)
if params[:parent_id].present?
parent = @folder.library.folders.find_by(id: params[:parent_id])
Expand All @@ -69,7 +75,10 @@ def update
end
end

old_path = @folder.path
old_description = @folder.description
@folder.update!(attrs)
log_folder_update(old_path, old_description)
render json: folder_json(@folder)
rescue ActiveRecord::RecordInvalid => e
render json: { error: e.record.errors.full_messages.join(", ") }, status: :unprocessable_content
Expand All @@ -81,7 +90,13 @@ def destroy
return render json: { error: "Not authorized" }, status: :forbidden
end

path = @folder.path
if @folder.destroy
Libraries::LogEvent.call(
library: @folder.library, actor: current_user, actor_type: api_author_type,
event_type: "folder_deleted", before: path,
metadata: { folder_name: @folder.name }
)
head :no_content
else
render json: { error: @folder.errors.full_messages.join(", ") }, status: :unprocessable_content
Expand All @@ -95,13 +110,41 @@ def set_folder
render json: { error: "Folder not found" }, status: :not_found unless @folder
end

# One update call can rename, move, and re-describe at once — log
# each change as its own audit event so the library log stays
# readable ("renamed A → B", not "something about this folder").
def log_folder_update(old_path, old_description)
new_path = @folder.path
if @folder.saved_change_to_name?
Libraries::LogEvent.call(
library: @folder.library, actor: current_user, actor_type: api_author_type,
event_type: "folder_renamed", folder: @folder, before: old_path, after: new_path
)
end
if @folder.saved_change_to_parent_id?
Libraries::LogEvent.call(
library: @folder.library, actor: current_user, actor_type: api_author_type,
event_type: "folder_moved", folder: @folder, before: old_path, after: new_path
)
end
if @folder.saved_change_to_description?
Libraries::LogEvent.call(
library: @folder.library, actor: current_user, actor_type: api_author_type,
event_type: "folder_described", folder: @folder,
before: old_description, after: @folder.description,
metadata: { path: new_path }
)
end
end

# `paths` and `counts` let index serialize the whole tree without
# per-folder queries. `plans_count` is the folder's own visible
# placements (not including subfolders).
def folder_json(folder, paths: nil, counts: nil)
{
id: folder.id,
name: folder.name,
description: folder.description,
library_id: folder.library_id,
parent_id: folder.parent_id,
path: paths ? paths[folder.id] : folder.path,
Expand Down
Loading
Loading