support_desk 0.2.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +197 -0
  3. data/README.md +1131 -14
  4. data/app/assets/stylesheets/support_desk.css +10 -0
  5. data/app/controllers/support_desk/tickets_controller.rb +23 -1
  6. data/app/helpers/support_desk/engine_helper.rb +16 -0
  7. data/app/views/support_desk/console/tickets/_actions.html.erb +15 -0
  8. data/app/views/support_desk/console/tickets/_assignment.html.erb +16 -2
  9. data/app/views/support_desk/console/tickets/_composer.html.erb +27 -0
  10. data/app/views/support_desk/console/tickets/_context_card.html.erb +28 -0
  11. data/app/views/support_desk/console/tickets/_draft.html.erb +114 -0
  12. data/app/views/support_desk/console/tickets/_message.html.erb +34 -1
  13. data/app/views/support_desk/console/tickets/_ticket_row.html.erb +20 -0
  14. data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
  15. data/app/views/support_desk/console/tickets/show.html.erb +5 -0
  16. data/app/views/support_desk/tickets/_human_door.html.erb +24 -0
  17. data/app/views/support_desk/tickets/_ticket_row.html.erb +13 -0
  18. data/config/locales/support_desk.console.en.yml +63 -0
  19. data/config/locales/support_desk.console.es.yml +65 -0
  20. data/config/locales/support_desk.en.yml +15 -0
  21. data/config/locales/support_desk.es.yml +23 -0
  22. data/config/routes.rb +7 -1
  23. data/lib/generators/support_desk/assistant_generator.rb +193 -0
  24. data/lib/generators/support_desk/install_generator.rb +21 -3
  25. data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
  26. data/lib/generators/support_desk/templates/assistant/service.rb.erb +60 -0
  27. data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +72 -0
  28. data/lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb +69 -0
  29. data/lib/generators/support_desk/templates/create_support_desk_message_registrations.rb.erb +42 -0
  30. data/lib/generators/support_desk/templates/initializer.rb +33 -0
  31. data/lib/generators/support_desk/upgrade_generator.rb +25 -7
  32. data/lib/support_desk/assistant_policy.rb +213 -0
  33. data/lib/support_desk/brief.rb +283 -0
  34. data/lib/support_desk/configuration.rb +552 -2
  35. data/lib/support_desk/console.rb +290 -8
  36. data/lib/support_desk/context_card.rb +10 -1
  37. data/lib/support_desk/doctor.rb +171 -1
  38. data/lib/support_desk/engine.rb +16 -1
  39. data/lib/support_desk/errors.rb +37 -0
  40. data/lib/support_desk/events.rb +12 -5
  41. data/lib/support_desk/macros.rb +14 -1
  42. data/lib/support_desk/message_signatures.rb +20 -0
  43. data/lib/support_desk/models/assistant.rb +188 -0
  44. data/lib/support_desk/models/concerns/requester.rb +18 -0
  45. data/lib/support_desk/models/desk.rb +26 -3
  46. data/lib/support_desk/models/draft.rb +275 -0
  47. data/lib/support_desk/models/event.rb +15 -1
  48. data/lib/support_desk/models/message_registration.rb +12 -0
  49. data/lib/support_desk/models/ticket/assistance.rb +899 -0
  50. data/lib/support_desk/models/ticket.rb +406 -58
  51. data/lib/support_desk/outcome.rb +38 -0
  52. data/lib/support_desk/queue.rb +41 -5
  53. data/lib/support_desk/test_helpers.rb +152 -0
  54. data/lib/support_desk/timeline.rb +40 -11
  55. data/lib/support_desk/topic.rb +22 -0
  56. data/lib/support_desk/topic_tree.rb +7 -1
  57. data/lib/support_desk/transcript.rb +234 -0
  58. data/lib/support_desk/version.rb +1 -1
  59. data/lib/support_desk.rb +151 -0
  60. data/lib/tasks/support_desk.rake +57 -0
  61. metadata +33 -8
@@ -52,7 +52,12 @@ module SupportDesk
52
52
  # routing concern reads it at draw time and `OFFERED_AS` is checked
53
53
  # against it, because a verb added in two places is a console that
54
54
  # accepts a POST its own Drawer never routed (or the other way round).
55
- MEMBER_VERBS = %i[reply take assign hand_off release close reopen note change_topic].freeze
55
+ # The four the assistants added sit at the end, in the order a case
56
+ # meets them: decide her proposal, then switch her off or back on.
57
+ MEMBER_VERBS = %i[
58
+ reply take assign hand_off release close reopen note change_topic
59
+ send_draft reject_draft pause_assistant resume_assistant
60
+ ].freeze
56
61
 
57
62
  # The verbs that work on the QUEUE rather than on a case, and the HTTP
58
63
  # method each one is drawn with. `open_conversation` is the only write
@@ -79,9 +84,16 @@ module SupportDesk
79
84
  #
80
85
  # `take` and `assign` are both :assign — taking a case is assigning it
81
86
  # to yourself, which is the model's vocabulary, not two permissions.
87
+ #
88
+ # `send_draft` is its OWN entry rather than :reply, because
89
+ # `actions_for` offers it only while there is a proposal to send: mapped
90
+ # to :reply it would have accepted a POST naming a draft somebody had
91
+ # already decided about.
82
92
  OFFERED_AS = {
83
93
  reply: :reply, take: :assign, assign: :assign, hand_off: :hand_off, release: :release,
84
- close: :close, reopen: :reopen, note: :note, change_topic: :change_topic
94
+ close: :close, reopen: :reopen, note: :note, change_topic: :change_topic,
95
+ send_draft: :send_draft, reject_draft: :reject_draft,
96
+ pause_assistant: :pause_assistant, resume_assistant: :resume_assistant
85
97
  }.freeze
86
98
 
87
99
  # Everything a transition raises because of WHO asked, WHEN, or from WHAT
@@ -137,7 +149,9 @@ module SupportDesk
137
149
  helper_method :current_agent, :support_desk_record, :support_queue, :support_transcript,
138
150
  :console_ticket_path, :console_tickets_path, :console_file_path,
139
151
  :support_conversation_available?, :support_conversation_offered?,
140
- :support_conversation_topics, :support_conversation_sendable?
152
+ :support_conversation_topics, :support_conversation_sendable?,
153
+ :support_pending_draft, :support_assistant, :support_seen_turn,
154
+ :support_editing_draft, :support_console_timeline_sentence
141
155
  end
142
156
 
143
157
  # --- The verbs --------------------------------------------------------------
@@ -210,6 +224,66 @@ module SupportDesk
210
224
  attempt(:topic_changed) { @ticket.change_topic!(to: topic, by: current_agent, request: request) }
211
225
  end
212
226
 
227
+ # --- Her proposal, and the switch ------------------------------------------
228
+ #
229
+ # Four verbs, and only one of them is interesting. A draft is written by
230
+ # a machine and SENT BY A PERSON: they read it, they may rewrite it, and
231
+ # the requester sees their signature on it. Everything here exists to
232
+ # make that reading real — which is why a send carries the turn the page
233
+ # was rendered with, and why a mismatch comes back as the same screen
234
+ # rather than as a redirect that throws the reviewer's edit away.
235
+
236
+ # Send the proposal, verbatim or edited.
237
+ def send_draft
238
+ draft = support_reviewable_draft
239
+ return if draft.nil?
240
+
241
+ seen = params[:seen_turn]
242
+ return refuse(:invalid_input) unless seen.is_a?(String) && seen.present?
243
+
244
+ body = params[:body].is_a?(String) ? params[:body] : nil
245
+ # The model refuses an edit that is blank with no attachment, and it
246
+ # refuses it with an ArgumentError — a bug in the caller, not a flash.
247
+ # So the console asks the same question first, in the words an agent
248
+ # needs: an empty box is "write something", never "descártala".
249
+ return refuse(:blank_message) if body && body.strip.empty? && !draft.files_attached?
250
+
251
+ draft.send!(by: current_agent, seen_turn: seen, body: body, request: request)
252
+ flash[:notice] = support_console_t("flashes.draft_sent")
253
+ respond_to_transition
254
+ rescue SupportDesk::StaleTurn
255
+ refuse_stale_draft
256
+ rescue StandardError => error
257
+ raise unless support_console_rescuable?(error)
258
+
259
+ flash[:alert] = support_console_error_message(error)
260
+ respond_to_transition
261
+ end
262
+
263
+ # Throw it away, with a reason worth reading later: the rejections are
264
+ # what tell a host whether the assistant is ready for a higher level.
265
+ def reject_draft
266
+ draft = support_reviewable_draft
267
+ return if draft.nil?
268
+
269
+ attempt(:draft_rejected) do
270
+ draft.reject!(by: current_agent, reason: support_console_text(:reason), request: request)
271
+ end
272
+ end
273
+
274
+ # Switch her off on THIS case: a delicate conversation, a customer who
275
+ # has had enough, a thread somebody wants to handle themselves.
276
+ def pause_assistant
277
+ attempt(:assistant_paused) do
278
+ @ticket.pause_assistant!(by: current_agent, reason: support_console_text(:reason), request: request)
279
+ end
280
+ end
281
+
282
+ # Let her back in on this case.
283
+ def resume_assistant
284
+ attempt(:assistant_resumed) { @ticket.resume_assistant!(by: current_agent, request: request) }
285
+ end
286
+
213
287
  # The form for writing to somebody who hasn't written to us — "Escribir
214
288
  # a alguien". Renders with whatever the caller supplied: a requester
215
289
  # GlobalID from one of your own pages (a user's admin screen, a ride),
@@ -290,6 +364,70 @@ module SupportDesk
290
364
  scope
291
365
  end
292
366
 
367
+ # The proposal waiting on this case, or nil. What `show` renders the
368
+ # draft card from, and what the composer's edit mode is about.
369
+ def support_pending_draft(ticket = @ticket)
370
+ ticket&.pending_draft
371
+ end
372
+
373
+ # This desk's assistant, or nil. A view asks it to decide whether there
374
+ # is a switch to draw at all — never to decide what may be pressed.
375
+ def support_assistant(ticket = @ticket)
376
+ ticket&.assistant
377
+ end
378
+
379
+ # The turn the page is being rendered with, which every send carries
380
+ # back. A case that has never been touched still has one, so this is
381
+ # never nil and the form never has an empty hidden field.
382
+ def support_seen_turn(ticket = @ticket)
383
+ ticket&.assistant_turn
384
+ end
385
+
386
+ # The draft the composer is EDITING: the reviewer pressed "Editar"
387
+ # (`?compose=reply&draft=ID`), or a stale send came back as this screen
388
+ # with their text still in it. Nil unless it names the pending proposal
389
+ # AND this agent is offered the send — a composer in edit mode with no
390
+ # way to submit is worse than no edit mode at all.
391
+ def support_editing_draft(ticket = @ticket)
392
+ draft = support_pending_draft(ticket)
393
+ return nil if draft.nil?
394
+ return nil unless support_offered_actions(ticket).include?(:send_draft)
395
+
396
+ named = [ params[:draft], params[:draft_id] ].detect { |value| value.is_a?(String) && value.present? }
397
+ named == draft.id.to_s ? draft : nil
398
+ end
399
+
400
+ # One line for the timeline kinds the assistants added — "Lucía envió la
401
+ # propuesta de Rose" — from the event's own payload. Nil for everything
402
+ # else, so the partial falls back to the label-and-actor shape 0.2 had.
403
+ #
404
+ # It lives here rather than in the partial because the sentence is COPY:
405
+ # an ejected view must not have to carry a `case` over event kinds to
406
+ # keep saying the right thing in Spanish.
407
+ def support_console_timeline_sentence(entry, actor_name = nil)
408
+ return nil unless entry.event?
409
+
410
+ payload = entry.event.payload.is_a?(Hash) ? entry.event.payload : {}
411
+ assistant = support_console_assistant_name(payload["assistant"])
412
+
413
+ case entry.kind
414
+ when :escalated
415
+ support_console_t("timeline.escalated", actor: actor_name,
416
+ reason: support_console_reason_word(payload["reason"]))
417
+ when :human_requested then support_console_t("timeline.human_requested")
418
+ when :assistant_paused then support_console_t("timeline.assistant_paused", actor: actor_name)
419
+ when :assistant_resumed then support_console_t("timeline.assistant_resumed", actor: actor_name)
420
+ when :draft_sent
421
+ support_console_t("timeline.draft_sent#{"_edited" if payload["edited"]}",
422
+ actor: actor_name, assistant: assistant)
423
+ when :draft_rejected
424
+ support_console_t("timeline.draft_rejected", actor: actor_name, assistant: assistant)
425
+ when :assistant_withheld
426
+ support_console_t("timeline.assistant_withheld", assistant: assistant,
427
+ reason: support_console_withheld_word(payload["reason"]))
428
+ end
429
+ end
430
+
293
431
  # The default queue tab and the tickets behind it, for hosts that want
294
432
  # the obvious index. Entirely optional — everything it does is three
295
433
  # lines of Layer 1.
@@ -333,7 +471,7 @@ module SupportDesk
333
471
  # that no test notices until somebody counts.
334
472
  def support_queue_tickets
335
473
  support_queue.scope(@scope)
336
- .includes(:requester, :assignee, :desk, :subject, :opened_by,
474
+ .includes(:requester, :assignee, :desk, :subject, :opened_by, :pending_draft,
337
475
  conversation: { last_message: %i[sender author] })
338
476
  .limit(support_tickets_per_page)
339
477
  end
@@ -638,7 +776,9 @@ module SupportDesk
638
776
  offered = @ticket.actions_for(current_agent)
639
777
  return if offered.include?(OFFERED_AS.fetch(action_name.to_sym))
640
778
 
641
- flash[:alert] = support_console_t("errors.#{unavailable_reason}", holder: support_console_holder)
779
+ flash[:alert] = support_console_t("errors.#{unavailable_reason}",
780
+ holder: support_console_holder,
781
+ status: support_draft_status_word(support_named_draft))
642
782
  respond_to_transition
643
783
  end
644
784
 
@@ -647,12 +787,25 @@ module SupportDesk
647
787
  # taken first.
648
788
  def unavailable_reason
649
789
  return "closed_case" if @ticket.closed?
790
+ return draft_unavailable_reason if %i[send_draft reject_draft].include?(action_name.to_sym)
650
791
  return "unavailable_action" unless %i[reply hand_off].include?(action_name.to_sym)
651
792
  return "take_it_first" if @ticket.unassigned?
652
793
 
653
794
  "held_by_somebody_else"
654
795
  end
655
796
 
797
+ # Pressing the same button twice is the common way to get here, and
798
+ # "there is no proposal" is the wrong thing to read after sending one.
799
+ # The row the request names is the whole answer: still there and already
800
+ # decided, or gone.
801
+ def draft_unavailable_reason
802
+ named = support_named_draft
803
+ return "draft_already_reviewed" if named && !named.pending?
804
+ return "no_pending_draft" if @ticket.pending_draft.nil?
805
+
806
+ "unavailable_action"
807
+ end
808
+
656
809
  # Who has the case, for a refusal that names them.
657
810
  def support_console_holder
658
811
  @ticket&.assignee&.try(:support_agent_name) || support_console_t("assignment.nobody")
@@ -730,10 +883,113 @@ module SupportDesk
730
883
  # desk — never the one `?desk=` names. Those are different desks the
731
884
  # moment a host has two, and reading the parameter let a billing agent
732
885
  # be assigned to a case on another desk entirely.
886
+ # Values are `SupportDesk.actor_key(agent)` — a GlobalID, which says
887
+ # WHICH CLASS as well as which row. The pool is people and the desk's
888
+ # assistant, and their ids are drawn from different tables: a picker
889
+ # posting a bare "1" could mean either of them, and "assign the case to
890
+ # the machine" is not a mistake to make on a coin flip.
891
+ #
892
+ # A bare id still resolves, because a host may have written one into
893
+ # their own form — but only while exactly one member of the pool answers
894
+ # to it. Two matches is a refusal, not a guess.
733
895
  def support_console_agent(id = params[:agent_id])
734
896
  return nil if id.blank?
735
897
 
736
- @ticket.desk.agents.detect { |agent| agent.id.to_s == id.to_s }
898
+ key = id.to_s
899
+ pool = @ticket.desk.agents
900
+ exact = pool.detect { |agent| SupportDesk.actor_key(agent) == key }
901
+ return exact if exact
902
+
903
+ matches = pool.select { |agent| agent.id.to_s == key }
904
+ matches.one? ? matches.first : nil
905
+ end
906
+
907
+ # --- The proposal a request names -------------------------------------------
908
+
909
+ # The draft this request names, in ANY status. Deliberately not
910
+ # `drafts.pending`: a second submit of the same button has to be able to
911
+ # read "ya se envió" instead of "no hay ninguna propuesta", and only the
912
+ # row itself knows which.
913
+ def support_console_draft
914
+ raise InvalidInput, :invalid_input unless params[:draft_id].is_a?(String)
915
+
916
+ support_named_draft
917
+ end
918
+
919
+ # The same lookup for the REFUSALS, which run before the action and must
920
+ # never raise: a crafted `draft_id` on a case with nothing pending is a
921
+ # flash, not a 500.
922
+ def support_named_draft
923
+ return @support_named_draft if defined?(@support_named_draft)
924
+
925
+ id = params[:draft_id]
926
+ @support_named_draft = (@ticket.drafts.find_by(id: id) if id.is_a?(String) && id.present?)
927
+ rescue ActiveRecord::StatementInvalid
928
+ # A uuid-keyed host: an id that isn't one is an id nothing has.
929
+ @support_named_draft = nil
930
+ end
931
+
932
+ # The draft a decision may be made about, or nil with the refusal
933
+ # already rendered.
934
+ def support_reviewable_draft
935
+ draft = support_console_draft
936
+ if draft.nil?
937
+ refuse(:unknown_draft)
938
+ elsif !draft.pending?
939
+ refuse(:draft_already_reviewed, status: support_draft_status_word(draft))
940
+ else
941
+ return draft
942
+ end
943
+ nil
944
+ rescue InvalidInput => error
945
+ refuse(error.key)
946
+ nil
947
+ end
948
+
949
+ # What happened to a draft, as the word the flash reads: "ya se envió",
950
+ # "ya se descartó". Never the English status — a Spanish desk reading
951
+ # "ya se sent" is the bug this map exists to stop.
952
+ def support_draft_status_word(draft)
953
+ return nil if draft.nil?
954
+
955
+ support_console_t("draft.statuses.#{draft.status}", default: draft.status)
956
+ end
957
+
958
+ # The case moved between the render and the submit. A redirect here
959
+ # would throw away whatever the reviewer typed, so this is the SAME
960
+ # screen again with a 422: their text still in the composer, the CURRENT
961
+ # turn in the form, and the reason on top. There is no "send anyway" —
962
+ # reading the case again is the whole point.
963
+ def refuse_stale_draft
964
+ @ticket.reload
965
+ flash.now[:alert] = support_console_t("errors.stale_turn")
966
+ rerender_support_case
967
+ end
968
+
969
+ # The case screen again, as the host renders it: their own `show` runs,
970
+ # so whatever it sets up is set up, and the two things every bundled
971
+ # view needs are filled in when it didn't.
972
+ def rerender_support_case(status: :unprocessable_entity)
973
+ show if respond_to?(:show)
974
+ @actions ||= @ticket.actions_for(current_agent)
975
+ @context_card ||= @ticket.context_card
976
+ render :show, formats: [ :html ], status: status unless performed?
977
+ end
978
+
979
+ # The buttons this agent is offered on +ticket+, asked once per render.
980
+ # `show` usually set them already; a 422 re-render may not have.
981
+ def support_offered_actions(ticket = @ticket)
982
+ return @actions if ticket == @ticket && @actions
983
+
984
+ ticket.actions_for(current_agent)
985
+ end
986
+
987
+ # A free-text field a verb takes: text, or nothing. A Hash where a
988
+ # reason belongs is a crafted request, and `.to_s` on it would be
989
+ # written into an event payload forever.
990
+ def support_console_text(name)
991
+ value = params[name]
992
+ value.is_a?(String) ? value.presence : nil
737
993
  end
738
994
 
739
995
  def support_agent_name(agent)
@@ -757,8 +1013,8 @@ module SupportDesk
757
1013
 
758
1014
  # A refusal the console spotted before the model was asked (an empty
759
1015
  # message, an agent who isn't in the pool).
760
- def refuse(reason)
761
- flash[:alert] = support_console_t("errors.#{reason}")
1016
+ def refuse(reason, **interpolations)
1017
+ flash[:alert] = support_console_t("errors.#{reason}", **interpolations)
762
1018
  respond_to_transition
763
1019
  end
764
1020
 
@@ -812,6 +1068,32 @@ module SupportDesk
812
1068
  default: :"support_desk.console.errors.generic")
813
1069
  end
814
1070
 
1071
+ # Who proposed it, in a word. The payloads carry a key on one kind and
1072
+ # an actor key on another, and this desk's own assistant answers to
1073
+ # both; anything else is history (a host swapped assistants), so the
1074
+ # readable half of the token stands in rather than a GlobalID nobody
1075
+ # can read.
1076
+ def support_console_assistant_name(token)
1077
+ token = token.to_s
1078
+ assistant = @ticket&.assistant
1079
+ return assistant.name if assistant && [ assistant.key.to_s, SupportDesk.actor_key(assistant) ].include?(token)
1080
+ return token.humanize if token.match?(/\A[a-z0-9_]+\z/i)
1081
+
1082
+ @ticket&.desk&.name.to_s
1083
+ end
1084
+
1085
+ # Why a case was handed to a person, as a sentence rather than as the
1086
+ # token the model writes. A reason a host's own harness invented falls
1087
+ # back to itself — a strange word in the timeline beats no word at all.
1088
+ def support_console_reason_word(reason)
1089
+ support_console_t("escalation_reasons.#{reason}", default: reason.to_s.humanize)
1090
+ end
1091
+
1092
+ # Why she said nothing.
1093
+ def support_console_withheld_word(reason)
1094
+ support_console_t("withheld_reasons.#{reason}", default: reason.to_s.humanize)
1095
+ end
1096
+
815
1097
  def support_console_t(key, **interpolations)
816
1098
  I18n.t("support_desk.console.#{key}", **interpolations)
817
1099
  end
@@ -59,6 +59,14 @@ module SupportDesk
59
59
  Chats.avatar_for(requester)
60
60
  end
61
61
 
62
+ # Key/value pairs about the PERSON, from `Requester#support_context` —
63
+ # the same idea as #pairs, one row up: `pairs` describes the thing the
64
+ # case is about, these describe whoever is asking. Empty unless the host
65
+ # filled it in.
66
+ def requester_pairs
67
+ requester.try(:support_context) || {}
68
+ end
69
+
62
70
  # When this requester joined — context for "is this a new user?".
63
71
  def requester_since = requester.try(:created_at)
64
72
 
@@ -102,7 +110,8 @@ module SupportDesk
102
110
  requester: {
103
111
  name: requester_name,
104
112
  since: requester_since,
105
- open_tickets: requester_open_tickets
113
+ open_tickets: requester_open_tickets,
114
+ context: requester_pairs
106
115
  }
107
116
  }
108
117
  end
@@ -139,6 +139,71 @@ module SupportDesk
139
139
  ok_with("the console can look people up")
140
140
  end
141
141
 
142
+ if SupportDesk.config.assistants.any?
143
+ checks << check("assistants (config)") do
144
+ problems = []
145
+ warnings = []
146
+ SupportDesk.config.assistants.each_value do |assistant|
147
+ problems.concat(assistant.line_problems)
148
+ warnings << "#{assistant.key} has no max_turns — a loop with no bound is a loop" if
149
+ assistant.max_turns.nil?
150
+ warnings << "#{assistant.key} has no responds_within — nothing will notice if she goes quiet" if
151
+ assistant.responds_within.nil?
152
+ end
153
+ SupportDesk.config.desks.each_value do |desk|
154
+ key = desk.assistant_key
155
+ next if key.nil? || SupportDesk.config.assistant?(key)
156
+
157
+ problems << "desk #{desk.key} points at assistant #{key.inspect}, which isn't configured"
158
+ end
159
+ next fail_with(problems.join("; ")) if problems.any?
160
+ next warn_with(warnings.join("; ")) if warnings.any?
161
+
162
+ ok_with("#{SupportDesk.config.assistants.size} assistant(s) configured")
163
+ end
164
+
165
+ checks << check("assistant serialization") do
166
+ next ok_with("no assistant on any desk") if assistant_desks.empty?
167
+ next warn_with("the turn cannot exclude a requester message committing behind an answer on " \
168
+ "SQLite — use PostgreSQL or MySQL") if sqlite?
169
+
170
+ ok_with("#{ActiveRecord::Base.connection.adapter_name} takes the row locks the turn rests on")
171
+ end
172
+
173
+ checks << check("assistant turn subscriber") do
174
+ next ok_with("no assistant on any desk") if assistant_desks.empty?
175
+ next warn_with("nothing subscribes to :assistant_turn — no harness will ever answer. See the " \
176
+ "README's assistants section") if SupportDesk.subscribers[:assistant_turn].empty?
177
+
178
+ ok_with("#{SupportDesk.subscribers[:assistant_turn].size} subscriber(s)")
179
+ end
180
+
181
+ checks << check("assistant authorship") do
182
+ blank = assistant_desks.filter_map do |desk|
183
+ assistant = desk.assistant
184
+ assistant.key if Chats.display_name_for(assistant).blank?
185
+ end
186
+ next fail_with("chats has no display name for #{blank.join(", ")} — a signed message would go out " \
187
+ "unsigned; check `Chats.config.messager_display_name`") if blank.any?
188
+
189
+ ok_with("every assistant has a name chats can print")
190
+ end
191
+
192
+ checks << check("ai agents without policy") do
193
+ strays = SupportDesk.agent_class_names.select do |name|
194
+ klass = name.safe_constantize
195
+ next false if klass.nil? || klass == SupportDesk::Assistant
196
+ next false unless klass.respond_to?(:support_desk_agent_options)
197
+
198
+ klass.support_desk_agent_options[:kind] == :ai
199
+ end
200
+ next warn_with("#{strays.join(", ")} declares `kind: :ai` but isn't this gem's assistant — every " \
201
+ "support write by it, or to it, is refused") if strays.any?
202
+
203
+ ok_with("no unmanaged AI agents")
204
+ end
205
+ end
206
+
142
207
  checks << check("engine mount") do
143
208
  path = SupportDesk.root_path
144
209
  next warn_with("SupportDesk::Engine isn't mounted — requesters have nowhere to write") if path.nil?
@@ -187,7 +252,13 @@ module SupportDesk
187
252
  return [ check("database") { warn_with("support_desk tables are missing — run rails db:migrate") } ] unless
188
253
  tables?
189
254
 
190
- [
255
+ checks = [
256
+ check("message registrations") do
257
+ next fail_with("run rails g support_desk:upgrade and rails db:migrate before serving support writes") unless
258
+ MessageRegistration.table_exists?
259
+
260
+ ok_with("message registration receipts available")
261
+ end,
191
262
  check("conversations") do
192
263
  orphans = Ticket.where(conversation_id: nil).count
193
264
  next fail_with("#{orphans} ticket(s) without a conversation") if orphans.positive?
@@ -248,6 +319,105 @@ module SupportDesk
248
319
  ok_with("references are unique")
249
320
  end
250
321
  ]
322
+
323
+ checks.concat(assistant_invariant_checks)
324
+ checks
325
+ end
326
+
327
+ # The assistants' own invariants: nobody sitting on a case they may not
328
+ # work, nobody silently not answering, one pending proposal per case.
329
+ #
330
+ # Every one of them is asked of EVIDENCE — a seat, a timestamp, a row —
331
+ # and never of the policy's own verdict. A policy cannot page anybody
332
+ # about its own bug.
333
+ def assistant_invariant_checks
334
+ return [] unless assistants_migrated?
335
+ # Nothing about a feature nobody turned on (I1) — but "turned on" is
336
+ # not "configured right now". A host that removed her configuration
337
+ # still has the seats she is sitting on and the proposals she wrote,
338
+ # and the checks about THOSE are the ones that matter most on the way
339
+ # down (R6).
340
+ return [] if SupportDesk.config.assistants.empty? && !SupportDesk::Assistant.exists?
341
+
342
+ checks = []
343
+
344
+ SupportDesk.config.assistants.each_key do |key|
345
+ assistant = SupportDesk.assistant(key)
346
+ window = assistant&.responds_within
347
+ next if window.nil?
348
+
349
+ checks << check("assistant silence (#{key})") do
350
+ quiet = Ticket.open.assigned_to(assistant).awaiting_reply.waiting_over(window).count
351
+ next fail_with("#{quiet} case(s) have waited longer than #{window.inspect} for #{key} — is the " \
352
+ "harness running? `rake support_desk:release_silent_assistants` hands them over") if
353
+ quiet.positive?
354
+
355
+ ok_with("nobody is waiting on #{key}")
356
+ end
357
+ end
358
+
359
+ checks << check("assistant seats") do
360
+ seated = Ticket.open.held_by_assistants.to_a
361
+ wrong = seated.reject { |ticket| ticket.assistant_policy.may_hold? }
362
+ next fail_with("#{wrong.size} case(s) held by an assistant who may not hold them " \
363
+ "(#{wrong.first(3).map(&:reference).join(", ")}) — release them") if wrong.any?
364
+
365
+ ok_with("#{seated.size} seat(s), all of them allowed")
366
+ end
367
+
368
+ assistant_desks.each do |desk|
369
+ window = desk.assistant.responds_within
370
+ next if window.nil?
371
+
372
+ checks << check("assistant idle turns (#{desk.key})") do
373
+ idle = Ticket.open.for_desk(desk.key).assistant_idle_since((window * 3).ago).count
374
+ next warn_with("#{idle} case(s) have had no answer and no assistant action — the harness may be " \
375
+ "down; `rake support_desk:redispatch_assistant_turns` re-emits their turns") if
376
+ idle.positive?
377
+
378
+ ok_with("every turn has been picked up")
379
+ end
380
+ end
381
+
382
+ checks << check("drafts") do
383
+ duplicated = Draft.pending.group(:ticket_id).having("COUNT(*) > 1").count.size
384
+ next fail_with("#{duplicated} case(s) with more than one pending proposal") if duplicated.positive?
385
+
386
+ orphaned = Draft.pending.where(ticket_id: Ticket.closed.select(:id)).count
387
+ next warn_with("#{orphaned} pending proposal(s) on closed cases — a close expires them, so these " \
388
+ "predate 0.3 or were written by hand") if orphaned.positive?
389
+
390
+ ok_with("at most one pending proposal per case")
391
+ end
392
+
393
+ checks
394
+ end
395
+
396
+ # Whether this app is on SQLite, which has no row locks: it serializes
397
+ # writes, and a WAL snapshot reads straight through an open write
398
+ # transaction. Everything the turn rests on — the ticket's row lock and
399
+ # the conversation's — buys nothing there, because nothing waits on it.
400
+ def sqlite?
401
+ ActiveRecord::Base.connection.adapter_name.match?(/sqlite/i)
402
+ rescue StandardError
403
+ false
404
+ end
405
+
406
+ # The desks that actually have an assistant, as Desk records.
407
+ def assistant_desks
408
+ SupportDesk.config.desks.each_key.filter_map do |key|
409
+ desk = SupportDesk.desk(key)
410
+ desk if desk&.assistant?
411
+ end
412
+ rescue StandardError
413
+ []
414
+ end
415
+
416
+ def assistants_migrated?
417
+ Ticket.column_names.include?("human_required_at") &&
418
+ ActiveRecord::Base.connection.table_exists?(Draft.table_name)
419
+ rescue StandardError
420
+ false
251
421
  end
252
422
 
253
423
  def tables?
@@ -33,7 +33,7 @@ module SupportDesk
33
33
  SUPPORT_DESK_LIB = File.expand_path("support_desk", LIB_ROOT)
34
34
 
35
35
  ZEITWERK_IGNORED = %w[
36
- version.rb errors.rb events.rb topic.rb topic_tree.rb configuration.rb current.rb macros.rb engine.rb
36
+ message_signatures.rb version.rb errors.rb events.rb topic.rb topic_tree.rb configuration.rb current.rb macros.rb engine.rb
37
37
  console.rb console_routes.rb console_engine.rb
38
38
  ].freeze
39
39
 
@@ -111,6 +111,11 @@ module SupportDesk
111
111
  SupportDesk.subscribe_to_chats!
112
112
  end
113
113
 
114
+ initializer "support_desk.message_signatures" do
115
+ require_relative "message_signatures"
116
+ Chats.singleton_class.prepend(SupportDesk::MessageSignatures)
117
+ end
118
+
114
119
  # The checks that need the host's own classes loaded — the requester
115
120
  # class, and every `about:` class named in a topic tree. In to_prepare
116
121
  # (not an initializer) so they re-run after every code reload, which is
@@ -135,6 +140,16 @@ module SupportDesk
135
140
  # the worst shape for a bug. Found by the CarHey integration (#2).
136
141
  _desk = SupportDesk::Desk
137
142
 
143
+ # And the assistant, for the THIRD registry with the same shape:
144
+ # `acts_as_support_agent` registers a class when that class loads, and
145
+ # both `SupportDesk.agent_class?` and the doctor's "ai agents without
146
+ # policy" check read that registry. Under lazy autoloading nothing has
147
+ # referenced her before the first case is answered, so the doctor would
148
+ # report a healthy desk as having no assistant at all. A constant
149
+ # reference and nothing else: no row is created and no connection is
150
+ # needed to load the class.
151
+ _assistant = SupportDesk::Assistant
152
+
138
153
  SupportDesk.config.validate_classes! if SupportDesk.configured?
139
154
  end
140
155
  end
@@ -51,4 +51,41 @@ module SupportDesk
51
51
 
52
52
  # Raised when a requester is already at `config.max_open_tickets`.
53
53
  class TooManyOpenTickets < Error; end
54
+
55
+ # Raised when an AI-kind actor is not the desk's own SupportDesk::Assistant.
56
+ # A host model declared `acts_as_support_agent kind: :ai` never inherits
57
+ # human authority: every support write by it, or to it, is refused rather
58
+ # than quietly treated as a human's (see the README's assistants section).
59
+ class NotAnAssistant < NotAnAgent; end
60
+
61
+ # Raised when the assistant's policy forbids the verb on this case. It
62
+ # carries the policy that refused and the verb that was asked for, so a
63
+ # host can log the RULE rather than parse the sentence.
64
+ class AssistantNotAllowed < NotAllowed
65
+ attr_reader :policy, :verb
66
+
67
+ def initialize(policy, verb:, message: nil)
68
+ @policy = policy
69
+ @verb = verb.to_sym
70
+ super(message || default_message)
71
+ end
72
+
73
+ private
74
+
75
+ # "rose may not reply on T-AB12CD: topic payments caps rose at draft" —
76
+ # who, what, where, and the rule that decided it.
77
+ def default_message
78
+ return "an assistant may not #{@verb} here" if @policy.nil?
79
+
80
+ "#{@policy.assistant&.key || "the assistant"} may not #{@verb} on " \
81
+ "#{@policy.ticket&.reference || "this case"}: #{@policy.because}"
82
+ end
83
+ end
84
+
85
+ # Raised when an assistant acts on a turn that is no longer the case's:
86
+ # somebody (the requester, a human, the assistant itself) changed the case
87
+ # since it was read. A subclass of InvalidTransition, so a host that
88
+ # rescues that still catches it — and a late, retried or redelivered job
89
+ # writes nothing.
90
+ class StaleTurn < InvalidTransition; end
54
91
  end