collavre 0.21.0 → 0.22.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/stylesheets/collavre/comments_popup.css +51 -3
- data/app/assets/stylesheets/collavre/popup.css +148 -0
- data/app/channels/collavre/agent_channel.rb +205 -0
- data/app/controllers/collavre/admin/integrations_controller.rb +16 -5
- data/app/controllers/collavre/api/v1/agents_controller.rb +777 -0
- data/app/controllers/collavre/api/v1/base_controller.rb +46 -0
- data/app/controllers/collavre/attachments_controller.rb +30 -2
- data/app/controllers/collavre/comments_controller.rb +1 -1
- data/app/controllers/collavre/creatives/attachments_controller.rb +79 -0
- data/app/controllers/collavre/creatives_controller.rb +91 -1
- data/app/controllers/collavre/tasks_controller.rb +12 -4
- data/app/controllers/collavre/topics_controller.rb +15 -0
- data/app/controllers/concerns/collavre/comments/approval_actions.rb +57 -14
- data/app/javascript/components/InlineLexicalEditor.jsx +42 -59
- data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +3 -0
- data/app/javascript/components/plugins/image_upload_plugin.jsx +12 -0
- data/app/javascript/controllers/__tests__/link_creative_controller.test.js +447 -0
- data/app/javascript/controllers/comment_controller.js +6 -1
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +108 -0
- data/app/javascript/controllers/comments/list_controller.js +17 -2
- data/app/javascript/controllers/comments/presence_controller.js +56 -5
- data/app/javascript/controllers/link_creative_controller.js +451 -29
- data/app/javascript/lib/api/creatives.js +13 -0
- data/app/javascript/lib/lexical/__tests__/color_import.test.js +318 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +259 -0
- data/app/javascript/lib/lexical/color_import.js +186 -0
- data/app/javascript/lib/lexical/minimize_html.js +182 -0
- data/app/javascript/lib/lexical/video_node.jsx +96 -0
- data/app/jobs/collavre/ai_agent_job.rb +89 -3
- data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +134 -0
- data/app/jobs/collavre/claude_channel_presence_job.rb +91 -0
- data/app/models/collavre/agent_subscription.rb +52 -0
- data/app/models/collavre/comment/claude_channel_permission.rb +145 -0
- data/app/models/collavre/comment.rb +70 -5
- data/app/models/collavre/creative/describable.rb +139 -2
- data/app/models/collavre/creative_share.rb +1 -0
- data/app/models/collavre/task.rb +34 -5
- data/app/models/collavre/topic.rb +5 -0
- data/app/models/collavre/user.rb +4 -0
- data/app/services/collavre/agent_session_abort.rb +28 -0
- data/app/services/collavre/ai_agent/claude_channel_adapter.rb +79 -0
- data/app/services/collavre/ai_agent/response_finalizer.rb +4 -2
- data/app/services/collavre/ai_agent_service.rb +68 -49
- data/app/services/collavre/attachment_backfill.rb +26 -0
- data/app/services/collavre/creatives/breadcrumb_resolver.rb +91 -0
- data/app/services/collavre/creatives/filter_pipeline.rb +26 -42
- data/app/services/collavre/creatives/filters/search_filter.rb +12 -2
- data/app/services/collavre/creatives/index_query.rb +110 -8
- data/app/services/collavre/creatives/permission_filter.rb +50 -0
- data/app/services/collavre/creatives/reveal_path_resolver.rb +118 -0
- data/app/services/collavre/crons/recurring_task_arguments.rb +28 -0
- data/app/services/collavre/orchestration/stuck_detector.rb +22 -2
- data/app/services/collavre/tools/creative_attach_files_service.rb +29 -63
- data/app/services/collavre/tools/creative_remove_attachment_service.rb +7 -5
- data/app/services/collavre/tools/cron_list_service.rb +1 -14
- data/app/services/collavre/topics/orphaned_cron_notifier.rb +68 -0
- data/app/views/collavre/admin/integrations/_category.html.erb +1 -1
- data/app/views/collavre/admin/integrations/_setting_row.html.erb +27 -11
- data/app/views/collavre/comments/_comment.html.erb +10 -1
- data/app/views/collavre/comments/_comments_popup.html.erb +4 -2
- data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -2
- data/config/locales/channels.en.yml +2 -0
- data/config/locales/channels.ko.yml +2 -0
- data/config/locales/claude_channel.en.yml +16 -0
- data/config/locales/claude_channel.ko.yml +16 -0
- data/config/locales/comments.en.yml +3 -0
- data/config/locales/comments.ko.yml +3 -0
- data/config/locales/creatives.en.yml +2 -0
- data/config/locales/creatives.ko.yml +2 -0
- data/config/locales/integrations.en.yml +13 -2
- data/config/locales/integrations.ko.yml +13 -2
- data/config/routes.rb +12 -0
- data/db/migrate/20260609000000_drop_action_text_rich_texts.rb +20 -0
- data/db/migrate/20260609005000_add_session_id_to_topics.rb +16 -0
- data/db/migrate/20260609010000_create_agent_subscriptions.rb +19 -0
- data/db/migrate/20260609020000_add_last_seen_at_to_agent_subscriptions.rb +23 -0
- data/db/migrate/20260609030000_add_session_id_to_agent_subscriptions.rb +17 -0
- data/db/migrate/20260609190659_backfill_creative_files_into_description.rb +24 -0
- data/lib/collavre/engine.rb +0 -1
- data/lib/collavre/integration_settings/key_definition.rb +6 -0
- data/lib/collavre/integration_settings/registry.rb +7 -2
- data/lib/collavre/version.rb +1 -1
- metadata +31 -2
- data/app/services/collavre/openclaw_abort_service.rb +0 -45
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9fd2c39503f762d09d1c98d125910e010ab634bb8d9e5e7c39c1acdc450b841f
|
|
4
|
+
data.tar.gz: 11684679fc116af3955540f8b866b5a3df763823e7d2cb82656d0652cb1b01ef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a8c63aed51bf215f04787f5a6adb220b4306c117b951e0af7c7e19c2998455d50ff9d98edec2ad691e1f2c2d8610218565fd1f7c23348e4e12cfbd6a74167bbc
|
|
7
|
+
data.tar.gz: 9d7ff16652f6d416b3be9730fee13821ed6bbc94054e6695b4e97c82d68d023feff9d192675a733af9ecaf81b7366176d128ba6ad08695618fe3708cf508f422
|
|
@@ -806,7 +806,8 @@ body.chat-fullscreen {
|
|
|
806
806
|
}
|
|
807
807
|
|
|
808
808
|
.edit-comment-action-btn,
|
|
809
|
-
.approve-comment-btn
|
|
809
|
+
.approve-comment-btn,
|
|
810
|
+
.deny-comment-btn {
|
|
810
811
|
padding: 0.15em 0.4em;
|
|
811
812
|
border-radius: var(--radius-2);
|
|
812
813
|
font-size: 0.75rem;
|
|
@@ -836,6 +837,18 @@ body.chat-fullscreen {
|
|
|
836
837
|
border-color: var(--color-success);
|
|
837
838
|
}
|
|
838
839
|
|
|
840
|
+
.deny-comment-btn {
|
|
841
|
+
border: 1px solid color-mix(in srgb, var(--color-danger) 35%, transparent);
|
|
842
|
+
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
|
|
843
|
+
color: var(--color-danger);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
.deny-comment-btn:hover {
|
|
847
|
+
background: var(--color-danger);
|
|
848
|
+
color: var(--text-on-badge);
|
|
849
|
+
border-color: var(--color-danger);
|
|
850
|
+
}
|
|
851
|
+
|
|
839
852
|
.comment-status-label {
|
|
840
853
|
margin-left: 0.4em;
|
|
841
854
|
font-size: 0.75em;
|
|
@@ -858,6 +871,11 @@ body.chat-fullscreen {
|
|
|
858
871
|
color: var(--color-complete);
|
|
859
872
|
}
|
|
860
873
|
|
|
874
|
+
.comment-status-label.denied-label {
|
|
875
|
+
background-color: color-mix(in srgb, var(--color-danger) 20%, transparent);
|
|
876
|
+
color: var(--color-danger);
|
|
877
|
+
}
|
|
878
|
+
|
|
861
879
|
.comment-status-label.pending-label {
|
|
862
880
|
background-color: color-mix(in srgb, var(--color-warning) 20%, transparent);
|
|
863
881
|
color: var(--color-warning);
|
|
@@ -1111,6 +1129,34 @@ body.chat-fullscreen {
|
|
|
1111
1129
|
gap: var(--space-1);
|
|
1112
1130
|
}
|
|
1113
1131
|
|
|
1132
|
+
/* Horizontally-scrollable viewport for channel chips + typing indicator.
|
|
1133
|
+
Keeps PR/Preview badges and live typing on a single line so they never
|
|
1134
|
+
wrap and push the form down. The scroll-prev button stays pinned at the
|
|
1135
|
+
left because it is a flex sibling of (not inside) this viewport.
|
|
1136
|
+
min-width:0 lets this flex child shrink below its content width, which is
|
|
1137
|
+
what actually enables overflow scrolling. */
|
|
1138
|
+
#typing-scroll-viewport {
|
|
1139
|
+
flex: 1;
|
|
1140
|
+
min-width: 0;
|
|
1141
|
+
display: flex;
|
|
1142
|
+
align-items: center;
|
|
1143
|
+
gap: var(--space-1);
|
|
1144
|
+
flex-wrap: nowrap;
|
|
1145
|
+
overflow-x: auto;
|
|
1146
|
+
overflow-y: hidden;
|
|
1147
|
+
scrollbar-width: thin;
|
|
1148
|
+
scroll-behavior: smooth;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
#typing-scroll-viewport::-webkit-scrollbar {
|
|
1152
|
+
height: 4px;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
#typing-scroll-viewport::-webkit-scrollbar-thumb {
|
|
1156
|
+
background: var(--border-color);
|
|
1157
|
+
border-radius: var(--radius-2);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1114
1160
|
.scroll-prev-msg-btn {
|
|
1115
1161
|
background: none;
|
|
1116
1162
|
border: 1px solid var(--border-color);
|
|
@@ -1131,10 +1177,11 @@ body.chat-fullscreen {
|
|
|
1131
1177
|
|
|
1132
1178
|
#typing-indicator {
|
|
1133
1179
|
font-style: italic;
|
|
1134
|
-
flex:
|
|
1180
|
+
flex: 0 0 auto;
|
|
1135
1181
|
min-height: var(--space-px-5);
|
|
1136
1182
|
display: flex;
|
|
1137
1183
|
align-items: center;
|
|
1184
|
+
white-space: nowrap;
|
|
1138
1185
|
}
|
|
1139
1186
|
|
|
1140
1187
|
#typing-indicator .avatar-wrapper {
|
|
@@ -1188,7 +1235,8 @@ body.chat-fullscreen {
|
|
|
1188
1235
|
#channel-chips-container,
|
|
1189
1236
|
.channel-chips {
|
|
1190
1237
|
display: inline-flex;
|
|
1191
|
-
flex-wrap:
|
|
1238
|
+
flex-wrap: nowrap;
|
|
1239
|
+
flex-shrink: 0;
|
|
1192
1240
|
gap: var(--space-1);
|
|
1193
1241
|
align-items: center;
|
|
1194
1242
|
}
|
|
@@ -396,3 +396,151 @@ a.popup-menu-item:hover {
|
|
|
396
396
|
min-width: 100%;
|
|
397
397
|
}
|
|
398
398
|
}
|
|
399
|
+
|
|
400
|
+
/* ── Link-creative picker: mini-tree + flat search results ── */
|
|
401
|
+
.link-creative-list {
|
|
402
|
+
list-style: none;
|
|
403
|
+
margin: 0;
|
|
404
|
+
padding: 0;
|
|
405
|
+
max-height: 320px;
|
|
406
|
+
overflow-y: auto;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/* Tree (browse) rows */
|
|
410
|
+
.link-tree-item {
|
|
411
|
+
list-style: none;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
.link-tree-children {
|
|
415
|
+
list-style: none;
|
|
416
|
+
margin: 0;
|
|
417
|
+
padding: 0;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
.link-tree-row {
|
|
421
|
+
display: flex;
|
|
422
|
+
align-items: center;
|
|
423
|
+
gap: 0.35em;
|
|
424
|
+
padding: 0.3em 0.5em;
|
|
425
|
+
border-radius: var(--radius-1);
|
|
426
|
+
cursor: pointer;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
.link-tree-row:hover,
|
|
430
|
+
.link-tree-row.active {
|
|
431
|
+
background: var(--surface-hover);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
.link-tree-toggle {
|
|
435
|
+
flex: 0 0 auto;
|
|
436
|
+
width: 1.75em;
|
|
437
|
+
height: 1.75em;
|
|
438
|
+
display: inline-flex;
|
|
439
|
+
align-items: center;
|
|
440
|
+
justify-content: center;
|
|
441
|
+
background: transparent;
|
|
442
|
+
border: none;
|
|
443
|
+
border-radius: var(--radius-1);
|
|
444
|
+
padding: 0;
|
|
445
|
+
line-height: 1;
|
|
446
|
+
color: var(--text-secondary);
|
|
447
|
+
cursor: pointer;
|
|
448
|
+
appearance: none;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
.link-tree-toggle svg {
|
|
452
|
+
display: block;
|
|
453
|
+
width: 16px;
|
|
454
|
+
height: 16px;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
.link-tree-toggle:hover {
|
|
458
|
+
color: var(--text-primary);
|
|
459
|
+
background: var(--surface-hover);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
.link-tree-toggle-empty {
|
|
463
|
+
visibility: hidden;
|
|
464
|
+
cursor: default;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
.link-tree-label {
|
|
468
|
+
flex: 1 1 auto;
|
|
469
|
+
min-width: 0;
|
|
470
|
+
overflow: hidden;
|
|
471
|
+
text-overflow: ellipsis;
|
|
472
|
+
white-space: nowrap;
|
|
473
|
+
color: var(--text-primary);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
.link-tree-loading,
|
|
477
|
+
.link-tree-empty,
|
|
478
|
+
.link-popup-message {
|
|
479
|
+
list-style: none;
|
|
480
|
+
padding: 0.35em 0.6em;
|
|
481
|
+
color: var(--text-muted);
|
|
482
|
+
font-size: var(--text-0);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/* Flat search results with breadcrumb */
|
|
486
|
+
.link-result-item {
|
|
487
|
+
list-style: none;
|
|
488
|
+
padding: 0.4em 0.5em;
|
|
489
|
+
border-radius: var(--radius-1);
|
|
490
|
+
cursor: pointer;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
.link-result-item:hover,
|
|
494
|
+
.link-result-item.active {
|
|
495
|
+
background: var(--surface-hover);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
.link-result-label {
|
|
499
|
+
color: var(--text-primary);
|
|
500
|
+
overflow: hidden;
|
|
501
|
+
text-overflow: ellipsis;
|
|
502
|
+
white-space: nowrap;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
.link-result-path {
|
|
506
|
+
display: flex;
|
|
507
|
+
flex-wrap: wrap;
|
|
508
|
+
align-items: center;
|
|
509
|
+
gap: 0.15em;
|
|
510
|
+
margin-top: 0.15em;
|
|
511
|
+
font-size: var(--text-00);
|
|
512
|
+
color: var(--text-muted);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
.link-crumb {
|
|
516
|
+
background: transparent;
|
|
517
|
+
border: none;
|
|
518
|
+
padding: 0 0.1em;
|
|
519
|
+
font-size: inherit;
|
|
520
|
+
color: var(--text-muted);
|
|
521
|
+
cursor: pointer;
|
|
522
|
+
appearance: none;
|
|
523
|
+
max-width: 14ch;
|
|
524
|
+
overflow: hidden;
|
|
525
|
+
text-overflow: ellipsis;
|
|
526
|
+
white-space: nowrap;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
.link-crumb:hover {
|
|
530
|
+
color: var(--color-link);
|
|
531
|
+
text-decoration: underline;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
.link-crumb-sep {
|
|
535
|
+
color: var(--text-muted);
|
|
536
|
+
opacity: 0.6;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
.link-crumb-restricted {
|
|
540
|
+
cursor: default;
|
|
541
|
+
opacity: 0.6;
|
|
542
|
+
}
|
|
543
|
+
.link-crumb-restricted:hover {
|
|
544
|
+
color: var(--text-muted);
|
|
545
|
+
text-decoration: none;
|
|
546
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
class AgentChannel < ApplicationCable::Channel
|
|
5
|
+
# Heartbeat for Claude Channel presence rows. Fires for every subscription,
|
|
6
|
+
# but touch_presence is a no-op unless this connection registered a presence
|
|
7
|
+
# row (agent_id subscribe by a Claude Channel agent), so topic/legacy/non-
|
|
8
|
+
# Claude subscribers pay only an idle timer.
|
|
9
|
+
periodically :touch_presence, every: AgentSubscription::HEARTBEAT_SECONDS
|
|
10
|
+
|
|
11
|
+
# Subscribes to an agent stream for real-time dispatch notifications.
|
|
12
|
+
# Accepts either:
|
|
13
|
+
# - agent_id: per-agent stream — used by MCP plugin clients (Claude
|
|
14
|
+
# Channel) so they receive every dispatch routed to the agent, no
|
|
15
|
+
# matter which topic triggered it. Authorized by created_by ownership.
|
|
16
|
+
# - topic_id: per-topic stream — legacy/UI listeners scoped to one topic.
|
|
17
|
+
def subscribed
|
|
18
|
+
return reject unless current_user
|
|
19
|
+
|
|
20
|
+
if params[:agent_id].present?
|
|
21
|
+
subscribe_to_agent_stream
|
|
22
|
+
elsif params[:topic_id].present?
|
|
23
|
+
subscribe_to_topic_stream
|
|
24
|
+
else
|
|
25
|
+
reject
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# When the MCP process crashes or the WebSocket drops before the plugin
|
|
30
|
+
# can call DELETE /api/v1/agent/:id, a Claude Channel session would
|
|
31
|
+
# otherwise stay matchable (routing_expression: "true") and any future
|
|
32
|
+
# comment on a creative still shared with the agent would dispatch into an
|
|
33
|
+
# empty stream — delegated work that only clears via stuck recovery.
|
|
34
|
+
#
|
|
35
|
+
# One shared agent can have MANY concurrent sessions, so routing is gated
|
|
36
|
+
# on PRESENCE: this session drops its own AgentSubscription row, and
|
|
37
|
+
# routing is cleared only when NO rows remain. A still-live sibling session
|
|
38
|
+
# keeps its row, so routing stays active and its in-flight work is never
|
|
39
|
+
# cancelled. Done under the agent's row lock so a concurrent subscribe on
|
|
40
|
+
# the same agent serializes (no clear-vs-activate race).
|
|
41
|
+
#
|
|
42
|
+
# Cross-process / late unsubscribe: the row was keyed by this connection's
|
|
43
|
+
# own @subscription_token. A stale unsubscribe whose row was already
|
|
44
|
+
# removed (newer subscribe took over, or a different Puma/Kamal process)
|
|
45
|
+
# deletes zero rows and becomes a no-op — required for scaled deployments
|
|
46
|
+
# (WEB_CONCURRENCY > 1, Solid Cable).
|
|
47
|
+
def unsubscribed
|
|
48
|
+
return unless @session_agent && @subscription_token
|
|
49
|
+
|
|
50
|
+
cleared = false
|
|
51
|
+
dropped_session_id = nil
|
|
52
|
+
@session_agent.with_lock do
|
|
53
|
+
scope = AgentSubscription.where(agent_id: @session_agent.id, token: @subscription_token)
|
|
54
|
+
# Capture the dropped session's id before deleting its row so a live-
|
|
55
|
+
# sibling exit can still scope a cleanup to this session's own topic.
|
|
56
|
+
dropped_session_id = scope.pick(:session_id)
|
|
57
|
+
deleted = scope.delete_all
|
|
58
|
+
# Stale: our presence row is already gone. The live owner's lifecycle
|
|
59
|
+
# owns routing — do not clobber it, do not schedule cancellation.
|
|
60
|
+
if deleted.zero?
|
|
61
|
+
dropped_session_id = nil
|
|
62
|
+
next
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Drop crash-orphaned sibling rows (a Puma/ActionCable process that
|
|
66
|
+
# died without firing unsubscribed) before reading presence, so a dead
|
|
67
|
+
# row cannot masquerade as a live sibling and pin routing on forever.
|
|
68
|
+
AgentSubscription.reap_stale!(@session_agent.id)
|
|
69
|
+
|
|
70
|
+
# Another session is still LIVE on this shared agent. Keep routing
|
|
71
|
+
# active; whichever session unsubscribes last clears it.
|
|
72
|
+
next if AgentSubscription.live.where(agent_id: @session_agent.id).exists?
|
|
73
|
+
|
|
74
|
+
@session_agent.update_columns(
|
|
75
|
+
routing_expression: nil,
|
|
76
|
+
routing_subscription_token: nil
|
|
77
|
+
)
|
|
78
|
+
cleared = true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
if cleared
|
|
82
|
+
# Reconnect-grace cancellation (last session): clearing routing only
|
|
83
|
+
# makes the agent unroutable. Any task already "delegated" still holds
|
|
84
|
+
# its ResourceTracker slot — the dispatch was broadcast to a now-dead
|
|
85
|
+
# stream so no client remains to call /reply, and the slot would stay
|
|
86
|
+
# held until StuckDetectorJob times out. The job cancels those tasks
|
|
87
|
+
# after a grace window, but only if the agent is still offline (it
|
|
88
|
+
# rechecks routing_expression AND that no session has resubscribed).
|
|
89
|
+
CancelOfflineDelegatedTasksJob
|
|
90
|
+
.set(wait: CancelOfflineDelegatedTasksJob::GRACE_SECONDS.seconds)
|
|
91
|
+
.perform_later(@session_agent.id, @subscription_token)
|
|
92
|
+
elsif dropped_session_id.present?
|
|
93
|
+
# A live sibling keeps the shared agent routable, so we must NOT clear
|
|
94
|
+
# routing or sweep agent-wide. But this dropped session's OWN session
|
|
95
|
+
# topic is private to it — siblings filter session_topic dispatches to
|
|
96
|
+
# their own topic, so none will /reply to a task delegated there and it
|
|
97
|
+
# would hold its slot until stuck recovery. Schedule a grace-delayed,
|
|
98
|
+
# session-scoped cancellation (skipped if this same session reconnects
|
|
99
|
+
# within the window), mirroring the destroy path's cancel_tasks_for_topic.
|
|
100
|
+
CancelOfflineDelegatedTasksJob
|
|
101
|
+
.set(wait: CancelOfflineDelegatedTasksJob::GRACE_SECONDS.seconds)
|
|
102
|
+
.perform_later(@session_agent.id, @subscription_token, dropped_session_id)
|
|
103
|
+
end
|
|
104
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
105
|
+
Rails.logger.warn("[AgentChannel] unsubscribed presence clear failed: #{e.message}")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Pull-on-resubscribe replay (Codex P2): after every (re)subscribe the plugin
|
|
109
|
+
# sends the permission request_ids it still holds pending, and the server
|
|
110
|
+
# re-broadcasts the recorded decision for exactly those ids. A decision
|
|
111
|
+
# broadcast once into the transient agent:user:<id> stream while the plugin's
|
|
112
|
+
# WebSocket was down would otherwise be lost (action_executed_at already
|
|
113
|
+
# stamped, buttons hidden), hanging the suspended tool with no retry path.
|
|
114
|
+
# The plugin's pending set is the sole bound — there is no wall-clock window,
|
|
115
|
+
# so an outage of any length is covered, and a decision already consumed is
|
|
116
|
+
# never requested. Idempotent: the plugin's coordinator drops any id it no
|
|
117
|
+
# longer holds. Gated on @session_agent so only a Claude Channel agent
|
|
118
|
+
# subscription (where stream_from agent:user:<id> is attached) can pull.
|
|
119
|
+
def replay_permissions(data)
|
|
120
|
+
return unless @session_agent
|
|
121
|
+
|
|
122
|
+
Comment.replay_claude_channel_permission_decisions_for(
|
|
123
|
+
@session_agent.id,
|
|
124
|
+
data["request_ids"]
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Broadcast an arbitrary payload to a topic's agent stream.
|
|
129
|
+
def self.broadcast_to_topic(topic_id, payload)
|
|
130
|
+
ActionCable.server.broadcast("agent:topic:#{topic_id}", payload)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Broadcast to a per-agent stream so the agent's MCP plugin receives the
|
|
134
|
+
# dispatch regardless of which topic triggered it.
|
|
135
|
+
def self.broadcast_to_agent(agent_id, payload)
|
|
136
|
+
ActionCable.server.broadcast("agent:user:#{agent_id}", payload)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def subscribe_to_topic_stream
|
|
142
|
+
@topic = Topic.find_by(id: params[:topic_id])
|
|
143
|
+
return reject unless @topic
|
|
144
|
+
|
|
145
|
+
creative = @topic.creative&.effective_origin
|
|
146
|
+
return reject unless creative&.has_permission?(current_user, :read)
|
|
147
|
+
|
|
148
|
+
stream_from "agent:topic:#{@topic.id}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def subscribe_to_agent_stream
|
|
152
|
+
agent = User.find_by(id: params[:agent_id])
|
|
153
|
+
return reject unless agent&.ai_user?
|
|
154
|
+
return reject unless agent.created_by_id == current_user.id
|
|
155
|
+
|
|
156
|
+
# Attach the stream BEFORE activating routing. Orchestration::Matcher
|
|
157
|
+
# can pick this agent as soon as routing_expression becomes "true";
|
|
158
|
+
# broadcasts to agent:user:<id> in the window between the UPDATE
|
|
159
|
+
# committing and stream_from registering the subscription would land
|
|
160
|
+
# in a stream with no subscriber, leaving the new delegated task
|
|
161
|
+
# waiting on stuck recovery. Registering the subscription first means
|
|
162
|
+
# by the time the agent is matchable, this connection is already
|
|
163
|
+
# receiving broadcasts.
|
|
164
|
+
@session_agent = agent if agent.claude_channel_agent?
|
|
165
|
+
@subscription_token = SecureRandom.hex(8) if agent.claude_channel_agent?
|
|
166
|
+
stream_from "agent:user:#{agent.id}"
|
|
167
|
+
|
|
168
|
+
# Record this session's presence row and (re)activate routing under the
|
|
169
|
+
# agent's row lock, so a concurrent unsubscribe on the same shared agent
|
|
170
|
+
# serializes against this and cannot clear routing out from under a live
|
|
171
|
+
# session. routing_subscription_token is kept as the most-recent-session
|
|
172
|
+
# marker (debugging / grace-job arg); presence rows are the real gate.
|
|
173
|
+
if agent.claude_channel_agent?
|
|
174
|
+
agent.with_lock do
|
|
175
|
+
# Self-heal: clear crash-orphaned rows for this agent before counting
|
|
176
|
+
# so this session's activation isn't blocked from, and presence reads
|
|
177
|
+
# aren't fooled by, a dead process's leftover row.
|
|
178
|
+
AgentSubscription.reap_stale!(agent.id)
|
|
179
|
+
# Record the plugin-supplied session_id (stable across --resume) on the
|
|
180
|
+
# row. The HTTP unregister path (DELETE /api/v1/agent/:id) cannot know
|
|
181
|
+
# this connection's server-minted @subscription_token, so it correlates
|
|
182
|
+
# the exiting session to its row via session_id instead.
|
|
183
|
+
AgentSubscription.create!(
|
|
184
|
+
agent_id: agent.id,
|
|
185
|
+
token: @subscription_token,
|
|
186
|
+
session_id: params[:session_id].presence
|
|
187
|
+
)
|
|
188
|
+
agent.update_columns(
|
|
189
|
+
routing_subscription_token: @subscription_token,
|
|
190
|
+
routing_expression: "true"
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Periodic heartbeat callback: keep this session's presence row live. No-op
|
|
197
|
+
# once the row is gone (rotated by a newer subscribe, or reaped), so it can
|
|
198
|
+
# never resurrect a removed row.
|
|
199
|
+
def touch_presence
|
|
200
|
+
return unless @session_agent && @subscription_token
|
|
201
|
+
|
|
202
|
+
AgentSubscription.touch!(@session_agent.id, @subscription_token)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -23,6 +23,10 @@ module Collavre
|
|
|
23
23
|
next if value.blank?
|
|
24
24
|
|
|
25
25
|
definition = Registry.instance.find(key) or next
|
|
26
|
+
# Mirror the index filter: keys hidden from the admin UI
|
|
27
|
+
# (admin_visible: false) are not editable via this form, so a crafted
|
|
28
|
+
# POST cannot write them.
|
|
29
|
+
next if definition.admin_visible == false
|
|
26
30
|
|
|
27
31
|
row = Collavre::IntegrationSetting.find_or_initialize_by(key: definition.key.to_s)
|
|
28
32
|
row.category = definition.category
|
|
@@ -47,14 +51,21 @@ module Collavre
|
|
|
47
51
|
private
|
|
48
52
|
|
|
49
53
|
def load_definition!
|
|
50
|
-
@definition = Registry.instance.find(params[:key])
|
|
51
|
-
|
|
54
|
+
@definition = Registry.instance.find(params[:key])
|
|
55
|
+
# Keys hidden from the admin UI (admin_visible: false) are treated as
|
|
56
|
+
# non-addressable here too, so a crafted DELETE cannot reset them.
|
|
57
|
+
if @definition.nil? || @definition.admin_visible == false
|
|
58
|
+
@definition = nil
|
|
59
|
+
render file: Rails.root.join("public/404.html"), status: :not_found, layout: false and return
|
|
60
|
+
end
|
|
52
61
|
end
|
|
53
62
|
|
|
54
63
|
def build_grouped_settings
|
|
55
|
-
Registry.instance.by_category.sort_by { |category, _defs| category.to_s }.
|
|
56
|
-
|
|
57
|
-
|
|
64
|
+
Registry.instance.by_category.sort_by { |category, _defs| category.to_s }.filter_map do |category, definitions|
|
|
65
|
+
visible = definitions.select { |d| d.admin_visible != false }
|
|
66
|
+
next if visible.empty?
|
|
67
|
+
|
|
68
|
+
[ category, visible.map { |definition| build_row(definition) } ]
|
|
58
69
|
end
|
|
59
70
|
end
|
|
60
71
|
|