protege 0.1.0.alpha.1
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 +7 -0
- data/README.md +24 -0
- data/Rakefile +12 -0
- data/app/assets/builds/protege.css +1128 -0
- data/app/assets/javascripts/protege/attachment_picker.js +163 -0
- data/app/assets/javascripts/protege/copy.js +80 -0
- data/app/assets/javascripts/protege/forms.js +28 -0
- data/app/assets/javascripts/protege/keys.js +212 -0
- data/app/assets/javascripts/protege/resize.js +149 -0
- data/app/assets/javascripts/protege/theme.js +25 -0
- data/app/assets/stylesheets/protege/application.css +15 -0
- data/app/assets/tailwind/application.css +137 -0
- data/app/controllers/concerns/protege/persona_scoped.rb +22 -0
- data/app/controllers/concerns/protege/thread_scoped.rb +22 -0
- data/app/controllers/protege/access_rules_controller.rb +72 -0
- data/app/controllers/protege/application_controller.rb +18 -0
- data/app/controllers/protege/archives_controller.rb +52 -0
- data/app/controllers/protege/email_domains_controller.rb +72 -0
- data/app/controllers/protege/home_controller.rb +15 -0
- data/app/controllers/protege/messages_controller.rb +46 -0
- data/app/controllers/protege/mta_domains_syncs_controller.rb +22 -0
- data/app/controllers/protege/personas_controller.rb +118 -0
- data/app/controllers/protege/replies_controller.rb +64 -0
- data/app/controllers/protege/responsibilities_controller.rb +82 -0
- data/app/controllers/protege/responsibility_runs_controller.rb +46 -0
- data/app/controllers/protege/threads_controller.rb +75 -0
- data/app/controllers/protege/traces/searches_controller.rb +19 -0
- data/app/controllers/protege/traces_controller.rb +52 -0
- data/app/helpers/protege/application_helper.rb +30 -0
- data/app/helpers/protege/components/badges_helper.rb +59 -0
- data/app/helpers/protege/components/buttons_helper.rb +141 -0
- data/app/helpers/protege/components/cards_helper.rb +48 -0
- data/app/helpers/protege/components/components_helper.rb +30 -0
- data/app/helpers/protege/components/dialogs_helper.rb +36 -0
- data/app/helpers/protege/components/forms_helper.rb +223 -0
- data/app/helpers/protege/components/hotkeys_helper.rb +60 -0
- data/app/helpers/protege/components/icons_helper.rb +41 -0
- data/app/helpers/protege/components/introspection_helper.rb +162 -0
- data/app/helpers/protege/components/layout_helper.rb +169 -0
- data/app/helpers/protege/components/menus_helper.rb +68 -0
- data/app/helpers/protege/components/pagination_helper.rb +100 -0
- data/app/helpers/protege/components/sidebar_helper.rb +211 -0
- data/app/helpers/protege/components/tables_helper.rb +59 -0
- data/app/helpers/protege/components/typography_helper.rb +64 -0
- data/app/helpers/protege/email_domains_helper.rb +22 -0
- data/app/helpers/protege/messages_helper.rb +152 -0
- data/app/helpers/protege/personas_helper.rb +90 -0
- data/app/helpers/protege/responsibilities_helper.rb +63 -0
- data/app/helpers/protege/threads_helper.rb +118 -0
- data/app/helpers/protege/trace_helper.rb +22 -0
- data/app/hooks/protege/event_logger_hook.rb +109 -0
- data/app/jobs/protege/application_job.rb +41 -0
- data/app/jobs/protege/console_inference_job.rb +57 -0
- data/app/jobs/protege/inference_job.rb +60 -0
- data/app/jobs/protege/mail_provisioning_job.rb +21 -0
- data/app/jobs/protege/responsibility_job.rb +70 -0
- data/app/jobs/protege/responsibility_tick_job.rb +19 -0
- data/app/mailboxes/protege/agent_mailbox.rb +123 -0
- data/app/mailers/protege/application_mailer.rb +65 -0
- data/app/models/concerns/protege/broadcastable_email_domain.rb +48 -0
- data/app/models/concerns/protege/broadcastable_message.rb +47 -0
- data/app/models/concerns/protege/broadcastable_persona.rb +50 -0
- data/app/models/concerns/protege/broadcastable_responsibility.rb +51 -0
- data/app/models/concerns/protege/broadcastable_thread.rb +55 -0
- data/app/models/concerns/protege/tool_scoped.rb +88 -0
- data/app/models/protege/access_rule.rb +50 -0
- data/app/models/protege/application_record.rb +18 -0
- data/app/models/protege/current.rb +23 -0
- data/app/models/protege/email_domain.rb +136 -0
- data/app/models/protege/email_thread.rb +81 -0
- data/app/models/protege/message.rb +401 -0
- data/app/models/protege/persona.rb +229 -0
- data/app/models/protege/responsibility.rb +98 -0
- data/app/models/protege/responsibility_run.rb +68 -0
- data/app/models/protege/tool_use.rb +115 -0
- data/app/models/protege/trace.rb +71 -0
- data/app/providers/protege/open_router_provider.rb +317 -0
- data/app/resolvers/README.md +255 -0
- data/app/resolvers/protege/load_file_resolver.rb +86 -0
- data/app/resolvers/protege/load_record_resolver.rb +80 -0
- data/app/resolvers/protege/load_text_resolver.rb +40 -0
- data/app/resolvers/protege/thread_history_resolver.rb +116 -0
- data/app/services/protege/introspection_tool_call_presentation.rb +134 -0
- data/app/services/protege/message_search.rb +114 -0
- data/app/services/protege/trace_search.rb +102 -0
- data/app/subscribers/protege/introspection_broadcaster.rb +324 -0
- data/app/tools/protege/create_file_tool.rb +109 -0
- data/app/tools/protege/read_attachment_tool.rb +101 -0
- data/app/tools/protege/search_emails_tool.rb +129 -0
- data/app/tools/protege/send_email_tool.rb +240 -0
- data/app/tools/protege/web_fetch_tool.rb +224 -0
- data/app/tools/protege/web_search_tool.rb +131 -0
- data/app/views/layouts/protege/application.html.slim +19 -0
- data/app/views/protege/access_rules/create.turbo_stream.slim +6 -0
- data/app/views/protege/email_domains/_email_domain_sidebar_item.html.slim +4 -0
- data/app/views/protege/email_domains/_form.html.slim +10 -0
- data/app/views/protege/email_domains/_sidebar.html.slim +11 -0
- data/app/views/protege/email_domains/index.html.slim +6 -0
- data/app/views/protege/email_domains/new.html.slim +7 -0
- data/app/views/protege/email_domains/show.html.slim +36 -0
- data/app/views/protege/home/show.html.slim +26 -0
- data/app/views/protege/messages/_message.html.slim +19 -0
- data/app/views/protege/messages/_sidebar.html.slim +13 -0
- data/app/views/protege/messages/index.html.slim +20 -0
- data/app/views/protege/personas/_access_rule.html.slim +7 -0
- data/app/views/protege/personas/_access_rule_form.html.slim +8 -0
- data/app/views/protege/personas/_access_rules.html.slim +19 -0
- data/app/views/protege/personas/_actions.html.slim +15 -0
- data/app/views/protege/personas/_form.html.slim +29 -0
- data/app/views/protege/personas/_persona_sidebar_item.html.slim +5 -0
- data/app/views/protege/personas/_sidebar.html.slim +11 -0
- data/app/views/protege/personas/edit.html.slim +7 -0
- data/app/views/protege/personas/index.html.slim +6 -0
- data/app/views/protege/personas/new.html.slim +7 -0
- data/app/views/protege/personas/show.html.slim +37 -0
- data/app/views/protege/responsibilities/_form.html.slim +21 -0
- data/app/views/protege/responsibilities/_responsibility_sidebar_item.html.slim +5 -0
- data/app/views/protege/responsibilities/_sidebar.html.slim +11 -0
- data/app/views/protege/responsibilities/edit.html.slim +7 -0
- data/app/views/protege/responsibilities/index.html.slim +6 -0
- data/app/views/protege/responsibilities/new.html.slim +7 -0
- data/app/views/protege/responsibilities/show.html.slim +22 -0
- data/app/views/protege/responsibility_runs/show.html.slim +13 -0
- data/app/views/protege/shared/_confirm_dialog.html.slim +22 -0
- data/app/views/protege/shared/_header.html.slim +14 -0
- data/app/views/protege/shared/_hotkeys.html.slim +43 -0
- data/app/views/protege/threads/_composer_footer.html.slim +15 -0
- data/app/views/protege/threads/_introspection.html.slim +7 -0
- data/app/views/protege/threads/_reasoning.html.slim +4 -0
- data/app/views/protege/threads/_reply_form.html.slim +7 -0
- data/app/views/protege/threads/_run_marker.html.slim +3 -0
- data/app/views/protege/threads/_sidebar.html.slim +15 -0
- data/app/views/protege/threads/_thread.html.slim +6 -0
- data/app/views/protege/threads/_tool_call.html.slim +8 -0
- data/app/views/protege/threads/index.html.slim +6 -0
- data/app/views/protege/threads/new.html.slim +15 -0
- data/app/views/protege/threads/show.html.slim +25 -0
- data/app/views/protege/traces/_sidebar.html.slim +15 -0
- data/app/views/protege/traces/index.html.slim +11 -0
- data/app/views/protege/traces/searches/_sidebar.html.slim +13 -0
- data/app/views/protege/traces/searches/show.html.slim +22 -0
- data/app/views/protege/traces/show.html.slim +28 -0
- data/config/routes.rb +33 -0
- data/db/migrate/20260520000001_create_protege_tables.rb +105 -0
- data/db/migrate/20260706120130_create_protege_traces.rb +30 -0
- data/db/migrate/20260707120000_replace_persona_active_with_archived_at.rb +24 -0
- data/db/migrate/20260708120000_add_disabled_tool_ids_to_personas.rb +22 -0
- data/lib/generators/protege/extension_naming.rb +38 -0
- data/lib/generators/protege/hook/hook_generator.rb +33 -0
- data/lib/generators/protege/hook/templates/hook.rb.tt +36 -0
- data/lib/generators/protege/install/install_generator.rb +170 -0
- data/lib/generators/protege/install/templates/initializer.rb.tt +84 -0
- data/lib/generators/protege/persona/persona_generator.rb +35 -0
- data/lib/generators/protege/persona/templates/persona.rb.tt +31 -0
- data/lib/generators/protege/postfix/postfix_generator.rb +74 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/Dockerfile +43 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/KeyTable +3 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/MAIL.md +135 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/README.md +56 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/SigningTable +3 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/TrustedHosts +8 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/entrypoint.sh +118 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/kamal-accessory.example.yml +32 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/main.cf +68 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendkim.conf +36 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.conf +32 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.ignore.hosts +9 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/policyd-spf.conf +21 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/provision.py +182 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/relay-to-app.sh +28 -0
- data/lib/generators/protege/postfix/templates/github/workflows/build-mail-image.example.yml +50 -0
- data/lib/generators/protege/provider/provider_generator.rb +44 -0
- data/lib/generators/protege/provider/templates/provider.rb.tt +50 -0
- data/lib/generators/protege/resolver/resolver_generator.rb +34 -0
- data/lib/generators/protege/resolver/templates/resolver.rb.tt +31 -0
- data/lib/generators/protege/tool/templates/tool.rb.tt +50 -0
- data/lib/generators/protege/tool/tool_generator.rb +34 -0
- data/lib/protege/configuration.rb +171 -0
- data/lib/protege/engine.rb +103 -0
- data/lib/protege/errors/bad_request_error.rb +8 -0
- data/lib/protege/errors/contract_violation_error.rb +8 -0
- data/lib/protege/errors/error.rb +30 -0
- data/lib/protege/errors/generic_error.rb +7 -0
- data/lib/protege/errors/internal_error.rb +8 -0
- data/lib/protege/errors/invalid_model_error.rb +8 -0
- data/lib/protege/errors/invalid_tool_result_error.rb +9 -0
- data/lib/protege/errors/rate_limited_error.rb +8 -0
- data/lib/protege/errors/resolver_file_not_found_error.rb +9 -0
- data/lib/protege/errors/response_parse_failed_error.rb +8 -0
- data/lib/protege/errors/timeout_error.rb +8 -0
- data/lib/protege/errors/tool_not_available_error.rb +12 -0
- data/lib/protege/errors/tool_not_found_error.rb +9 -0
- data/lib/protege/errors/unauthorized_error.rb +8 -0
- data/lib/protege/errors/unavailable_error.rb +8 -0
- data/lib/protege/errors/unsupported_capability_error.rb +8 -0
- data/lib/protege/events/event.rb +98 -0
- data/lib/protege/events/inference_chunk_event.rb +17 -0
- data/lib/protege/events/inference_completed_event.rb +12 -0
- data/lib/protege/events/inference_failed_event.rb +12 -0
- data/lib/protege/events/inference_generated_event.rb +19 -0
- data/lib/protege/events/inference_max_turns_reached_event.rb +12 -0
- data/lib/protege/events/inference_started_event.rb +11 -0
- data/lib/protege/events/loop_run_completed_event.rb +11 -0
- data/lib/protege/events/loop_run_enqueued_event.rb +12 -0
- data/lib/protege/events/loop_run_failed_event.rb +12 -0
- data/lib/protege/events/loop_run_started_event.rb +11 -0
- data/lib/protege/events/tool_call_completed_event.rb +12 -0
- data/lib/protege/events/tool_call_failed_event.rb +12 -0
- data/lib/protege/events/tool_call_started_event.rb +11 -0
- data/lib/protege/events/tool_calls_received_event.rb +11 -0
- data/lib/protege/extensions/hook.rb +13 -0
- data/lib/protege/extensions/hook_mixin.rb +90 -0
- data/lib/protege/extensions/provider.rb +12 -0
- data/lib/protege/extensions/provider_mixin.rb +124 -0
- data/lib/protege/extensions/resolver.rb +12 -0
- data/lib/protege/extensions/resolver_mixin.rb +58 -0
- data/lib/protege/extensions/tool.rb +12 -0
- data/lib/protege/extensions/tool_mixin.rb +217 -0
- data/lib/protege/gateway/access_control.rb +66 -0
- data/lib/protege/gateway/access_policy.rb +94 -0
- data/lib/protege/gateway/attachment_policy.rb +115 -0
- data/lib/protege/gateway/mail/address.rb +145 -0
- data/lib/protege/gateway/mail/attachment.rb +17 -0
- data/lib/protege/gateway/mail/message_id.rb +104 -0
- data/lib/protege/gateway/mail/outbound.rb +181 -0
- data/lib/protege/gateway/mail/references.rb +107 -0
- data/lib/protege/gateway.rb +398 -0
- data/lib/protege/inference/provider/request.rb +110 -0
- data/lib/protege/inference/provider/response.rb +50 -0
- data/lib/protege/inference/provider/tool_call.rb +52 -0
- data/lib/protege/inference.rb +121 -0
- data/lib/protege/loop/schedule.rb +66 -0
- data/lib/protege/loop/scheduler.rb +42 -0
- data/lib/protege/loop.rb +37 -0
- data/lib/protege/orchestrator/context.rb +65 -0
- data/lib/protege/orchestrator/harness.rb +305 -0
- data/lib/protege/orchestrator/reply_context.rb +21 -0
- data/lib/protege/orchestrator/reply_harness.rb +68 -0
- data/lib/protege/orchestrator/resolver_chain.rb +93 -0
- data/lib/protege/orchestrator/responsibility_context.rb +22 -0
- data/lib/protege/orchestrator/responsibility_harness.rb +60 -0
- data/lib/protege/orchestrator.rb +37 -0
- data/lib/protege/subscribers/hook_dispatcher.rb +140 -0
- data/lib/protege/subscribers/tracing.rb +65 -0
- data/lib/protege/types/document_part.rb +13 -0
- data/lib/protege/types/image_part.rb +11 -0
- data/lib/protege/types/model_message.rb +40 -0
- data/lib/protege/types/result.rb +113 -0
- data/lib/protege/types/stored_file.rb +127 -0
- data/lib/protege/types/text_part.rb +8 -0
- data/lib/protege/types/tool_result.rb +16 -0
- data/lib/protege/version.rb +6 -0
- data/lib/protege.rb +47 -0
- data/lib/tasks/protege_tasks.rake +16 -0
- metadata +494 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Runs one inbound email through the full Protege inference pipeline (the "I" of LOGI).
|
|
5
|
+
#
|
|
6
|
+
# Enqueued by +AgentMailbox+ once persona routing succeeds. The job re-hydrates the raw email and
|
|
7
|
+
# persona from their IDs, ingests a +Message+, runs the Orchestrator's +Harness+, and records the
|
|
8
|
+
# outcome on the message. IDs (not objects) are passed across the async boundary so the queue
|
|
9
|
+
# adapter can serialize and safely retry the job across process restarts.
|
|
10
|
+
#
|
|
11
|
+
# The +correlation_id+ argument carries the trace token set in +AgentMailbox+; restoring it into
|
|
12
|
+
# +Protege::Current+ at the top of +perform+ keeps events consistent from HTTP ingress through
|
|
13
|
+
# inference. Failures are captured on the message before re-raising so the retry machinery and the
|
|
14
|
+
# UI both have full context.
|
|
15
|
+
class InferenceJob < ApplicationJob
|
|
16
|
+
queue_as :default
|
|
17
|
+
|
|
18
|
+
# Process one inbound email end to end: ingest, run inference, record the result.
|
|
19
|
+
#
|
|
20
|
+
# @param inbound_email_id [Integer] id of the +ActionMailbox::InboundEmail+ to process
|
|
21
|
+
# @param persona_id [Integer] primary key of the +Protege::Persona+; STI loads the subclass
|
|
22
|
+
# @param correlation_id [String] the RFC 2822 Message-ID set by +AgentMailbox+ for tracing
|
|
23
|
+
# @return [void]
|
|
24
|
+
# @raise [StandardError] re-raised after being recorded on the message, to trigger retries
|
|
25
|
+
def perform(inbound_email_id:, persona_id:, correlation_id:)
|
|
26
|
+
restore_correlation_id(correlation_id)
|
|
27
|
+
|
|
28
|
+
# Re-hydrate the raw email and the target persona from their IDs.
|
|
29
|
+
# IDs are passed rather than objects so Solid Queue can serialize
|
|
30
|
+
# the job and retry it safely across process restarts.
|
|
31
|
+
inbound_email = ActionMailbox::InboundEmail.find(inbound_email_id)
|
|
32
|
+
persona = Persona.find(persona_id)
|
|
33
|
+
|
|
34
|
+
# Parse the raw email, find-or-create the EmailThread, and persist
|
|
35
|
+
# a Protege::Message record with processing_status: :processing.
|
|
36
|
+
# Returns the saved message so we can update its lifecycle below.
|
|
37
|
+
message = Message.ingest!(inbound_email:, persona:)
|
|
38
|
+
|
|
39
|
+
process(message:, persona:)
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
# Capture the error on the message record before re-raising so the
|
|
42
|
+
# job can be inspected and manually re-queued without losing context.
|
|
43
|
+
message&.mark_failed!(e)
|
|
44
|
+
raise
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Run the message through the inference loop and record success.
|
|
50
|
+
#
|
|
51
|
+
# @param message [Protege::Message] the message to process
|
|
52
|
+
# @param persona [Protege::Persona] the persona handling it
|
|
53
|
+
# @return [void]
|
|
54
|
+
def process(message:, persona:)
|
|
55
|
+
Orchestrator.reply(persona:, message:)
|
|
56
|
+
|
|
57
|
+
message.mark_processed!
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Pushes the current mail provisioning manifest to the self-hosted MTA accessory. Enqueued from
|
|
5
|
+
# +EmailDomain+'s +after_commit+ so a domain save (create/update/destroy) isn't coupled to the
|
|
6
|
+
# accessory's availability — the push happens just after the record commits, and its retry covers a
|
|
7
|
+
# momentarily-down MTA.
|
|
8
|
+
#
|
|
9
|
+
# A no-op in provider mode: +Gateway.push_mail_provisioning+ returns early when no accessory is
|
|
10
|
+
# configured (no +MAIL_RELAY_HOST+), so this job is harmless when there is nothing to provision.
|
|
11
|
+
class MailProvisioningJob < ApplicationJob
|
|
12
|
+
queue_as :default
|
|
13
|
+
|
|
14
|
+
# Push the whole domain manifest to the mail accessory's control listener.
|
|
15
|
+
#
|
|
16
|
+
# @return [void]
|
|
17
|
+
def perform
|
|
18
|
+
Gateway.push_mail_provisioning
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Carries out one scheduled responsibility run — the Loop layer's worker (the "L" of LOGI driving the
|
|
5
|
+
# "I"). Enqueued by +Responsibility#dispatch!+ with the id of a +pending+ +ResponsibilityRun+; it
|
|
6
|
+
# advances that run to running, runs the persona through +ResponsibilityHarness+, and records the
|
|
7
|
+
# outcome on the run. The run id (not the object) is passed across the async boundary so the queue
|
|
8
|
+
# adapter can serialize the job.
|
|
9
|
+
#
|
|
10
|
+
# There is no inbound request to inherit a correlation id from, so the job mints a fresh UUID and
|
|
11
|
+
# restores it into +Protege::Current+ at the top of +perform+; the run snapshots it onto its own
|
|
12
|
+
# +correlation_id+ when it starts, tying the run record to the events it emits. Unlike the inbound
|
|
13
|
+
# jobs, a failure is recorded but **not re-raised**: a responsibility is cron-driven, so the next
|
|
14
|
+
# scheduled minute is its natural retry — letting ActiveJob rerun it would double-fire. (The harness
|
|
15
|
+
# still emits +inference_failed+, so failure hooks observe it.)
|
|
16
|
+
class ResponsibilityJob < ApplicationJob
|
|
17
|
+
queue_as :default
|
|
18
|
+
|
|
19
|
+
# Carry out one scheduled run: advance the run, run inference, record the result.
|
|
20
|
+
#
|
|
21
|
+
# @param run_id [Integer] id of the pending +ResponsibilityRun+ to carry out
|
|
22
|
+
# @return [void]
|
|
23
|
+
def perform(run_id:)
|
|
24
|
+
responsibility_run = ResponsibilityRun.eager_load(responsibility: :persona).find(run_id)
|
|
25
|
+
# Adopt the trace id minted at dispatch (Responsibility#dispatch!) so the whole run lifecycle
|
|
26
|
+
# shares it; fall back to a fresh one for a run created some other way (e.g. in a test).
|
|
27
|
+
restore_correlation_id(responsibility_run.correlation_id || SecureRandom.uuid)
|
|
28
|
+
|
|
29
|
+
responsibility = responsibility_run.responsibility
|
|
30
|
+
persona = responsibility.persona
|
|
31
|
+
|
|
32
|
+
process(responsibility_run:, responsibility:, persona:)
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
# Record the failure without re-raising — the cron schedule, not ActiveJob, drives the retry.
|
|
35
|
+
responsibility_run&.mark_failed!(e)
|
|
36
|
+
emit_loop_run_failed(responsibility_run, e)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Advance the run through its lifecycle around one +ResponsibilityHarness+ run.
|
|
42
|
+
#
|
|
43
|
+
# @param responsibility_run [Protege::ResponsibilityRun] the run to carry out
|
|
44
|
+
# @param responsibility [Protege::Responsibility] the responsibility to handle
|
|
45
|
+
# @param persona [Protege::Persona] the persona handling the responsibility
|
|
46
|
+
# @return [void]
|
|
47
|
+
def process(responsibility_run:, responsibility:, persona:)
|
|
48
|
+
responsibility_run.start!
|
|
49
|
+
LoopRunStartedEvent.emit(name: responsibility.name, persona_name: persona.name)
|
|
50
|
+
|
|
51
|
+
Orchestrator.run_responsibility(persona:, responsibility:, responsibility_run:)
|
|
52
|
+
|
|
53
|
+
responsibility_run.mark_completed!
|
|
54
|
+
LoopRunCompletedEvent.emit(name: responsibility.name, persona_name: persona.name)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Emit +loop_run_failed+ for a recorded failure — omitted when the run never loaded (e.g. the
|
|
58
|
+
# +find+ itself raised), since there is no responsibility to name.
|
|
59
|
+
#
|
|
60
|
+
# @param responsibility_run [Protege::ResponsibilityRun, nil] the failed run, if loaded
|
|
61
|
+
# @param error [Exception] the failure
|
|
62
|
+
# @return [void]
|
|
63
|
+
def emit_loop_run_failed(responsibility_run, error)
|
|
64
|
+
return unless responsibility_run
|
|
65
|
+
|
|
66
|
+
responsibility = responsibility_run.responsibility
|
|
67
|
+
LoopRunFailedEvent.emit(name: responsibility.name, persona_name: responsibility.persona.name, error:)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# The Loop layer's clock — a thin recurring job the host registers to run every minute (Solid Queue
|
|
5
|
+
# recurring, alongside any other recurring tasks). Each tick hands off to +Loop.dispatch+, which
|
|
6
|
+
# enqueues a +ResponsibilityJob+ for every responsibility due that minute. ActiveJob has no native
|
|
7
|
+
# recurring, so the recurrence itself is the host queue adapter's concern; the engine only ships this
|
|
8
|
+
# job class and the dispatch logic.
|
|
9
|
+
class ResponsibilityTickJob < ApplicationJob
|
|
10
|
+
queue_as :default
|
|
11
|
+
|
|
12
|
+
# Dispatch every responsibility due now.
|
|
13
|
+
#
|
|
14
|
+
# @return [void]
|
|
15
|
+
def perform
|
|
16
|
+
Loop.dispatch
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Inbound edge of the Gateway: routes every received email toward the inference pipeline.
|
|
5
|
+
#
|
|
6
|
+
# Action Mailbox delivers parsed mail here, exposing +mail+ and +inbound_email+ as instance
|
|
7
|
+
# methods and calling {#process} as the single entry point. The mailbox resolves the target
|
|
8
|
+
# persona by recipient address, then hands off to +Gateway.accept_smtp_message+, which enqueues
|
|
9
|
+
# +InferenceJob+. The inbound Message-ID is forwarded as the correlation ID so the trace token
|
|
10
|
+
# survives the async boundary and is restored inside the job.
|
|
11
|
+
#
|
|
12
|
+
# == Recursion guard
|
|
13
|
+
#
|
|
14
|
+
# Outbound mail carries an +X-Protege-Recursion+ budget header. Any inbound email whose budget has
|
|
15
|
+
# reached zero (or below) is silently dropped, preventing infinite loops when one persona replies
|
|
16
|
+
# to another. Unroutable mail (no matching persona) is bounced with an unrouted notice instead.
|
|
17
|
+
#
|
|
18
|
+
# == Access guardrail
|
|
19
|
+
#
|
|
20
|
+
# Once routed, the sender is checked against the persona's inbound access guardrail
|
|
21
|
+
# (+Gateway::AccessControl+): the committed global ceiling intersected with the persona's runtime rules. A
|
|
22
|
+
# sender that fails any layer is bounced with an access-denied notice and never reaches inference.
|
|
23
|
+
class AgentMailbox < ActionMailbox::Base
|
|
24
|
+
# Route the inbound email: drop on exhausted recursion budget, bounce when unrouted or when the
|
|
25
|
+
# sender fails the access guardrail, else accept.
|
|
26
|
+
#
|
|
27
|
+
# @return [void]
|
|
28
|
+
def process
|
|
29
|
+
return handle_recursion_limit if recursion_limit_reached?
|
|
30
|
+
return bounce_with_unrouted if persona.nil?
|
|
31
|
+
return bounce_with_access_denied unless sender_permitted?
|
|
32
|
+
return bounce_with_attachment_error if attachment_violation
|
|
33
|
+
|
|
34
|
+
Gateway.accept_smtp_message(inbound_email:, persona:)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Report whether the sender may reach the routed persona, per the access guardrail.
|
|
40
|
+
#
|
|
41
|
+
# @return [Boolean] true when every access layer permits the sender
|
|
42
|
+
def sender_permitted?
|
|
43
|
+
Gateway.permits?(persona:, address: mail.from.first)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Resolve and memoise the persona for the email's primary recipient.
|
|
47
|
+
#
|
|
48
|
+
# Uses +Persona.lookup+, which strips plus-tag subaddresses before matching.
|
|
49
|
+
#
|
|
50
|
+
# @return [Protege::Persona, nil] the routed persona, or nil when none matches
|
|
51
|
+
def persona
|
|
52
|
+
@persona ||= Protege::Persona.lookup(mail.to.first)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The attachment-limit breach for this email, or nil when its attachments are within the limits.
|
|
56
|
+
#
|
|
57
|
+
# Memoised (including the nil result) so +#process+ checks once and the bounce can reuse the reason.
|
|
58
|
+
#
|
|
59
|
+
# @return [String, nil] the limit-breach reason, or nil
|
|
60
|
+
def attachment_violation
|
|
61
|
+
@attachment_violation ||= begin
|
|
62
|
+
sizes = mail.attachments.map { _1.decoded.bytesize }
|
|
63
|
+
Protege.configuration.attachment_policy.violation(byte_sizes: sizes)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Report whether the +X-Protege-Recursion+ budget has been exhausted.
|
|
68
|
+
#
|
|
69
|
+
# @return [Boolean] true when the header is present and its value is zero or below
|
|
70
|
+
def recursion_limit_reached?
|
|
71
|
+
header = mail.header['X-Protege-Recursion']
|
|
72
|
+
header.present? && header.value.to_i <= 0
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Silently drop the message when the recursion budget is exhausted — no reply, no error.
|
|
76
|
+
#
|
|
77
|
+
# @return [void]
|
|
78
|
+
def handle_recursion_limit; end
|
|
79
|
+
|
|
80
|
+
# Bounce an unroutable email with an unrouted notice.
|
|
81
|
+
#
|
|
82
|
+
# Swallows any error raised while building or sending the bounce so a broken bounce template can
|
|
83
|
+
# never prevent the mailbox from completing cleanly — but logs it so the failure is visible rather
|
|
84
|
+
# than silently lost.
|
|
85
|
+
#
|
|
86
|
+
# @return [void]
|
|
87
|
+
def bounce_with_unrouted
|
|
88
|
+
bounce_with ApplicationMailer.new.unrouted_bounce(mail)
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
Protege.configuration.logger.error("[Protege] failed to send unrouted bounce: #{e.class}: #{e.message}")
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Bounce an email whose sender failed the access guardrail with an access-denied notice.
|
|
95
|
+
#
|
|
96
|
+
# Swallows any error raised while building or sending the bounce (logging it) for the same reason
|
|
97
|
+
# as +bounce_with_unrouted+: a broken bounce template must never prevent the mailbox from
|
|
98
|
+
# completing cleanly.
|
|
99
|
+
#
|
|
100
|
+
# @return [void]
|
|
101
|
+
def bounce_with_access_denied
|
|
102
|
+
bounce_with ApplicationMailer.new.access_denied_bounce(mail)
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
Protege.configuration.logger.error("[Protege] failed to send access-denied bounce: #{e.class}: #{e.message}")
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Bounce an email whose attachments breached the configured limits, with the breach reason.
|
|
109
|
+
#
|
|
110
|
+
# Swallows any error raised while building or sending the bounce (logging it) for the same reason
|
|
111
|
+
# as the other bounces: a broken bounce template must never prevent the mailbox from completing.
|
|
112
|
+
#
|
|
113
|
+
# @return [void]
|
|
114
|
+
def bounce_with_attachment_error
|
|
115
|
+
bounce_with ApplicationMailer.new.attachment_rejected_bounce(mail, reason: attachment_violation)
|
|
116
|
+
rescue StandardError => e
|
|
117
|
+
Protege.configuration.logger.error(
|
|
118
|
+
"[Protege] failed to send attachment-rejected bounce: #{e.class}: #{e.message}"
|
|
119
|
+
)
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Base mailer for the Protege engine and home of the Gateway's bounce notices.
|
|
5
|
+
#
|
|
6
|
+
# Sits at the outbound edge of the Gateway. +AgentMailbox+ returns these notices to Action Mailbox's
|
|
7
|
+
# +bounce_with+ when an inbound email can't be accepted — no matching persona ({#unrouted_bounce}) or
|
|
8
|
+
# a sender rejected by the access guardrail ({#access_denied_bounce}). The default +from:+ address is
|
|
9
|
+
# a deliberate placeholder: a host application should override it through
|
|
10
|
+
# +config.action_mailer.default_options+ in an initializer, or per-mailer, so outbound mail
|
|
11
|
+
# originates from a domain it actually controls.
|
|
12
|
+
class ApplicationMailer < ActionMailer::Base
|
|
13
|
+
# Placeholder sender; override in the host app so mail comes from a domain you control.
|
|
14
|
+
default from: 'from@example.com'
|
|
15
|
+
layout 'mailer'
|
|
16
|
+
|
|
17
|
+
# Bounce an inbound email that matched no persona back to its sender.
|
|
18
|
+
#
|
|
19
|
+
# Returned to +AgentMailbox#bounce_with_unrouted+, which hands it to Action Mailbox's +bounce_with+
|
|
20
|
+
# for delivery. The body is built inline (no template/layout) so a bounce can never fail to render.
|
|
21
|
+
#
|
|
22
|
+
# @param inbound [::Mail::Message] the undeliverable inbound email
|
|
23
|
+
# @return [Mail::Message] the bounce notice addressed to the original sender
|
|
24
|
+
def unrouted_bounce(inbound)
|
|
25
|
+
mail(
|
|
26
|
+
to: inbound.from,
|
|
27
|
+
subject: 'Undeliverable: no such recipient',
|
|
28
|
+
body: "Your message could not be delivered: there is no agent at this address.\n"
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Bounce an inbound email whose sender was rejected by the inbound access guardrail.
|
|
33
|
+
#
|
|
34
|
+
# Returned to +AgentMailbox#bounce_with_access_denied+. Like {#unrouted_bounce}, the body is inline
|
|
35
|
+
# so rendering can't fail. The wording is deliberately neutral — it states the message was not
|
|
36
|
+
# accepted without disclosing the policy that rejected it.
|
|
37
|
+
#
|
|
38
|
+
# @param inbound [::Mail::Message] the rejected inbound email
|
|
39
|
+
# @return [Mail::Message] the bounce notice addressed to the original sender
|
|
40
|
+
def access_denied_bounce(inbound)
|
|
41
|
+
mail(
|
|
42
|
+
to: inbound.from,
|
|
43
|
+
subject: 'Undeliverable: message rejected',
|
|
44
|
+
body: "Your message could not be delivered: this recipient is not accepting mail from you.\n"
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Bounce an inbound email whose attachments breached the configured limits.
|
|
49
|
+
#
|
|
50
|
+
# Returned to +AgentMailbox#bounce_with_attachment_rejected+. Like the other bounces, the body is
|
|
51
|
+
# inline so rendering can't fail. The +reason+ (from +Gateway::AttachmentPolicy+) is included so the sender
|
|
52
|
+
# knows what to fix — it discloses only the limit, not anything sensitive.
|
|
53
|
+
#
|
|
54
|
+
# @param inbound [::Mail::Message] the rejected inbound email
|
|
55
|
+
# @param reason [String] the limit-breach reason from +Gateway::AttachmentPolicy#violation+
|
|
56
|
+
# @return [Mail::Message] the bounce notice addressed to the original sender
|
|
57
|
+
def attachment_rejected_bounce(inbound, reason:)
|
|
58
|
+
mail(
|
|
59
|
+
to: inbound.from,
|
|
60
|
+
subject: 'Undeliverable: attachment rejected',
|
|
61
|
+
body: "Your message could not be delivered: #{reason}.\n"
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Drives real-time Turbo Stream broadcasting for +EmailDomain+ records — keeping the Domains sidebar
|
|
5
|
+
# live: the row is appended on create, replaced on update, and removed on destroy. First-page viewers
|
|
6
|
+
# subscribe via +turbo_stream_from "protege:email_domains"+ (see +SidebarHelper#sidebar_stream+).
|
|
7
|
+
module BroadcastableEmailDomain
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
# Turbo Stream channel for all +EmailDomain+ records.
|
|
11
|
+
# @return [String]
|
|
12
|
+
STREAM = 'protege:email_domains'
|
|
13
|
+
|
|
14
|
+
included do
|
|
15
|
+
after_create_commit :broadcast_email_domain_created
|
|
16
|
+
after_update_commit :broadcast_email_domain_updated
|
|
17
|
+
after_destroy_commit :broadcast_email_domain_removed
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Append this domain's row to the Domains sidebar on creation.
|
|
21
|
+
#
|
|
22
|
+
# @return [void]
|
|
23
|
+
def broadcast_email_domain_created
|
|
24
|
+
broadcast_append_to STREAM,
|
|
25
|
+
target: 'email_domains_list',
|
|
26
|
+
partial: 'protege/email_domains/email_domain_sidebar_item',
|
|
27
|
+
locals: { email_domain: self }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Replace this domain's sidebar row in place when its attributes change.
|
|
31
|
+
#
|
|
32
|
+
# @return [void]
|
|
33
|
+
def broadcast_email_domain_updated
|
|
34
|
+
broadcast_replace_to STREAM,
|
|
35
|
+
target: ActionView::RecordIdentifier.dom_id(self),
|
|
36
|
+
partial: 'protege/email_domains/email_domain_sidebar_item',
|
|
37
|
+
locals: { email_domain: self }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Remove this domain's sidebar row on destruction.
|
|
41
|
+
#
|
|
42
|
+
# @return [void]
|
|
43
|
+
def broadcast_email_domain_removed
|
|
44
|
+
broadcast_remove_to STREAM,
|
|
45
|
+
target: ActionView::RecordIdentifier.dom_id(self)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Drives real-time Turbo Stream broadcasting for +Message+ records.
|
|
5
|
+
#
|
|
6
|
+
# Mixed into +Message+, this concern wires Active Record commit callbacks to Turbo Streams so the
|
|
7
|
+
# web UI reflects mail activity live, with no polling. On create it prepends the new message to
|
|
8
|
+
# the open thread view and asks the owning +EmailThread+ (via +BroadcastableThread+) to refresh
|
|
9
|
+
# its inbox row. On update it replaces the message partial in place, so later attribute changes
|
|
10
|
+
# (e.g. an edited body) appear immediately. (Run liveness — reasoning and tool use — is not shown on
|
|
11
|
+
# the message; it streams into the console's introspection panel.)
|
|
12
|
+
module BroadcastableMessage
|
|
13
|
+
extend ActiveSupport::Concern
|
|
14
|
+
|
|
15
|
+
included do
|
|
16
|
+
after_create_commit :broadcast_message_created
|
|
17
|
+
after_update_commit :broadcast_message_updated
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Broadcast a newly created message into the thread view and refresh its inbox row.
|
|
23
|
+
#
|
|
24
|
+
# Prepends the message partial to the thread's messages target (newest first), then delegates
|
|
25
|
+
# to the thread to update its sidebar row.
|
|
26
|
+
#
|
|
27
|
+
# @return [void]
|
|
28
|
+
def broadcast_message_created
|
|
29
|
+
broadcast_prepend_to email_thread,
|
|
30
|
+
target: ActionView::RecordIdentifier.dom_id(email_thread, :messages),
|
|
31
|
+
partial: 'protege/messages/message',
|
|
32
|
+
locals: { message: self }
|
|
33
|
+
|
|
34
|
+
email_thread.broadcast_thread_updated
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Broadcast an in-place replacement of this message's partial when its attributes change.
|
|
38
|
+
#
|
|
39
|
+
# @return [void]
|
|
40
|
+
def broadcast_message_updated
|
|
41
|
+
broadcast_replace_to email_thread,
|
|
42
|
+
target: ActionView::RecordIdentifier.dom_id(self),
|
|
43
|
+
partial: 'protege/messages/message',
|
|
44
|
+
locals: { message: self }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Drives real-time Turbo Stream broadcasting for +Persona+ records — keeping the Personas sidebar
|
|
5
|
+
# live: the row is appended on create, replaced on update, and removed on destroy. First-page viewers
|
|
6
|
+
# subscribe via +turbo_stream_from "protege:personas"+ (see +SidebarHelper#sidebar_stream+).
|
|
7
|
+
module BroadcastablePersona
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
# Turbo Stream channel for all +Persona+ records.
|
|
11
|
+
# @return [String]
|
|
12
|
+
STREAM = 'protege:personas'
|
|
13
|
+
|
|
14
|
+
included do
|
|
15
|
+
after_create_commit :broadcast_persona_created
|
|
16
|
+
after_update_commit :broadcast_persona_updated
|
|
17
|
+
after_destroy_commit :broadcast_persona_removed
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Append this persona's row to the Personas sidebar on creation.
|
|
23
|
+
#
|
|
24
|
+
# @return [void]
|
|
25
|
+
def broadcast_persona_created
|
|
26
|
+
broadcast_append_to STREAM,
|
|
27
|
+
target: 'personas_list',
|
|
28
|
+
partial: 'protege/personas/persona_sidebar_item',
|
|
29
|
+
locals: { persona: self }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Replace this persona's sidebar row in place when its attributes change.
|
|
33
|
+
#
|
|
34
|
+
# @return [void]
|
|
35
|
+
def broadcast_persona_updated
|
|
36
|
+
broadcast_replace_to STREAM,
|
|
37
|
+
target: ActionView::RecordIdentifier.dom_id(self),
|
|
38
|
+
partial: 'protege/personas/persona_sidebar_item',
|
|
39
|
+
locals: { persona: self }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Remove this persona's sidebar row on destruction.
|
|
43
|
+
#
|
|
44
|
+
# @return [void]
|
|
45
|
+
def broadcast_persona_removed
|
|
46
|
+
broadcast_remove_to STREAM,
|
|
47
|
+
target: ActionView::RecordIdentifier.dom_id(self)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Drives real-time Turbo Stream broadcasting for +Responsibility+ records — keeping the (global)
|
|
5
|
+
# Responsibilities sidebar live: the row is appended on create, replaced on update, and removed on
|
|
6
|
+
# destroy. First-page viewers subscribe via +turbo_stream_from "protege:responsibilities"+ (see
|
|
7
|
+
# +SidebarHelper#sidebar_stream+).
|
|
8
|
+
module BroadcastableResponsibility
|
|
9
|
+
extend ActiveSupport::Concern
|
|
10
|
+
|
|
11
|
+
# Turbo Stream channel for all +Responsibility+ records.
|
|
12
|
+
# @return [String]
|
|
13
|
+
STREAM = 'protege:responsibilities'
|
|
14
|
+
|
|
15
|
+
included do
|
|
16
|
+
after_create_commit :broadcast_responsibility_created
|
|
17
|
+
after_update_commit :broadcast_responsibility_updated
|
|
18
|
+
after_destroy_commit :broadcast_responsibility_removed
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# Append this duty's row to the Responsibilities sidebar on creation.
|
|
24
|
+
#
|
|
25
|
+
# @return [void]
|
|
26
|
+
def broadcast_responsibility_created
|
|
27
|
+
broadcast_append_to STREAM,
|
|
28
|
+
target: 'responsibilities_list',
|
|
29
|
+
partial: 'protege/responsibilities/responsibility_sidebar_item',
|
|
30
|
+
locals: { responsibility: self }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Replace this duty's sidebar row in place when its attributes change.
|
|
34
|
+
#
|
|
35
|
+
# @return [void]
|
|
36
|
+
def broadcast_responsibility_updated
|
|
37
|
+
broadcast_replace_to STREAM,
|
|
38
|
+
target: ActionView::RecordIdentifier.dom_id(self),
|
|
39
|
+
partial: 'protege/responsibilities/responsibility_sidebar_item',
|
|
40
|
+
locals: { responsibility: self }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Remove this duty's sidebar row on destruction.
|
|
44
|
+
#
|
|
45
|
+
# @return [void]
|
|
46
|
+
def broadcast_responsibility_removed
|
|
47
|
+
broadcast_remove_to STREAM,
|
|
48
|
+
target: ActionView::RecordIdentifier.dom_id(self)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Drives real-time Turbo Stream broadcasting for +EmailThread+ records — keeping the inbox sidebar
|
|
5
|
+
# live as threads come, change, and go.
|
|
6
|
+
#
|
|
7
|
+
# On create the thread's row is prepended to the list (newest-first); on update its row is replaced
|
|
8
|
+
# in place (also invoked directly by +BroadcastableMessage+ when a new message arrives, so the row
|
|
9
|
+
# reflects the latest activity and counts); on destroy the row is removed. First-page viewers subscribe
|
|
10
|
+
# via +turbo_stream_from "protege:threads"+ (see +SidebarHelper#sidebar_stream+).
|
|
11
|
+
module BroadcastableThread
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
|
|
14
|
+
# Turbo Stream channel for all +EmailThread+ records.
|
|
15
|
+
# @return [String]
|
|
16
|
+
STREAM = 'protege:threads'
|
|
17
|
+
|
|
18
|
+
included do
|
|
19
|
+
after_create_commit :broadcast_thread_created
|
|
20
|
+
after_update_commit :broadcast_thread_updated
|
|
21
|
+
after_destroy_commit :broadcast_thread_removed
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Prepend this thread's row to the inbox list on creation.
|
|
25
|
+
#
|
|
26
|
+
# @return [void]
|
|
27
|
+
def broadcast_thread_created
|
|
28
|
+
broadcast_prepend_to STREAM,
|
|
29
|
+
target: 'threads_list',
|
|
30
|
+
partial: 'protege/threads/thread',
|
|
31
|
+
locals: { thread: self }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Broadcast an in-place replacement of this thread's inbox-sidebar row.
|
|
35
|
+
#
|
|
36
|
+
# Public by design so +BroadcastableMessage+ can trigger the refresh after a new message saves,
|
|
37
|
+
# not only on the thread's own attribute changes.
|
|
38
|
+
#
|
|
39
|
+
# @return [void]
|
|
40
|
+
def broadcast_thread_updated
|
|
41
|
+
broadcast_replace_to STREAM,
|
|
42
|
+
target: ActionView::RecordIdentifier.dom_id(self),
|
|
43
|
+
partial: 'protege/threads/thread',
|
|
44
|
+
locals: { thread: self }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Remove this thread's row from the inbox list on destruction.
|
|
48
|
+
#
|
|
49
|
+
# @return [void]
|
|
50
|
+
def broadcast_thread_removed
|
|
51
|
+
broadcast_remove_to STREAM,
|
|
52
|
+
target: ActionView::RecordIdentifier.dom_id(self)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|