collavre 0.23.0 → 0.24.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
  3. data/app/assets/images/collavre/landing/interface-en.png +0 -0
  4. data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
  5. data/app/assets/images/collavre/landing/interface-ko.png +0 -0
  6. data/app/assets/stylesheets/collavre/comments_popup.css +40 -0
  7. data/app/assets/stylesheets/collavre/landing.css +92 -0
  8. data/app/assets/stylesheets/collavre/popup.css +4 -2
  9. data/app/controllers/collavre/admin/settings_controller.rb +52 -67
  10. data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
  11. data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
  12. data/app/controllers/collavre/attachments_controller.rb +13 -3
  13. data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
  14. data/app/controllers/collavre/creatives_controller.rb +32 -99
  15. data/app/controllers/collavre/inbox_items_controller.rb +10 -4
  16. data/app/controllers/collavre/landing_controller.rb +5 -2
  17. data/app/controllers/collavre/topics_controller.rb +5 -0
  18. data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
  19. data/app/errors/collavre/api_error.rb +20 -0
  20. data/app/helpers/collavre/creatives_helper.rb +30 -8
  21. data/app/javascript/components/InlineLexicalEditor.jsx +2 -34
  22. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
  23. data/app/javascript/components/creative_tree_row.js +8 -2
  24. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
  25. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
  26. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
  27. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  28. data/app/javascript/controllers/comments/topics_controller.js +47 -0
  29. data/app/javascript/controllers/common_popup_controller.js +2 -2
  30. data/app/javascript/controllers/index.js +3 -0
  31. data/app/javascript/controllers/search_popup_controller.js +17 -1
  32. data/app/javascript/controllers/topic_list_controller.js +78 -0
  33. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  34. data/app/javascript/lib/common_popup.js +24 -5
  35. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  36. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  37. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  38. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  39. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  40. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  41. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  42. data/app/javascript/modules/creative_inline_payload.js +86 -0
  43. data/app/javascript/modules/creative_row_editor.js +121 -401
  44. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  45. data/app/javascript/modules/creative_save_queue.js +95 -0
  46. data/app/javascript/modules/creative_tree_dom.js +260 -0
  47. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  48. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  49. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  50. data/app/models/collavre/channel.rb +16 -0
  51. data/app/models/collavre/comment/approvable.rb +16 -0
  52. data/app/models/collavre/comment/notifiable.rb +1 -1
  53. data/app/models/collavre/comment.rb +8 -1
  54. data/app/models/collavre/comment_version.rb +1 -1
  55. data/app/models/collavre/creative/describable.rb +15 -9
  56. data/app/models/collavre/creative/linkable.rb +9 -0
  57. data/app/models/collavre/creative/permissible.rb +97 -49
  58. data/app/models/collavre/creative.rb +73 -5
  59. data/app/models/collavre/creative_share.rb +78 -26
  60. data/app/models/collavre/inbox_item.rb +2 -2
  61. data/app/models/collavre/task.rb +12 -0
  62. data/app/models/collavre/user.rb +33 -9
  63. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  64. data/app/services/collavre/agent_type_classifier.rb +23 -0
  65. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  66. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  67. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  68. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  69. data/app/services/collavre/ai_client.rb +53 -2
  70. data/app/services/collavre/auto_theme_generator.rb +14 -10
  71. data/app/services/collavre/creatives/children_index.rb +86 -0
  72. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  73. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  74. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  75. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  76. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  77. data/app/services/collavre/creatives/tree_builder.rb +105 -77
  78. data/app/services/collavre/http_client.rb +119 -0
  79. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  80. data/app/services/collavre/markdown_importer.rb +30 -6
  81. data/app/services/collavre/mobile/event_summarizer.rb +1 -1
  82. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  83. data/app/services/collavre/orchestration/agent_orchestrator.rb +1 -1
  84. data/app/services/collavre/system_events/context_builder.rb +1 -14
  85. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  86. data/app/views/collavre/comments/_comment.html.erb +4 -4
  87. data/app/views/collavre/comments/_comments_popup.html.erb +11 -3
  88. data/app/views/collavre/creatives/_inline_edit_form.html.erb +9 -0
  89. data/app/views/collavre/landing/show.html.erb +52 -10
  90. data/config/locales/comments.en.yml +1 -0
  91. data/config/locales/comments.ko.yml +1 -0
  92. data/config/locales/creatives.en.yml +6 -0
  93. data/config/locales/creatives.ko.yml +5 -0
  94. data/config/locales/landing.en.yml +22 -10
  95. data/config/locales/landing.ko.yml +22 -10
  96. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  97. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  98. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  99. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  100. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  101. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  102. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  103. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  104. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  105. data/lib/collavre/version.rb +1 -1
  106. metadata +42 -1
@@ -0,0 +1,76 @@
1
+ // Pure, closure-independent helpers extracted from creative_row_editor.js.
2
+ //
3
+ // Every function here depends only on its arguments (and the module-local
4
+ // layout constants below) — none captures editor closure state. Keeping them in
5
+ // a separate module shrinks the main editor module and makes these units
6
+ // individually testable. Behavior is identical to the original inline versions.
7
+
8
+ const BULLET_STARTING_LEVEL = 3;
9
+ const HEADING_INDENT_STEP_EM = 0.4;
10
+ const BULLET_INDENT_STEP_PX = 30;
11
+
12
+ export function treeRowElement(node) {
13
+ return node && node.closest ? node.closest('creative-tree-row') : null;
14
+ }
15
+
16
+ export function hasDatasetValue(element, key) {
17
+ if (!element || !element.dataset) return false;
18
+ return Object.prototype.hasOwnProperty.call(element.dataset, key);
19
+ }
20
+
21
+ export function setRowDatasetValue(row, key, value) {
22
+ if (!row || !row.dataset) return;
23
+ if (value === undefined || value === null) {
24
+ delete row.dataset[key];
25
+ } else {
26
+ row.dataset[key] = String(value);
27
+ }
28
+ }
29
+
30
+ export function isMarkdownEmpty(md) {
31
+ return !md || md.trim().length === 0;
32
+ }
33
+
34
+ export function buildChildrenLoadUrl(parentId, childLevel, selectMode) {
35
+ const params = new URLSearchParams();
36
+ params.set('level', String(childLevel));
37
+ params.set('select_mode', selectMode ? '1' : '0');
38
+ return `/creatives/${parentId}/children?${params.toString()}`;
39
+ }
40
+
41
+ export function readRowLevel(row) {
42
+ if (!row) return null;
43
+ if (row.isTitle) return 0;
44
+ if (row.getAttribute) {
45
+ const levelAttr = row.getAttribute('level');
46
+ if (levelAttr) {
47
+ const parsed = Number(levelAttr);
48
+ if (!Number.isNaN(parsed)) return parsed;
49
+ }
50
+ }
51
+ if (typeof row.level === 'number') {
52
+ return row.level;
53
+ }
54
+ if (row.level) {
55
+ const parsed = Number(row.level);
56
+ if (!Number.isNaN(parsed)) return parsed;
57
+ }
58
+ const tree = row.querySelector ? row.querySelector('.creative-tree') : null;
59
+ if (tree && tree.dataset?.level) {
60
+ const parsed = Number(tree.dataset.level);
61
+ if (!Number.isNaN(parsed)) return parsed;
62
+ }
63
+ return 1;
64
+ }
65
+
66
+ export function editorPaddingForLevel(level) {
67
+ if (typeof level !== 'number' || Number.isNaN(level) || level <= 1) {
68
+ return '0px';
69
+ }
70
+ if (level <= BULLET_STARTING_LEVEL) {
71
+ const emValue = (level - 1) * HEADING_INDENT_STEP_EM;
72
+ return emValue ? `${emValue}em` : '0px';
73
+ }
74
+ const pxValue = (level - BULLET_STARTING_LEVEL) * BULLET_INDENT_STEP_PX;
75
+ return `${pxValue}px`;
76
+ }
@@ -0,0 +1,95 @@
1
+ // The autosave request lifecycle extracted from creative_row_editor.js.
2
+ //
3
+ // The inline editor persists edits on a debounce and also saves directly on
4
+ // structural changes (reorder, indent, progress toggle). Two invariants kept
5
+ // getting re-broken across dozens of fix commits ("Prevent double saves...",
6
+ // "Skip in-flight request during queue deduplication"):
7
+ //
8
+ // 1. Debounce: rapid edits collapse into a single trailing save; re-arming
9
+ // the timer must cancel the previous one.
10
+ // 2. Single-flight: at most one save request is in flight at a time. A save
11
+ // requested while one is running must NOT start a second request — it
12
+ // returns the in-flight promise. (Dirty-tracking in the editor re-arms and
13
+ // a later schedule/flush persists the newer buffer.)
14
+ //
15
+ // This class owns exactly those two concerns — the debounce timer and the
16
+ // single-flight guard around the request promise. It deliberately does NOT own
17
+ // the editor's dirty flags (isDirty / pendingSave), which track *what* changed;
18
+ // this owns *when the request runs*. Behavior is identical to the original
19
+ // inline `saveTimer` / `saving` / `savePromise` logic; the timer functions are
20
+ // injectable so the state machine is testable without real timers.
21
+ export class CreativeSaveQueue {
22
+ constructor({ debounceMs = 5000, setTimer, clearTimer } = {}) {
23
+ this._debounceMs = debounceMs;
24
+ // Wrap the timer functions so they are invoked as free functions. Passing
25
+ // `setTimeout`/`clearTimeout` directly and calling them as `this._setTimer(...)`
26
+ // would invoke them with `this === queue`, which throws "Illegal invocation"
27
+ // in real browsers (jsdom does not, which is why unit tests can't catch it).
28
+ // Tests inject their own timer fns; production uses these window-bound wrappers.
29
+ this._setTimer = setTimer || ((fn, ms) => setTimeout(fn, ms));
30
+ this._clearTimer = clearTimer || ((id) => clearTimeout(id));
31
+ this._timer = null;
32
+ this._saving = false;
33
+ this._promise = Promise.resolve();
34
+ }
35
+
36
+ /** True while a save request is in flight. */
37
+ get saving() {
38
+ return this._saving;
39
+ }
40
+
41
+ /** The most recent (possibly in-flight) save promise. */
42
+ get promise() {
43
+ return this._promise;
44
+ }
45
+
46
+ /**
47
+ * Arm the debounce timer to invoke `flush` after `debounceMs`. Re-arming
48
+ * cancels any previously scheduled flush, so only the latest edit's timer
49
+ * fires (trailing debounce).
50
+ * @param {() => void} flush
51
+ */
52
+ schedule(flush) {
53
+ this.cancelTimer();
54
+ this._timer = this._setTimer(() => {
55
+ this._timer = null;
56
+ flush();
57
+ }, this._debounceMs);
58
+ }
59
+
60
+ /** Cancel a pending debounce timer, if any. Idempotent. */
61
+ cancelTimer() {
62
+ if (this._timer !== null) {
63
+ this._clearTimer(this._timer);
64
+ this._timer = null;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Run `execute` under the single-flight guard.
70
+ *
71
+ * - If a save is already in flight, returns the in-flight promise WITHOUT
72
+ * invoking `execute` (the coalescing invariant).
73
+ * - Otherwise cancels any pending debounce, then invokes `execute`. `execute`
74
+ * returns the save request promise, or a nullish value to signal "nothing
75
+ * to persist" (e.g. empty content) — in which case no in-flight state is
76
+ * entered and a resolved promise is returned. Invoking `execute` before
77
+ * committing `saving` preserves the original ordering, where an empty-save
78
+ * short-circuit never flips the in-flight flag.
79
+ *
80
+ * `saving` is cleared when the returned promise settles (success or failure).
81
+ * @param {() => (Promise|null|undefined)} execute
82
+ * @returns {Promise}
83
+ */
84
+ runExclusive(execute) {
85
+ if (this._saving) return this._promise;
86
+ this.cancelTimer();
87
+ const request = execute();
88
+ if (request == null) return Promise.resolve();
89
+ this._saving = true;
90
+ this._promise = Promise.resolve(request).finally(() => {
91
+ this._saving = false;
92
+ });
93
+ return this._promise;
94
+ }
95
+ }
@@ -0,0 +1,260 @@
1
+ // Tree-DOM navigation & geometry helpers extracted from creative_row_editor.js.
2
+ //
3
+ // This is slice 2 of the creative_row_editor god-file decomposition (slice 1 was
4
+ // the save-queue single-flight/debounce cluster, already in creative_save_queue.js).
5
+ //
6
+ // Every function here operates purely on the DOM nodes passed as arguments (plus
7
+ // `document` for id/selector lookups) — none captures the editor closure's shared
8
+ // state (currentTree, form, parentInput, isDirty, pendingSave, uploadsPending, …).
9
+ // That is exactly why this cluster is a clean seam: it can be lifted out and unit
10
+ // tested against a jsdom fixture without standing up the whole inline editor.
11
+ //
12
+ // The cluster underpins the structural-move paths (levelUp / levelDown / addNew /
13
+ // move) that compute a node's parent, its before/after siblings, and its indent
14
+ // level — the inputs to the recurring "parentId loss / wrong ordering" bug class.
15
+ // Pinning them with characterization tests raises the floor under those moves.
16
+ //
17
+ // NOTE ON BINDING HAZARDS: none of these functions use setTimeout / setInterval /
18
+ // requestAnimationFrame / queueMicrotask, and none rely on `this`. They are plain
19
+ // functions invoked by name, so the "Illegal invocation" class of real-browser
20
+ // regression (a prior timer-extraction bug that jsdom could not catch) does not
21
+ // apply here. The only DOM writes are element mutations (insertBefore, appendChild,
22
+ // setAttribute, dataset, style, requestUpdate) invoked on the passed nodes.
23
+ import { treeRowElement, readRowLevel, buildChildrenLoadUrl } from './creative_row_editor_helpers';
24
+
25
+ export function creativeTreeElement(node) {
26
+ if (!node) return null;
27
+ if (node.classList && node.classList.contains('creative-tree')) return node;
28
+ if (node.querySelector) {
29
+ const inner = node.querySelector('.creative-tree');
30
+ if (inner) return inner;
31
+ }
32
+ return null;
33
+ }
34
+
35
+ export function creativeIdFrom(node) {
36
+ const treeEl = creativeTreeElement(node);
37
+ if (treeEl && treeEl.dataset) {
38
+ return treeEl.dataset.id || '';
39
+ }
40
+ if (node?.getAttribute) {
41
+ return node.getAttribute('creative-id') || node.getAttribute('data-id') || '';
42
+ }
43
+ return '';
44
+ }
45
+
46
+ export function siblingTreeRow(row, direction) {
47
+ if (!row) return null;
48
+ const step = direction === 'previous' ? 'previousSibling' : 'nextSibling';
49
+ let node = row[step];
50
+ while (node) {
51
+ if (node.nodeType === Node.TEXT_NODE) {
52
+ node = node[step];
53
+ continue;
54
+ }
55
+ if (node.matches?.('creative-tree-row')) return node;
56
+ if (node.classList?.contains?.('creative-children')) {
57
+ node = node[step];
58
+ continue;
59
+ }
60
+ node = node[step];
61
+ }
62
+ return null;
63
+ }
64
+
65
+ export function siblingOrderingForRow(row) {
66
+ const beforeRow = siblingTreeRow(row, 'next');
67
+ const afterRow = siblingTreeRow(row, 'previous');
68
+ return {
69
+ beforeId: beforeRow ? creativeIdFrom(beforeRow) : '',
70
+ afterId: afterRow ? creativeIdFrom(afterRow) : ''
71
+ };
72
+ }
73
+
74
+ export function treeContainerElement(tree) {
75
+ if (!tree) return null;
76
+ const row = treeRowElement(tree);
77
+ if (row && row.parentNode) return row.parentNode;
78
+ return tree.parentNode;
79
+ }
80
+
81
+ export function nodeAfterTreeBlock(tree) {
82
+ if (!tree) return null;
83
+ const row = treeRowElement(tree);
84
+ if (!row) return tree.nextSibling;
85
+ let node = row.nextSibling;
86
+ while (node && node.nodeType === Node.TEXT_NODE) node = node.nextSibling;
87
+ const treeId = tree.dataset?.id;
88
+ if (treeId) {
89
+ const childrenContainer = document.getElementById(`creative-children-${treeId}`);
90
+ if (childrenContainer && childrenContainer.parentNode === row.parentNode && node === childrenContainer) {
91
+ node = childrenContainer.nextSibling;
92
+ while (node && node.nodeType === Node.TEXT_NODE) node = node.nextSibling;
93
+ }
94
+ }
95
+ return node;
96
+ }
97
+
98
+ export function normalizeRowNode(node) {
99
+ if (!node) return null;
100
+ if (node.matches && node.matches('creative-tree-row')) return node;
101
+ if (node.classList && node.classList.contains('creative-tree')) {
102
+ const row = treeRowElement(node);
103
+ return row || node;
104
+ }
105
+ return node;
106
+ }
107
+
108
+ export function childrenContainerForTree(tree) {
109
+ if (!tree) return null;
110
+ const treeId = tree.dataset?.id;
111
+ if (treeId) {
112
+ const byId = document.getElementById(`creative-children-${treeId}`);
113
+ if (byId) return byId;
114
+ }
115
+ if (tree.children && tree.children.length > 0) {
116
+ for (const child of tree.children) {
117
+ if (child && child.classList && child.classList.contains('creative-children')) {
118
+ return child;
119
+ }
120
+ }
121
+ }
122
+ const row = treeRowElement(tree);
123
+ if (row) {
124
+ let sibling = row.nextElementSibling;
125
+ while (sibling) {
126
+ if (sibling.matches?.('creative-tree-row')) break;
127
+ if (sibling.classList?.contains('creative-children')) return sibling;
128
+ sibling = sibling.nextElementSibling;
129
+ }
130
+ }
131
+ return null;
132
+ }
133
+
134
+ export function ensureChildrenContainer(tree) {
135
+ if (!tree) return null;
136
+ let container = childrenContainerForTree(tree);
137
+ if (container) return container;
138
+ const parentId = tree.dataset?.id;
139
+ if (!parentId) return null;
140
+ container = document.createElement('div');
141
+ container.className = 'creative-children';
142
+ container.id = `creative-children-${parentId}`;
143
+ const parentRow = treeRowElement(tree);
144
+ const parentLevel = readRowLevel(parentRow) || 1;
145
+ const childLevel = parentLevel + 1;
146
+ const selectModeActive = parentRow?.hasAttribute?.('select-mode') ? 1 : 0;
147
+ container.dataset.loadUrl = buildChildrenLoadUrl(parentId, childLevel, selectModeActive);
148
+ container.dataset.expanded = 'true';
149
+ if (container.dataset.loaded) delete container.dataset.loaded;
150
+ const row = treeRowElement(tree);
151
+ const parentContainer = row?.parentNode || tree.parentNode;
152
+ if (parentContainer) {
153
+ const afterRow = row?.nextSibling;
154
+ if (afterRow) {
155
+ parentContainer.insertBefore(container, afterRow);
156
+ } else {
157
+ parentContainer.appendChild(container);
158
+ }
159
+ } else {
160
+ tree.appendChild(container);
161
+ }
162
+ return container;
163
+ }
164
+
165
+ export function expandChildrenContainer(container) {
166
+ if (!container) return;
167
+ container.style.display = '';
168
+ if (container.dataset) {
169
+ container.dataset.expanded = 'true';
170
+ }
171
+ }
172
+
173
+ export function moveTreeBlock(tree, targetContainer, referenceNode = null) {
174
+ if (!tree || !targetContainer) return;
175
+ const row = treeRowElement(tree);
176
+ if (!row) return;
177
+ const nodesToMove = [row];
178
+ const childContainer = childrenContainerForTree(tree);
179
+ if (childContainer) nodesToMove.push(childContainer);
180
+ nodesToMove.forEach((node) => {
181
+ if (!node) return;
182
+ if (referenceNode) {
183
+ targetContainer.insertBefore(node, referenceNode);
184
+ } else {
185
+ targetContainer.appendChild(node);
186
+ }
187
+ });
188
+ }
189
+
190
+ export function listAllTreeNodes() {
191
+ const root = document.getElementById('creatives');
192
+ if (root) return Array.from(root.querySelectorAll('.creative-tree'));
193
+ return Array.from(document.querySelectorAll('.creative-tree'));
194
+ }
195
+
196
+ export function findPreviousTree(tree) {
197
+ if (!tree) return null;
198
+ const nodes = listAllTreeNodes();
199
+ const index = nodes.indexOf(tree);
200
+ if (index <= 0) return null;
201
+ const currentLevel = getTreeLevel(tree);
202
+ for (let i = index - 1; i >= 0; i--) {
203
+ const candidate = nodes[i];
204
+ if (!candidate) continue;
205
+ const candidateLevel = getTreeLevel(candidate);
206
+ if (candidateLevel === currentLevel) return candidate;
207
+ if (candidateLevel < currentLevel) return null;
208
+ }
209
+ return null;
210
+ }
211
+
212
+ export function getTreeLevel(tree) {
213
+ if (!tree) return 1;
214
+ const levelValue = Number(tree.dataset?.level);
215
+ if (!Number.isNaN(levelValue) && levelValue > 0) {
216
+ return levelValue;
217
+ }
218
+ const row = treeRowElement(tree);
219
+ return readRowLevel(row) || 1;
220
+ }
221
+
222
+ export function updateTreeLevels(tree, delta) {
223
+ if (!tree || !delta) return;
224
+ const currentLevel = Number(tree.dataset?.level) || 1;
225
+ const nextLevel = Math.max(1, currentLevel + delta);
226
+ tree.dataset.level = String(nextLevel);
227
+ const row = treeRowElement(tree);
228
+ if (row) {
229
+ row.setAttribute('level', nextLevel);
230
+ row.level = nextLevel;
231
+ row.requestUpdate?.();
232
+ }
233
+ const container = childrenContainerForTree(tree);
234
+ if (!container) return;
235
+ Array.from(container.children || []).forEach((childRow) => {
236
+ if (!childRow.matches?.('creative-tree-row')) return;
237
+ const childTree = childRow.querySelector('.creative-tree');
238
+ if (childTree) {
239
+ updateTreeLevels(childTree, delta);
240
+ }
241
+ });
242
+ }
243
+
244
+ export function setTreeLevel(tree, targetLevel) {
245
+ if (!tree || typeof targetLevel !== 'number') return;
246
+ const currentLevel = Number(tree.dataset?.level) || 1;
247
+ const delta = targetLevel - currentLevel;
248
+ if (delta === 0) return;
249
+ updateTreeLevels(tree, delta);
250
+ }
251
+
252
+ export function removeTreeElement(tree) {
253
+ if (!tree) return;
254
+ const row = treeRowElement(tree);
255
+ if (row) {
256
+ row.remove();
257
+ } else if (tree.remove) {
258
+ tree.remove();
259
+ }
260
+ }
@@ -19,7 +19,7 @@ class PermissionCacheJob < ApplicationJob
19
19
  when :rebuild_for_creative
20
20
  rebuild_for_creative(args[:creative_id])
21
21
  when :propagate_share
22
- propagate_share(args[:creative_share_id])
22
+ propagate_share(args[:creative_share_id], purge_stale: args.fetch(:purge_stale, false))
23
23
  when :remove_share
24
24
  remove_share(args[:creative_share_id], args[:creative_id], args[:user_id])
25
25
  when :rebuild_user_cache_for_subtree
@@ -49,7 +49,18 @@ class PermissionCacheJob < ApplicationJob
49
49
  Creatives::PermissionCacheBuilder.rebuild_for_creative(creative)
50
50
  end
51
51
 
52
- def propagate_share(creative_share_id)
52
+ def propagate_share(creative_share_id, purge_stale: false)
53
+ # A relocated/reassigned share leaves stale cache rows keyed to it at the old
54
+ # location. When asked, delete them here — in this same job, immediately
55
+ # before re-propagating — never as a separate job. Both the purge and the
56
+ # propagate key on source_share_id, and the authz queue runs two threads, so
57
+ # a standalone purge could execute AFTER propagate and delete the rows it
58
+ # just wrote, leaving the share with no cache until an unrelated rebuild.
59
+ # Folding the purge in guarantees purge-before-propagate ordering while
60
+ # keeping the work off the commit path (the async, prolonged-grant window the
61
+ # CTO accepted is preserved). Purge runs before the find_by so it still fires
62
+ # even if the share was destroyed after enqueue.
63
+ purge_share_cache(creative_share_id) if purge_stale
53
64
  share = CreativeShare.find_by(id: creative_share_id)
54
65
  return unless share
55
66
  Creatives::PermissionCacheBuilder.propagate_share(share)
@@ -62,6 +73,13 @@ class PermissionCacheJob < ApplicationJob
62
73
  Creatives::PermissionCacheBuilder.rebuild_from_ancestors_for_user(creative, user_id)
63
74
  end
64
75
 
76
+ # Purge the stale cache rows a relocated/reassigned share left behind. Only
77
+ # ever called from propagate_share (never enqueued standalone) so the delete
78
+ # cannot race the re-propagation on a separate authz-queue thread.
79
+ def purge_share_cache(creative_share_id)
80
+ CreativeSharesCache.where(source_share_id: creative_share_id).delete_all
81
+ end
82
+
65
83
  def rebuild_user_cache_for_subtree(creative_id, user_id)
66
84
  creative = Creative.find_by(id: creative_id)
67
85
  return unless creative
@@ -86,17 +86,34 @@ module Collavre
86
86
  # These are NOT valid task results — the agent never actually worked.
87
87
  MAX_INFRA_RETRIES = 3
88
88
 
89
- INFRASTRUCTURE_ERROR_PATTERNS = [
90
- /OpenClaw Error/i,
89
+ # Generic, vendor-neutral infrastructure-error signatures. Vendor engines
90
+ # register their own provider-specific signatures (e.g. an OpenClaw gateway
91
+ # error banner) via register_infrastructure_error_pattern, so core names no
92
+ # vendor here.
93
+ BUILTIN_INFRASTRUCTURE_ERROR_PATTERNS = [
91
94
  /(?:request|connection|server)\s+timed?\s*out/i,
92
95
  /connection\s*(refused|reset|closed)/i,
93
96
  /internal\s*server\s*error/i,
94
97
  /\b50[234]\b.*(?:error|gateway|unavailable)/i
95
98
  ].freeze
96
99
 
100
+ class << self
101
+ def registered_infrastructure_error_patterns
102
+ @registered_infrastructure_error_patterns ||= []
103
+ end
104
+
105
+ def register_infrastructure_error_pattern(pattern)
106
+ registered_infrastructure_error_patterns << pattern unless registered_infrastructure_error_patterns.include?(pattern)
107
+ end
108
+
109
+ def infrastructure_error_patterns
110
+ BUILTIN_INFRASTRUCTURE_ERROR_PATTERNS + registered_infrastructure_error_patterns
111
+ end
112
+ end
113
+
97
114
  def infrastructure_error?(comment)
98
115
  content = comment.content.to_s
99
- INFRASTRUCTURE_ERROR_PATTERNS.any? { |pattern| content.match?(pattern) }
116
+ self.class.infrastructure_error_patterns.any? { |pattern| content.match?(pattern) }
100
117
  end
101
118
 
102
119
  # Retry without consuming an iteration — the agent never actually worked.
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ # Re-derives a creative's MCP tool definitions from its description HTML.
5
+ # Runs off the request/save path (enqueued from Creative after_commit) so the
6
+ # Nokogiri parsing and tool upserts never block a synchronous save.
7
+ class UpdateMcpToolsJob < ApplicationJob
8
+ queue_as :default
9
+
10
+ def perform(creative_id)
11
+ creative = Creative.find_by(id: creative_id)
12
+ return unless creative
13
+
14
+ McpService.new.update_from_creative(creative)
15
+ end
16
+ end
17
+ end
@@ -5,6 +5,22 @@ module Collavre
5
5
 
6
6
  self.table_name = "channels"
7
7
 
8
+ include Collavre::IndexedJsonColumns
9
+
10
+ # Denormalized columns that mirror same-named keys in `config`. They exist
11
+ # so the channels UNIQUE indexes are plain-column indexes (which dump
12
+ # identically to schema.rb on SQLite and PostgreSQL) instead of JSON
13
+ # expression indexes (json_extract vs config->>), which serialize
14
+ # per-adapter and crash `db:schema:load` on PostgreSQL. `config` stays the
15
+ # source of truth; these columns are re-derived on every save. Declared
16
+ # here beside the table whose unique indexes are defined in this engine's
17
+ # migration, so subtype engines need not know about the denormalization.
18
+ indexed_json_columns json: :config, columns: {
19
+ repo_full_name: "repo_full_name",
20
+ pr_number: "pr_number",
21
+ worktree_id: "worktree_id"
22
+ }
23
+
8
24
  belongs_to :topic, class_name: "Collavre::Topic"
9
25
 
10
26
  enum :state, { active: 0, detached: 1 }, default: :active
@@ -3,6 +3,22 @@ module Collavre
3
3
  module Approvable
4
4
  extend ActiveSupport::Concern
5
5
 
6
+ # A message that renders an approval button (pending) or an
7
+ # approved/denied status label (decided) in the chat list — i.e. it
8
+ # carries an `action` JSON payload. Both the `has_pending_action`
9
+ # button (action.present? && action_executed_at.blank?) and the
10
+ # ✅ approved / 🚫 denied label (action_executed_at.present?, which implies
11
+ # action.present?) roll up to this single condition.
12
+ #
13
+ # Such a message is a HUMAN decision surface and must never be dispatched
14
+ # to an AI agent — regardless of who authored it or whether it has already
15
+ # been decided. This is the single source of truth for that invariant,
16
+ # enforced at every dispatch seam (Comment#dispatch_to_orchestration and
17
+ # AiAgent::A2aDispatcher#dispatch).
18
+ def approval_action?
19
+ action.present?
20
+ end
21
+
6
22
  def can_be_approved_by?(user)
7
23
  approval_status(user) == :ok
8
24
  end
@@ -172,7 +172,7 @@ module Collavre
172
172
  end
173
173
 
174
174
  def notify_approver
175
- return unless approver.present? && action.present?
175
+ return unless approver.present? && approval_action?
176
176
  return if approver == user
177
177
  return if creative&.inbox? # Don't notify about inbox comments
178
178
 
@@ -51,7 +51,7 @@ module Collavre
51
51
  end
52
52
  end
53
53
 
54
- belongs_to :creative, class_name: "Collavre::Creative"
54
+ belongs_to :creative, class_name: "Collavre::Creative", counter_cache: true
55
55
  belongs_to :user, class_name: Collavre.configuration.user_class_name, optional: true
56
56
  belongs_to :approver, class_name: Collavre.configuration.user_class_name, optional: true
57
57
  belongs_to :action_executed_by, class_name: Collavre.configuration.user_class_name, optional: true
@@ -61,6 +61,11 @@ module Collavre
61
61
 
62
62
  scope :public_only, -> { where(private: false) }
63
63
 
64
+ # SQL inverse of Comment#approval_action? (action.present?). Approval-surface
65
+ # messages must never reach an AI agent — not only at the dispatch seams but
66
+ # also as chat-history/trigger context — so agent-context queries exclude them.
67
+ scope :without_approval_action, -> { where("action IS NULL OR action = ''") }
68
+
64
69
  scope :visible_to, ->(user) {
65
70
  where(
66
71
  "comments.private = ? OR comments.user_id = ? OR comments.approver_id = ?",
@@ -213,6 +218,7 @@ module Collavre
213
218
  return if private?
214
219
  return if skip_default_user # system notices should not trigger AI
215
220
  return if skip_dispatch # explicit opt-out (e.g., command processor responses)
221
+ return if approval_action? # approval button / approved message: human decision surface, never dispatch to an agent
216
222
  return unless user_id # nil user = system message
217
223
  return if user&.ai_user? # AI replies use A2aDispatcher, not this callback
218
224
  return unless creative
@@ -273,6 +279,7 @@ module Collavre
273
279
  def resume_trigger_loop_if_awaiting
274
280
  return unless user_id # must have a user (not system)
275
281
  return if user&.ai_user? # must be a human, not an AI agent
282
+ return if approval_action? # approval surface is not a user-resume signal; the resumed @agent turn would otherwise carry it into history
276
283
  return unless creative
277
284
 
278
285
  # Use pessimistic lock to prevent duplicate resume from concurrent comments
@@ -4,7 +4,7 @@ module Collavre
4
4
  class CommentVersion < ApplicationRecord
5
5
  self.table_name = "comment_versions"
6
6
 
7
- belongs_to :comment, class_name: "Collavre::Comment"
7
+ belongs_to :comment, class_name: "Collavre::Comment", counter_cache: true
8
8
  belongs_to :review_comment, class_name: "Collavre::Comment", optional: true
9
9
 
10
10
  validates :content, presence: true