collavre 0.20.2 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/actiontext.css +92 -2
  3. data/app/assets/stylesheets/collavre/code_highlight.css +144 -26
  4. data/app/assets/stylesheets/collavre/comments_popup.css +83 -0
  5. data/app/assets/stylesheets/collavre/landing.css +507 -0
  6. data/app/channels/collavre/comments_presence_channel.rb +7 -0
  7. data/app/controllers/collavre/admin/integrations_controller.rb +82 -0
  8. data/app/controllers/collavre/admin/settings_controller.rb +22 -17
  9. data/app/controllers/collavre/application_controller.rb +27 -0
  10. data/app/controllers/collavre/channels_controller.rb +23 -0
  11. data/app/controllers/collavre/creatives_controller.rb +50 -6
  12. data/app/controllers/collavre/landing_controller.rb +8 -0
  13. data/app/controllers/collavre/passwords_controller.rb +1 -0
  14. data/app/controllers/collavre/public_assets_controller.rb +24 -0
  15. data/app/controllers/collavre/topics_controller.rb +21 -30
  16. data/app/helpers/collavre/comments_helper.rb +7 -0
  17. data/app/helpers/collavre/public_assets_helper.rb +14 -0
  18. data/app/javascript/controllers/comment_controller.js +9 -0
  19. data/app/javascript/controllers/comments/form_controller.js +4 -0
  20. data/app/javascript/controllers/comments/list_controller.js +10 -7
  21. data/app/javascript/controllers/comments/popup_controller.js +9 -0
  22. data/app/javascript/controllers/comments/presence_controller.js +83 -1
  23. data/app/javascript/controllers/comments/topics_controller.js +15 -0
  24. data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
  25. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
  26. data/app/javascript/controllers/creatives/sync_controller.js +30 -9
  27. data/app/javascript/controllers/creatives/tree_controller.js +23 -0
  28. data/app/javascript/controllers/index.js +4 -1
  29. data/app/javascript/controllers/landing_video_controller.js +53 -0
  30. data/app/javascript/creatives/tree_renderer.js +6 -0
  31. data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
  32. data/app/javascript/lib/api/queue_manager.js +17 -5
  33. data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
  34. data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
  35. data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
  36. data/app/javascript/modules/command_args_form.js +22 -4
  37. data/app/javascript/modules/command_menu.js +27 -0
  38. data/app/javascript/modules/creative_row_editor.js +227 -17
  39. data/app/javascript/modules/html_content_empty.js +12 -0
  40. data/app/javascript/modules/markdown_source_reconcile.js +53 -0
  41. data/app/jobs/collavre/drop_trigger_job.rb +37 -8
  42. data/app/mailers/collavre/application_mailer.rb +1 -1
  43. data/app/models/collavre/channel/injected_message.rb +5 -0
  44. data/app/models/collavre/channel.rb +87 -0
  45. data/app/models/collavre/creative/describable.rb +65 -3
  46. data/app/models/collavre/creative.rb +2 -0
  47. data/app/models/collavre/integration_setting.rb +35 -0
  48. data/app/models/collavre/preview_channel.rb +93 -0
  49. data/app/models/collavre/system_setting.rb +13 -2
  50. data/app/models/collavre/topic.rb +3 -25
  51. data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
  52. data/app/services/collavre/ai_client.rb +3 -3
  53. data/app/services/collavre/channel_attacher.rb +58 -0
  54. data/app/services/collavre/comments/mcp_command.rb +31 -1
  55. data/app/services/collavre/creatives/tree_builder.rb +7 -3
  56. data/app/services/collavre/google_calendar_service.rb +4 -2
  57. data/app/services/collavre/markdown_converter.rb +130 -15
  58. data/app/services/collavre/markdown_importer.rb +7 -2
  59. data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
  60. data/app/services/collavre/tools/creative_attach_files_service.rb +96 -0
  61. data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
  62. data/app/services/collavre/tools/creative_remove_attachment_service.rb +35 -0
  63. data/app/services/collavre/tools/permission_denied_error.rb +9 -0
  64. data/app/services/collavre/tools/preview_attach_service.rb +128 -0
  65. data/app/services/collavre/tools/preview_detach_service.rb +61 -0
  66. data/app/services/collavre/tools/topic_authorizer.rb +24 -0
  67. data/app/services/collavre/topic_branch_service.rb +34 -26
  68. data/app/views/admin/shared/_tabs.html.erb +1 -0
  69. data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
  70. data/app/views/collavre/admin/integrations/_setting_row.html.erb +54 -0
  71. data/app/views/collavre/admin/integrations/index.html.erb +42 -0
  72. data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
  73. data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
  74. data/app/views/collavre/comments/_comment.html.erb +6 -1
  75. data/app/views/collavre/comments/_comments_popup.html.erb +1 -0
  76. data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
  77. data/app/views/collavre/creatives/index.html.erb +10 -2
  78. data/app/views/collavre/landing/show.html.erb +130 -0
  79. data/app/views/layouts/collavre/landing.html.erb +33 -0
  80. data/config/locales/admin.en.yml +4 -2
  81. data/config/locales/admin.ko.yml +4 -2
  82. data/config/locales/channels.en.yml +11 -0
  83. data/config/locales/channels.ko.yml +11 -0
  84. data/config/locales/comments.en.yml +2 -0
  85. data/config/locales/comments.ko.yml +2 -0
  86. data/config/locales/creatives.en.yml +9 -0
  87. data/config/locales/creatives.ko.yml +8 -0
  88. data/config/locales/integrations.en.yml +44 -0
  89. data/config/locales/integrations.ko.yml +44 -0
  90. data/config/locales/landing.en.yml +51 -0
  91. data/config/locales/landing.ko.yml +51 -0
  92. data/config/routes.rb +18 -0
  93. data/db/migrate/20260526000000_create_channels.rb +42 -0
  94. data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
  95. data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
  96. data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
  97. data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
  98. data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
  99. data/db/seeds.rb +19 -0
  100. data/lib/collavre/aws_credentials.rb +75 -0
  101. data/lib/collavre/engine.rb +51 -0
  102. data/lib/collavre/integration_settings/key_definition.rb +29 -0
  103. data/lib/collavre/integration_settings/registry.rb +55 -0
  104. data/lib/collavre/integration_settings/resolver.rb +71 -0
  105. data/lib/collavre/integration_settings.rb +46 -0
  106. data/lib/collavre/ses_settings_interceptor.rb +72 -0
  107. data/lib/collavre/version.rb +1 -1
  108. data/lib/collavre.rb +3 -0
  109. metadata +52 -1
@@ -132,6 +132,12 @@ en:
132
132
  metadata_tooltip: Edit metadata
133
133
  metadata_title: Metadata
134
134
  metadata_save: Save
135
+ toggle_markdown: Markdown
136
+ toggle_richtext: Rich Text
137
+ markdown_to_richtext_confirm: Switching to Rich Text will discard the Markdown
138
+ source. Continue?
139
+ richtext_to_markdown_confirm: Switching to Markdown will discard the current
140
+ rich text content. Continue?
135
141
  share:
136
142
  shared: Creative shared successfully.
137
143
  permission_updated: Permission updated successfully.
@@ -147,8 +153,11 @@ en:
147
153
  progress_recalculated: All parent progress recalculated.
148
154
  errors:
149
155
  no_permission: You do not have permission to perform this action.
156
+ metadata_must_be_object: Metadata must be an object.
150
157
  inline_editor:
151
158
  placeholder: Describe the creative…
159
+ markdown_placeholder: Write in Markdown…
160
+ markdown_preview_placeholder: Preview
152
161
  edit_title: Edit creative
153
162
  edit:
154
163
  inline_editor_only_html: Editing creatives now happens directly in the inline
@@ -120,6 +120,11 @@ ko:
120
120
  metadata_tooltip: 메타데이터 편집
121
121
  metadata_title: 메타데이터
122
122
  metadata_save: 저장
123
+ toggle_markdown: 마크다운
124
+ toggle_richtext: 서식 편집
125
+ markdown_to_richtext_confirm: 서식 편집으로 전환하면 마크다운 원본이 삭제됩니다.
126
+ 계속하시겠습니까?
127
+ richtext_to_markdown_confirm: 마크다운으로 전환하면 현재 서식 편집 내용이 삭제됩니다. 계속하시겠습니까?
123
128
  share:
124
129
  shared: 크리에이티브가 성공적으로 공유되었습니다.
125
130
  permission_updated: 권한이 성공적으로 변경되었습니다.
@@ -134,8 +139,11 @@ ko:
134
139
  progress_recalculated: 진행률을 다시 계산 했습니다.
135
140
  errors:
136
141
  no_permission: 이 작업을 수행할 권한이 없습니다.
142
+ metadata_must_be_object: 메타데이터는 객체여야 합니다.
137
143
  inline_editor:
138
144
  placeholder: 크리에이티브를 설명해주세요…
145
+ markdown_placeholder: 마크다운으로 작성…
146
+ markdown_preview_placeholder: 미리보기
139
147
  edit_title: 크리에이티브 수정
140
148
  edit:
141
149
  inline_editor_only_html: 크리에이티브 수정은 인라인 편집기에서 바로 진행하세요.
@@ -0,0 +1,44 @@
1
+ en:
2
+ collavre:
3
+ admin:
4
+ integrations:
5
+ title: "Integrations"
6
+ subtitle: "Configure external integration secrets. Database values take precedence over ENV; ENV is the seed/fallback."
7
+ tab_label: "Integrations"
8
+ empty_state: "No integrations registered yet."
9
+ sensitive: "(sensitive)"
10
+ restart_required: "restart required"
11
+ placeholder_leave_blank: "Leave blank to keep current"
12
+ env_var_label: "ENV"
13
+ confirm_reset: "Delete the DB value and fall back to ENV?"
14
+ headers:
15
+ key: "Key"
16
+ value: "Value"
17
+ actions: "Actions"
18
+ source:
19
+ db: "DB"
20
+ env: "ENV"
21
+ default: "default"
22
+ unknown: "unknown"
23
+ category:
24
+ slack: "Slack"
25
+ google_oauth: "Google OAuth"
26
+ github_oauth: "GitHub OAuth"
27
+ notion_oauth: "Notion OAuth"
28
+ github: "GitHub Integration"
29
+ aws_s3: "AWS S3"
30
+ aws_ses: "AWS SES"
31
+ firebase: "Firebase"
32
+ fcm: "FCM"
33
+ gemini: "Gemini"
34
+ llm: "LLM API Keys"
35
+ openclaw: "OpenClaw Gateway"
36
+ mail: "Mail / Host"
37
+ misc: "Miscellaneous"
38
+ actions:
39
+ save: "Save changes"
40
+ reset: "Reset to ENV"
41
+ flash:
42
+ updated: "Integration settings saved."
43
+ restart_required: "Some changed keys require a server restart to take effect."
44
+ reset_done: "Reset %{key} to ENV/default."
@@ -0,0 +1,44 @@
1
+ ko:
2
+ collavre:
3
+ admin:
4
+ integrations:
5
+ title: "통합"
6
+ subtitle: "외부 통합 비밀값을 설정합니다. DB 값이 ENV보다 우선하며, ENV는 시드/폴백입니다."
7
+ tab_label: "통합"
8
+ empty_state: "등록된 통합이 없습니다."
9
+ sensitive: "(민감)"
10
+ restart_required: "재시작 필요"
11
+ placeholder_leave_blank: "비워두면 현재 값 유지"
12
+ env_var_label: "환경변수"
13
+ confirm_reset: "DB 값을 삭제하고 ENV로 폴백할까요?"
14
+ headers:
15
+ key: "키"
16
+ value: "값"
17
+ actions: "작업"
18
+ source:
19
+ db: "DB"
20
+ env: "ENV"
21
+ default: "기본값"
22
+ unknown: "알 수 없음"
23
+ category:
24
+ slack: "Slack"
25
+ google_oauth: "Google OAuth"
26
+ github_oauth: "GitHub OAuth"
27
+ notion_oauth: "Notion OAuth"
28
+ github: "GitHub 연동"
29
+ aws_s3: "AWS S3"
30
+ aws_ses: "AWS SES"
31
+ firebase: "Firebase"
32
+ fcm: "FCM"
33
+ gemini: "Gemini"
34
+ llm: "LLM API 키"
35
+ openclaw: "OpenClaw 게이트웨이"
36
+ mail: "메일 / 호스트"
37
+ misc: "기타"
38
+ actions:
39
+ save: "변경사항 저장"
40
+ reset: "ENV로 초기화"
41
+ flash:
42
+ updated: "통합 설정이 저장되었습니다."
43
+ restart_required: "변경된 일부 키는 서버 재시작 후에 적용됩니다."
44
+ reset_done: "%{key}을(를) ENV/기본값으로 초기화했습니다."
@@ -0,0 +1,51 @@
1
+ en:
2
+ collavre:
3
+ landing:
4
+ meta:
5
+ title: "Where Teams Think Together"
6
+ description: "The collaborative workspace where hierarchical creatives, AI agents, and real-time chat come together. Organize, collaborate, and ship — with AI as your teammate."
7
+ hero:
8
+ headline_html: "A platform for both AI agents and people."
9
+ subline: "Achieve sustainable productivity gains."
10
+ learn_more: "Learn More"
11
+ demo:
12
+ title: "See it in action"
13
+ placeholder: "Product demo video goes here"
14
+ problem:
15
+ title: "Your tools are failing you"
16
+ notion:
17
+ title: "Notion"
18
+ desc: "Docs become graveyards. Nobody edits someone else's page. Search replaces structure."
19
+ jira:
20
+ title: "Jira"
21
+ desc: "Great for tasks, terrible for knowledge. Results never sync back to the wiki. Information overload."
22
+ slack:
23
+ title: "Slack"
24
+ desc: "Conversations vanish in the stream. Flat channel lists explode. Context is lost daily."
25
+ features:
26
+ title: "Everything in one living tree"
27
+ subtitle: "Creatives are hierarchical blocks that organize your work, track progress, and carry context — all at once."
28
+ tree:
29
+ title: "Hierarchical Creatives"
30
+ desc: "Organize work as a tree. Drag, link, and reuse blocks across projects. Progress rolls up automatically."
31
+ ai:
32
+ title: "AI Agents as Teammates"
33
+ desc: "Add AI agents to any conversation. They review, collaborate, and respond to @mentions — just like a colleague."
34
+ context:
35
+ title: "Context System"
36
+ desc: "Reduce cognitive load for people, and inject the knowledge and skills AI needs."
37
+ chat:
38
+ title: "Real-time Chat & Topics"
39
+ desc: "Every creative has a chat. Organize conversations with topics, move messages between creatives, and keep discussions focused."
40
+ integrations:
41
+ title: "Integrations"
42
+ desc: "GitHub PRs auto-analyzed, Slack messages synced, Google Calendar events linked, MCP tools built-in."
43
+ progress:
44
+ title: "Live Progress"
45
+ desc: "Track completion from leaf to root. Filter by tags, plans, or status. See your project's real health at a glance."
46
+ cta:
47
+ title: "Ready to collaborate smarter?"
48
+ subtitle: "Start organizing your work with AI-powered context."
49
+ start: "Get Started"
50
+ footer:
51
+ rights: "All rights reserved."
@@ -0,0 +1,51 @@
1
+ ko:
2
+ collavre:
3
+ landing:
4
+ meta:
5
+ title: "팀이 함께 생각하는 곳"
6
+ description: "계층형 크리에이티브, AI 에이전트, 실시간 채팅이 하나로. 체계적으로 정리하고, 협업하고, AI와 함께 완성하세요."
7
+ hero:
8
+ headline_html: "AI 에이전트와 사람 모두의 플랫폼."
9
+ subline: "지속 가능한 생산성 향상을 이루세요."
10
+ learn_more: "알아보기"
11
+ demo:
12
+ title: "직접 확인하세요"
13
+ placeholder: "실제 작동 화면이 들어갈 자리입니다"
14
+ problem:
15
+ title: "기존 도구의 한계"
16
+ notion:
17
+ title: "Notion"
18
+ desc: "문서는 무덤이 됩니다. 남의 페이지를 고치기 꺼려하고, 검색이 구조를 대체합니다."
19
+ jira:
20
+ title: "Jira"
21
+ desc: "작업 관리는 좋지만 지식 관리는 안 됩니다. 결과물이 위키로 돌아오지 않습니다."
22
+ slack:
23
+ title: "Slack"
24
+ desc: "대화가 흘러가 버립니다. 채널은 폭발적으로 늘어나고, 맥락은 매일 사라집니다."
25
+ features:
26
+ title: "하나의 살아있는 트리로"
27
+ subtitle: "크리에이티브는 작업을 정리하고, 진행률을 추적하며, 맥락을 전달하는 계층형 블록입니다."
28
+ tree:
29
+ title: "계층형 크리에이티브"
30
+ desc: "작업을 트리로 구성하세요. 드래그, 링크, 재사용이 자유롭고 진행률은 자동 계산됩니다."
31
+ ai:
32
+ title: "AI 에이전트 팀원"
33
+ desc: "AI 에이전트를 대화에 추가하세요. 리뷰, 협업, @멘션 응답 — 동료처럼 일합니다."
34
+ context:
35
+ title: "컨텍스트 시스템"
36
+ desc: "사람의 인지 부하를 줄이고, AI에게 필요한 지식과 Skill을 주입합니다."
37
+ chat:
38
+ title: "실시간 채팅 & 토픽"
39
+ desc: "모든 크리에이티브에 채팅이 있습니다. 토픽으로 대화를 정리하고, 메시지를 다른 크리에이티브로 옮기세요."
40
+ integrations:
41
+ title: "외부 연동"
42
+ desc: "GitHub PR 자동 분석, Slack 동기화, Google Calendar 연동, MCP 도구 내장."
43
+ progress:
44
+ title: "실시간 진행률"
45
+ desc: "리프부터 루트까지 완료율 추적. 태그, 계획, 상태별 필터. 프로젝트 건강도를 한눈에."
46
+ cta:
47
+ title: "더 똑똑하게 협업할 준비 되셨나요?"
48
+ subtitle: "AI 기반 컨텍스트로 작업을 정리하세요."
49
+ start: "시작하기"
50
+ footer:
51
+ rights: "모든 권리 보유."
data/config/routes.rb CHANGED
@@ -1,4 +1,7 @@
1
1
  Collavre::Engine.routes.draw do
2
+ # Landing page
3
+ get "landing", to: "landing#show"
4
+
2
5
  # Authentication routes
3
6
  resource :session, only: [ :new, :create, :destroy ]
4
7
  resources :passwords, param: :token, only: [ :new, :create, :edit, :update ]
@@ -28,8 +31,13 @@ Collavre::Engine.routes.draw do
28
31
  match "/auth/google_oauth2/callback", to: "google_auth#callback", via: [ :get, :post ]
29
32
 
30
33
  delete "/attachments/:signed_id", to: "attachments#destroy", as: :attachment
34
+ get "/public-assets/blobs/:signed_id/*filename",
35
+ to: "public_assets#show",
36
+ as: :public_asset,
37
+ format: false
31
38
 
32
39
  resources :calendar_events, only: [ :destroy ]
40
+ resources :channels, only: [ :destroy ]
33
41
  resources :contacts, only: [ :destroy ]
34
42
  resources :devices, only: [ :create ]
35
43
 
@@ -58,6 +66,7 @@ Collavre::Engine.routes.draw do
58
66
  get :next_name
59
67
  end
60
68
  member do
69
+ get :channel_chips
61
70
  patch :move
62
71
  patch :archive
63
72
  patch :unarchive
@@ -134,5 +143,14 @@ Collavre::Engine.routes.draw do
134
143
  get "/uiux", to: "admin/settings#uiux", as: :uiux
135
144
  patch "/uiux", to: "admin/settings#update_uiux"
136
145
  resource :orchestration, only: [ :show, :update ], controller: "admin/orchestration"
146
+
147
+ resources :integrations, only: [ :index ], param: :key, controller: "admin/integrations" do
148
+ collection do
149
+ patch :bulk_update
150
+ end
151
+ member do
152
+ delete :reset
153
+ end
154
+ end
137
155
  end
138
156
  end
@@ -0,0 +1,42 @@
1
+ class CreateChannels < ActiveRecord::Migration[8.1]
2
+ def up
3
+ postgres = connection.adapter_name == "PostgreSQL"
4
+
5
+ create_table :channels do |t|
6
+ t.string :type, null: false
7
+ t.references :topic, null: false, foreign_key: { to_table: :topics }, index: true
8
+ if postgres
9
+ t.jsonb :config, null: false, default: {}
10
+ else
11
+ t.json :config, null: false, default: {}
12
+ end
13
+ t.integer :state, null: false, default: 0
14
+ t.string :latest_label
15
+ t.string :latest_link
16
+ t.datetime :last_event_at
17
+ t.timestamps
18
+ end
19
+
20
+ if postgres
21
+ add_index :channels, :config, using: :gin
22
+ add_index :channels, :type
23
+ # Concurrent webhook safety: a single PR may emit pull_request.opened twice
24
+ # back-to-back (or auto-attach can race with pr_monitor). Without this
25
+ # constraint, the dispatch loop would inject every event N times.
26
+ execute <<~SQL
27
+ CREATE UNIQUE INDEX index_channels_on_type_topic_repo_pr
28
+ ON channels (type, topic_id, (config->>'repo_full_name'), (config->>'pr_number'))
29
+ SQL
30
+ else
31
+ add_index :channels, :type
32
+ execute <<~SQL
33
+ CREATE UNIQUE INDEX index_channels_on_type_topic_repo_pr
34
+ ON channels (type, topic_id, json_extract(config, '$.repo_full_name'), json_extract(config, '$.pr_number'))
35
+ SQL
36
+ end
37
+ end
38
+
39
+ def down
40
+ drop_table :channels
41
+ end
42
+ end
@@ -0,0 +1,6 @@
1
+ class AddDismissedAtToChannels < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :channels, :dismissed_at, :datetime
4
+ add_index :channels, :dismissed_at
5
+ end
6
+ end
@@ -0,0 +1,28 @@
1
+ class BackfillDismissedAtForLegacyDetachedChannels < ActiveRecord::Migration[8.1]
2
+ # Chips are now rendered via `not_dismissed` (dismissed_at IS NULL) instead
3
+ # of `state = active`. Without a backfill, any channel that was already
4
+ # detached pre-upgrade — user clicked X under the old flow, or the PR auto-
5
+ # detached on close — would reappear as a chip with the default-open badge
6
+ # the first time the new view runs. Mark legacy detached rows as dismissed
7
+ # using their last-touched timestamp as a best-effort dismissed_at.
8
+ #
9
+ # Idempotent: the WHERE clause skips rows that already have dismissed_at.
10
+ # Scoped to GithubPrChannel because the dismiss-on-detach UI ships only for
11
+ # PR chips. Future Channel STI subtypes opt in by adding their own backfill
12
+ # or by being created post-upgrade.
13
+ def up
14
+ execute <<~SQL.squish
15
+ UPDATE channels
16
+ SET dismissed_at = COALESCE(updated_at, CURRENT_TIMESTAMP)
17
+ WHERE state = 1
18
+ AND dismissed_at IS NULL
19
+ AND type = 'CollavreGithub::GithubPrChannel'
20
+ SQL
21
+ end
22
+
23
+ def down
24
+ # No-op: we can't distinguish backfilled rows from rows the user actually
25
+ # dismissed at the exact same timestamp, and leaving dismissed_at populated
26
+ # on rollback is harmless.
27
+ end
28
+ end
@@ -0,0 +1,31 @@
1
+ class AddPreviewChannelUniqueIndex < ActiveRecord::Migration[8.1]
2
+ def up
3
+ postgres = connection.adapter_name == "PostgreSQL"
4
+
5
+ # Mirror the PR-channel uniqueness guarantee for preview channels: a topic
6
+ # has at most one PreviewChannel per worktree_id. Without this, a racing
7
+ # preview_attach (e.g. concurrent CI + skill calls) could land two rows
8
+ # for the same worktree and the chip would render twice. `type` is
9
+ # included so the partial index does not collide with other channel
10
+ # subtypes that happen to store a `worktree_id` in their config.
11
+ if postgres
12
+ execute <<~SQL
13
+ CREATE UNIQUE INDEX index_channels_on_topic_preview_worktree
14
+ ON channels (topic_id, (config->>'worktree_id'))
15
+ WHERE type = 'Collavre::PreviewChannel'
16
+ SQL
17
+ else
18
+ execute <<~SQL
19
+ CREATE UNIQUE INDEX index_channels_on_topic_preview_worktree
20
+ ON channels (topic_id, json_extract(config, '$.worktree_id'))
21
+ WHERE type = 'Collavre::PreviewChannel'
22
+ SQL
23
+ end
24
+ end
25
+
26
+ def down
27
+ if connection.index_exists?(:channels, nil, name: "index_channels_on_topic_preview_worktree")
28
+ execute "DROP INDEX index_channels_on_topic_preview_worktree"
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddPrimaryAgentIdToTopics < ActiveRecord::Migration[8.0]
4
+ def up
5
+ add_column :topics, :primary_agent_id, :integer
6
+ add_index :topics, :primary_agent_id
7
+
8
+ # Migrate existing data from orchestrator_policies using ActiveRecord (adapter-agnostic)
9
+ Collavre::OrchestratorPolicy
10
+ .where(policy_type: "arbitration", scope_type: "Topic", enabled: true)
11
+ .find_each do |policy|
12
+ agent_id = policy.config&.dig("primary_agent_id")
13
+ next unless agent_id.present?
14
+
15
+ Collavre::Topic.where(id: policy.scope_id).update_all(primary_agent_id: agent_id)
16
+ end
17
+
18
+ # Remove migrated topic-scoped arbitration policies
19
+ Collavre::OrchestratorPolicy
20
+ .where(scope_type: "Topic", policy_type: "arbitration")
21
+ .delete_all
22
+ end
23
+
24
+ def down
25
+ # Re-create orchestrator_policies from topics with primary_agent_id
26
+ Collavre::Topic.where.not(primary_agent_id: nil).find_each do |topic|
27
+ Collavre::OrchestratorPolicy.create!(
28
+ policy_type: "arbitration",
29
+ scope_type: "Topic",
30
+ scope_id: topic.id,
31
+ config: { "strategy" => "primary_first", "primary_agent_id" => topic.primary_agent_id },
32
+ priority: 10,
33
+ enabled: true
34
+ )
35
+ end
36
+
37
+ remove_index :topics, :primary_agent_id
38
+ remove_column :topics, :primary_agent_id
39
+ end
40
+ end
@@ -0,0 +1,15 @@
1
+ class CreateIntegrationSettings < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :integration_settings do |t|
4
+ t.string :key, null: false
5
+ t.text :value
6
+ t.string :category, null: false
7
+ t.boolean :sensitive, null: false, default: true
8
+
9
+ t.timestamps
10
+ end
11
+
12
+ add_index :integration_settings, :key, unique: true
13
+ add_index :integration_settings, :category
14
+ end
15
+ end
data/db/seeds.rb ADDED
@@ -0,0 +1,19 @@
1
+ module Collavre
2
+ module ChannelBotSeed
3
+ def self.call
4
+ email = Channel::BOT_EMAIL
5
+ name = Channel::BOT_NAME
6
+ user = User.find_or_initialize_by(email: email)
7
+ user.name = name
8
+ user.password = SecureRandom.hex(32) if user.new_record?
9
+ user.email_verified_at ||= Time.current
10
+ user.searchable = false if user.respond_to?(:searchable=)
11
+ user.llm_vendor = nil
12
+ user.save!
13
+ Rails.logger.info "[Collavre] Channel bot user ensured: #{email}"
14
+ user
15
+ end
16
+ end
17
+ end
18
+
19
+ Collavre::ChannelBotSeed.call
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ # Returns source-coherent AWS credential pairs (S3 access key id + secret,
5
+ # SES SMTP username + password). Both halves of a pair come from the same
6
+ # source — DB > ENV > Rails credentials — so a partial admin save cannot
7
+ # combine a DB-saved value with an ENV-only sibling and produce a
8
+ # mismatched pair that breaks every upload or every SMTP delivery.
9
+ #
10
+ # Each entry is `[registry_key, label, env_var, credentials_path]`.
11
+ # `credentials_path` may be nil when the pair has no Rails.credentials
12
+ # fallback (S3 keys aren't carried in credentials by convention).
13
+ module AwsCredentials
14
+ module_function
15
+
16
+ # @param boot_safe [Boolean] when true, swallow encryption errors so
17
+ # `storage.yml` / env configs can boot before
18
+ # `active_record_encryption.rb` runs. Runtime callers (e.g.
19
+ # `SesSettingsInterceptor`) MUST leave this false so a decryption
20
+ # failure surfaces instead of silently falling back to ENV.
21
+ # @return [Hash{Symbol => String}] coherent S3 credential pair or `{}`
22
+ def s3(boot_safe: false)
23
+ coherent_pair(
24
+ boot_safe,
25
+ [ :aws_s3_access_key_id, :access_key_id, "AWS_S3_ACCESS_KEY_ID", nil ],
26
+ [ :aws_s3_secret_access_key, :secret_access_key, "AWS_S3_SECRET_ACCESS_KEY", nil ]
27
+ )
28
+ end
29
+
30
+ # @return [Hash{Symbol => String}] coherent SES SMTP credential pair or `{}`
31
+ def ses_smtp(boot_safe: false)
32
+ coherent_pair(
33
+ boot_safe,
34
+ [ :aws_ses_smtp_username, :user_name, "AWS_SES_SMTP_USERNAME", %i[aws smtp_username] ],
35
+ [ :aws_ses_smtp_password, :password, "AWS_SES_SMTP_PASSWORD", %i[aws smtp_password] ]
36
+ )
37
+ end
38
+
39
+ def coherent_pair(boot_safe, *entries)
40
+ [ db_pair(entries, boot_safe), env_pair(entries), credentials_pair(entries) ]
41
+ .find { |pair| pair.values.all?(&:present?) } || {}
42
+ end
43
+
44
+ def db_pair(entries, boot_safe)
45
+ entries.to_h { |entry| [ entry[1], read_db(entry[0], boot_safe: boot_safe) ] }
46
+ end
47
+
48
+ def env_pair(entries)
49
+ entries.to_h { |entry| [ entry[1], ENV[entry[2]].presence ] }
50
+ end
51
+
52
+ def credentials_pair(entries)
53
+ entries.to_h do |entry|
54
+ path = entry[3]
55
+ value = path ? Rails.application.credentials.dig(*path).presence : nil
56
+ [ entry[1], value ]
57
+ end
58
+ end
59
+
60
+ def read_db(key, boot_safe: false)
61
+ return nil unless defined?(Collavre::IntegrationSetting)
62
+ Collavre::IntegrationSetting.find_by(key: key.to_s)&.value.presence
63
+ rescue ActiveRecord::StatementInvalid,
64
+ ActiveRecord::NoDatabaseError,
65
+ ActiveRecord::ConnectionNotEstablished,
66
+ NameError
67
+ nil
68
+ rescue StandardError => e
69
+ raise unless boot_safe &&
70
+ defined?(ActiveRecord::Encryption::Errors::Base) &&
71
+ e.is_a?(ActiveRecord::Encryption::Errors::Base)
72
+ nil
73
+ end
74
+ end
75
+ end
@@ -16,6 +16,57 @@ module Collavre
16
16
  root.join("app/assets/stylesheets")
17
17
  end
18
18
 
19
+ # Register engine-internal integration settings keys with the central
20
+ # registry. These keys are consumed by engine code (mailer `from`, public
21
+ # assets helper, MCP upload service), so the engine itself must own their
22
+ # registration — host apps may not include the app-level
23
+ # `integration_settings_app.rb` initializer when mounting the engine as a gem.
24
+ # `register` is idempotent, so host app re-registration remains safe.
25
+ initializer "collavre.integration_settings_registry", before: :load_config_initializers do
26
+ if defined?(Collavre::IntegrationSettings::Registry)
27
+ registry = Collavre::IntegrationSettings::Registry.instance
28
+ registry.register(:default_mailer_from, category: "mail", sensitive: false, requires_restart: true)
29
+ registry.register(:public_assets_host, category: "mail", sensitive: false, requires_restart: false)
30
+ registry.register(:mcp_upload_root, category: "misc", sensitive: false, requires_restart: false)
31
+ # LLM keys consumed by Collavre::AiClient (engine service). Owned by the
32
+ # engine so gem-mounted host apps don't depend on the app-level
33
+ # `ruby_llm.rb` initializer for ENV fallback.
34
+ registry.register(:gemini_api_key, category: "llm", sensitive: true, requires_restart: true)
35
+ registry.register(:openai_api_key, category: "llm", sensitive: true, requires_restart: false)
36
+ registry.register(:anthropic_api_key, category: "llm", sensitive: true, requires_restart: false)
37
+ registry.register(:gemini_api_base, category: "llm", sensitive: false, requires_restart: true)
38
+ end
39
+ end
40
+
41
+ # AWS keys are consumed by `config/environments/*.rb` (active_storage.service
42
+ # decision, SMTP scaffold) and `config/storage.yml` ERB, both of which evaluate
43
+ # during the `:load_environment_config` initializer — earlier than
44
+ # `:load_config_initializers`. Register them in their own block so
45
+ # `IntegrationSettings.fetch` can resolve the keys at that earlier point.
46
+ # S3 keys are `requires_restart: true` because Rails resolves the storage
47
+ # service once at boot; SES keys are runtime-injected by SesSettingsInterceptor.
48
+ initializer "collavre.integration_settings_registry.aws", before: :load_environment_config do
49
+ if defined?(Collavre::IntegrationSettings::Registry)
50
+ registry = Collavre::IntegrationSettings::Registry.instance
51
+ registry.register(:aws_s3_access_key_id, category: "aws_s3", sensitive: true, requires_restart: true)
52
+ registry.register(:aws_s3_secret_access_key, category: "aws_s3", sensitive: true, requires_restart: true)
53
+ registry.register(:aws_s3_bucket, category: "aws_s3", sensitive: false, requires_restart: true)
54
+ registry.register(:aws_region, category: "aws_s3", sensitive: false, requires_restart: true)
55
+ registry.register(:aws_ses_smtp_username, category: "aws_ses", sensitive: true, requires_restart: false)
56
+ registry.register(:aws_ses_smtp_password, category: "aws_ses", sensitive: true, requires_restart: false)
57
+ end
58
+ end
59
+
60
+ # Register the SES SMTP settings interceptor so each outgoing mail picks up
61
+ # the current DB > ENV > credentials value for SES creds at send time.
62
+ # Hooked after ActionMailer loads to ensure Mail::SMTP is defined.
63
+ initializer "collavre.ses_settings_interceptor" do
64
+ ActiveSupport.on_load(:action_mailer) do
65
+ require "mail"
66
+ ::Mail.register_interceptor(Collavre::SesSettingsInterceptor)
67
+ end
68
+ end
69
+
19
70
  # Add engine migrations to main app's migration path
20
71
  # This allows migrations to live in the engine but be run from the host app
21
72
  initializer "collavre.migrations" do |app|
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module IntegrationSettings
5
+ # Value object describing a registered integration setting key.
6
+ #
7
+ # @!attribute [r] key
8
+ # @return [Symbol] canonical key identifier (e.g. :slack_client_id)
9
+ # @!attribute [r] category
10
+ # @return [String] grouping for the admin UI (e.g. "slack", "google_oauth")
11
+ # @!attribute [r] sensitive
12
+ # @return [Boolean] when true the value is masked in the admin UI
13
+ # @!attribute [r] requires_restart
14
+ # @return [Boolean] when true the admin UI surfaces a "restart required" badge
15
+ # @!attribute [r] env_var
16
+ # @return [String] name of the ENV variable used as fallback / seed source
17
+ # @!attribute [r] default
18
+ # @return [String, nil] static default returned when neither DB nor ENV has a value
19
+ KeyDefinition = Struct.new(
20
+ :key,
21
+ :category,
22
+ :sensitive,
23
+ :requires_restart,
24
+ :env_var,
25
+ :default,
26
+ keyword_init: true
27
+ )
28
+ end
29
+ end