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.
- checksums.yaml +4 -4
- data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-en.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko.png +0 -0
- data/app/assets/stylesheets/collavre/comments_popup.css +40 -0
- data/app/assets/stylesheets/collavre/landing.css +92 -0
- data/app/assets/stylesheets/collavre/popup.css +4 -2
- data/app/controllers/collavre/admin/settings_controller.rb +52 -67
- data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
- data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
- data/app/controllers/collavre/attachments_controller.rb +13 -3
- data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
- data/app/controllers/collavre/creatives_controller.rb +32 -99
- data/app/controllers/collavre/inbox_items_controller.rb +10 -4
- data/app/controllers/collavre/landing_controller.rb +5 -2
- data/app/controllers/collavre/topics_controller.rb +5 -0
- data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
- data/app/errors/collavre/api_error.rb +20 -0
- data/app/helpers/collavre/creatives_helper.rb +30 -8
- data/app/javascript/components/InlineLexicalEditor.jsx +2 -34
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
- data/app/javascript/components/creative_tree_row.js +8 -2
- data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
- data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
- data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
- data/app/javascript/controllers/comments/topics_controller.js +47 -0
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- data/app/javascript/controllers/index.js +3 -0
- data/app/javascript/controllers/search_popup_controller.js +17 -1
- data/app/javascript/controllers/topic_list_controller.js +78 -0
- data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
- data/app/javascript/lib/common_popup.js +24 -5
- data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
- data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
- data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
- data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
- data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
- data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
- data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
- data/app/javascript/modules/creative_inline_payload.js +86 -0
- data/app/javascript/modules/creative_row_editor.js +121 -401
- data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
- data/app/javascript/modules/creative_save_queue.js +95 -0
- data/app/javascript/modules/creative_tree_dom.js +260 -0
- data/app/jobs/collavre/permission_cache_job.rb +20 -2
- data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
- data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
- data/app/models/collavre/channel.rb +16 -0
- data/app/models/collavre/comment/approvable.rb +16 -0
- data/app/models/collavre/comment/notifiable.rb +1 -1
- data/app/models/collavre/comment.rb +8 -1
- data/app/models/collavre/comment_version.rb +1 -1
- data/app/models/collavre/creative/describable.rb +15 -9
- data/app/models/collavre/creative/linkable.rb +9 -0
- data/app/models/collavre/creative/permissible.rb +97 -49
- data/app/models/collavre/creative.rb +73 -5
- data/app/models/collavre/creative_share.rb +78 -26
- data/app/models/collavre/inbox_item.rb +2 -2
- data/app/models/collavre/task.rb +12 -0
- data/app/models/collavre/user.rb +33 -9
- data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
- data/app/services/collavre/agent_type_classifier.rb +23 -0
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
- data/app/services/collavre/ai_agent/message_builder.rb +1 -1
- data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
- data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
- data/app/services/collavre/ai_client.rb +53 -2
- data/app/services/collavre/auto_theme_generator.rb +14 -10
- data/app/services/collavre/creatives/children_index.rb +86 -0
- data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
- data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
- data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
- data/app/services/collavre/creatives/permission_checker.rb +1 -1
- data/app/services/collavre/creatives/permission_filter.rb +144 -17
- data/app/services/collavre/creatives/tree_builder.rb +105 -77
- data/app/services/collavre/http_client.rb +119 -0
- data/app/services/collavre/link_preview_fetcher.rb +129 -38
- data/app/services/collavre/markdown_importer.rb +30 -6
- data/app/services/collavre/mobile/event_summarizer.rb +1 -1
- data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
- data/app/services/collavre/orchestration/agent_orchestrator.rb +1 -1
- data/app/services/collavre/system_events/context_builder.rb +1 -14
- data/app/services/collavre/tools/preview_attach_service.rb +5 -7
- data/app/views/collavre/comments/_comment.html.erb +4 -4
- data/app/views/collavre/comments/_comments_popup.html.erb +11 -3
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +9 -0
- data/app/views/collavre/landing/show.html.erb +52 -10
- data/config/locales/comments.en.yml +1 -0
- data/config/locales/comments.ko.yml +1 -0
- data/config/locales/creatives.en.yml +6 -0
- data/config/locales/creatives.ko.yml +5 -0
- data/config/locales/landing.en.yml +22 -10
- data/config/locales/landing.ko.yml +22 -10
- data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
- data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
- data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
- data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
- data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
- data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
- data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
- data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
- data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
- data/lib/collavre/version.rb +1 -1
- 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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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? &&
|
|
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
|