support_desk 0.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +129 -0
- data/README.md +144 -11
- data/lib/generators/support_desk/install_generator.rb +9 -3
- data/lib/generators/support_desk/templates/create_support_desk_message_registrations.rb.erb +42 -0
- data/lib/generators/support_desk/upgrade_generator.rb +15 -7
- data/lib/support_desk/doctor.rb +30 -3
- data/lib/support_desk/engine.rb +6 -1
- data/lib/support_desk/message_signatures.rb +20 -0
- data/lib/support_desk/models/assistant.rb +43 -8
- data/lib/support_desk/models/draft.rb +9 -0
- data/lib/support_desk/models/message_registration.rb +12 -0
- data/lib/support_desk/models/ticket/assistance.rb +254 -30
- data/lib/support_desk/models/ticket.rb +117 -66
- data/lib/support_desk/transcript.rb +9 -12
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk.rb +50 -7
- data/lib/tasks/support_desk.rake +12 -1
- metadata +5 -2
|
@@ -59,6 +59,7 @@ module SupportDesk
|
|
|
59
59
|
# readonly record refuses to be destroyed. Deleting a ticket is the one
|
|
60
60
|
# thing that takes its timeline with it, and it needs no callbacks.
|
|
61
61
|
has_many :events, class_name: "SupportDesk::Event", inverse_of: :ticket, dependent: :delete_all
|
|
62
|
+
has_many :message_registrations, class_name: "SupportDesk::MessageRegistration", dependent: :delete_all
|
|
62
63
|
has_many :messages, through: :conversation, source: :messages
|
|
63
64
|
|
|
64
65
|
# The assistant surface — the turn, the policy gates, her two verbs and
|
|
@@ -341,10 +342,14 @@ module SupportDesk
|
|
|
341
342
|
|
|
342
343
|
# A loaded instance may predate revocation or deletion. Check the row,
|
|
343
344
|
# just as requester eligibility does; this does not serialize revocation.
|
|
345
|
+
#
|
|
346
|
+
# Returns the FRESH record, so a caller that goes on to read a runtime
|
|
347
|
+
# flag off it (the assistant's `active?`, which the policy reads) is
|
|
348
|
+
# reading the row and not the copy it was handed (R9).
|
|
344
349
|
def ensure_agent_record!(record) # :nodoc:
|
|
345
350
|
if record.respond_to?(:persisted?) && record.persisted? && record.respond_to?(:support_agent?)
|
|
346
351
|
current = record.class.uncached { record.class.find_by(id: record.id) }
|
|
347
|
-
return if current&.support_agent?
|
|
352
|
+
return current if current&.support_agent?
|
|
348
353
|
end
|
|
349
354
|
|
|
350
355
|
raise NotAnAgent, "#{record.class} is not a currently eligible, persisted support agent — " \
|
|
@@ -670,12 +675,10 @@ module SupportDesk
|
|
|
670
675
|
# running before any of it.
|
|
671
676
|
def reply_into!(ticket, message, files:, by:, by_support:, request:, authorize_reuse:)
|
|
672
677
|
if by_support
|
|
673
|
-
#
|
|
674
|
-
#
|
|
675
|
-
# hold is the one this lock reads, because there was never an
|
|
676
|
-
# earlier one to give her.
|
|
678
|
+
# Only this private outreach path supplies the current turn internally.
|
|
679
|
+
# Public replies must present the token their harness observed.
|
|
677
680
|
ticket.send(:reply_under_lock!, message, by: by, files: files, request: request,
|
|
678
|
-
authorize: authorize_reuse,
|
|
681
|
+
authorize: authorize_reuse, outreach: true)
|
|
679
682
|
else
|
|
680
683
|
ticket.with_lock(requires_new: true) do
|
|
681
684
|
authorize_reuse&.call(ticket)
|
|
@@ -863,6 +866,7 @@ module SupportDesk
|
|
|
863
866
|
def reply!(body = nil, by: nil, files: [], request: nil, metadata: {}, turn: nil)
|
|
864
867
|
actor = resolve_actor(by)
|
|
865
868
|
ensure_agent!(actor)
|
|
869
|
+
reconcile_and_commit! if SupportDesk.ai_actor?(actor)
|
|
866
870
|
|
|
867
871
|
reply_under_lock!(body, by: actor, files: files, request: request, metadata: metadata, turn: turn)
|
|
868
872
|
end
|
|
@@ -872,11 +876,15 @@ module SupportDesk
|
|
|
872
876
|
def note!(body, by: nil, request: nil, turn: nil)
|
|
873
877
|
actor = resolve_actor(by)
|
|
874
878
|
ensure_agent!(actor)
|
|
875
|
-
|
|
879
|
+
machine = SupportDesk.ai_actor?(actor)
|
|
876
880
|
raise ArgumentError, "a note needs something to say" if body.blank?
|
|
877
881
|
|
|
882
|
+
assistant = nil
|
|
878
883
|
event = write_transition!(:note, actor: actor, request: request) do
|
|
879
|
-
if
|
|
884
|
+
if machine
|
|
885
|
+
# Under the lock, from the row: a kill switch that commits while
|
|
886
|
+
# this call waits for the case is a kill switch that stops it (R9).
|
|
887
|
+
assistant = resolve_assistant!(actor)
|
|
880
888
|
ensure_current_turn!(turn)
|
|
881
889
|
policy = assistant_policy(assistant)
|
|
882
890
|
raise AssistantNotAllowed.new(policy, verb: :note) unless policy.may_observe?
|
|
@@ -891,10 +899,9 @@ module SupportDesk
|
|
|
891
899
|
# Give the ticket to an agent. `assign!(to: lucia, by: lucia)` is
|
|
892
900
|
# somebody taking it; `assign!(to: pedro, by: admin)` is somebody being
|
|
893
901
|
# handed it. Repeating an assignment to the current holder does nothing.
|
|
894
|
-
def assign!(to:, by: nil, reason: nil, note: nil, request: nil)
|
|
902
|
+
def assign!(to:, by: nil, reason: nil, note: nil, request: nil, turn: nil)
|
|
895
903
|
actor = resolve_actor(by)
|
|
896
904
|
ensure_agent!(actor)
|
|
897
|
-
ensure_assistant_may_assign!(actor, to)
|
|
898
905
|
ensure_assignable!(to)
|
|
899
906
|
|
|
900
907
|
reason ||= to == actor ? :taken : :assigned
|
|
@@ -906,6 +913,13 @@ module SupportDesk
|
|
|
906
913
|
|
|
907
914
|
event = write_transition!(:assigned, actor: actor, request: request) do
|
|
908
915
|
raise InvalidTransition, "can't assign a closed ticket — reopen it first" if closed?
|
|
916
|
+
# Every mutable question about a seat is asked HERE, under the row
|
|
917
|
+
# lock, from the reloaded row: who holds the case, what her policy
|
|
918
|
+
# says, which turn she is on. Asked before the lock (0.3.0) they
|
|
919
|
+
# were answers about a case that has since moved, and a stale
|
|
920
|
+
# instance could take a seat a person had already been given (R2).
|
|
921
|
+
ensure_assistant_may_assign!(actor, to, turn: turn)
|
|
922
|
+
ensure_assistant_may_hold!(to)
|
|
909
923
|
next false if assigned_to?(to)
|
|
910
924
|
|
|
911
925
|
assignment = Assignment.open!(ticket: self, agent: to, by: actor, reason: reason, note: note)
|
|
@@ -946,6 +960,7 @@ module SupportDesk
|
|
|
946
960
|
"(#{assignee ? describe_actor(assignee) : "nobody"} does) — use assign! to override"
|
|
947
961
|
end
|
|
948
962
|
raise InvalidTransition, "can't hand off a closed ticket" if closed?
|
|
963
|
+
ensure_assistant_may_hold!(to)
|
|
949
964
|
next false if assigned_to?(to)
|
|
950
965
|
|
|
951
966
|
from = assignee
|
|
@@ -969,14 +984,18 @@ module SupportDesk
|
|
|
969
984
|
end
|
|
970
985
|
|
|
971
986
|
# Put the ticket back in the unassigned pile.
|
|
972
|
-
def release!(by: nil, reason: :released, request: nil)
|
|
987
|
+
def release!(by: nil, reason: :released, request: nil, turn: nil)
|
|
973
988
|
actor = resolve_actor(by)
|
|
974
989
|
ensure_agent!(actor)
|
|
975
|
-
assistant = (resolve_assistant!(actor) if SupportDesk.ai_actor?(actor))
|
|
976
990
|
from = nil
|
|
977
991
|
event = write_transition!(:released, actor: actor, request: request) do
|
|
978
992
|
raise InvalidTransition, "can't release a closed ticket" if closed?
|
|
993
|
+
assistant = (resolve_assistant!(actor) if SupportDesk.ai_actor?(actor))
|
|
979
994
|
if assistant
|
|
995
|
+
# Giving a seat back is an action like any other, so it holds the
|
|
996
|
+
# turn it read: a run that finished after the case moved on must
|
|
997
|
+
# not release the seat a newer one took (R2).
|
|
998
|
+
ensure_current_turn!(turn)
|
|
980
999
|
policy = assistant_policy(assistant)
|
|
981
1000
|
raise AssistantNotAllowed.new(policy, verb: :release) unless policy.may_observe?
|
|
982
1001
|
# Her own seat, and only hers: putting a PERSON's case back in the
|
|
@@ -1000,10 +1019,15 @@ module SupportDesk
|
|
|
1000
1019
|
def close!(by: nil, request: nil, turn: nil)
|
|
1001
1020
|
actor = resolve_actor(by)
|
|
1002
1021
|
ensure_agent!(actor)
|
|
1003
|
-
|
|
1022
|
+
machine = SupportDesk.ai_actor?(actor)
|
|
1023
|
+
reconcile_and_commit! if machine
|
|
1004
1024
|
|
|
1005
1025
|
event = write_transition!(:closed, actor: actor, request: request) do
|
|
1006
|
-
if
|
|
1026
|
+
if machine
|
|
1027
|
+
# Resolved under the lock, so the kill switch is read after the
|
|
1028
|
+
# wait rather than before it (R9).
|
|
1029
|
+
assistant = resolve_assistant!(actor)
|
|
1030
|
+
lock_conversation!
|
|
1007
1031
|
reconcile_unregistered_messages!
|
|
1008
1032
|
ensure_current_turn!(turn)
|
|
1009
1033
|
policy = assistant_policy(assistant)
|
|
@@ -1327,22 +1351,30 @@ module SupportDesk
|
|
|
1327
1351
|
end
|
|
1328
1352
|
|
|
1329
1353
|
ensure_agent!(agent)
|
|
1354
|
+
end
|
|
1355
|
+
|
|
1356
|
+
# "Could she hold this case once the human-side flags were lifted?" —
|
|
1357
|
+
# the hand-back question, so the very flags a hand-back exists to clear
|
|
1358
|
+
# are not what refuses it. Her autonomy, the topic and the host's cap
|
|
1359
|
+
# block still decide.
|
|
1360
|
+
#
|
|
1361
|
+
# Asked UNDER THE LOCK (R2): a cap that lands while a take waits for the
|
|
1362
|
+
# row is the cap that applies to it.
|
|
1363
|
+
def ensure_assistant_may_hold!(agent)
|
|
1330
1364
|
return unless SupportDesk.ai_actor?(agent)
|
|
1331
1365
|
|
|
1332
|
-
# "Could she hold this case once the human-side flags were lifted?" —
|
|
1333
|
-
# the hand-back question, so the very flags a hand-back exists to
|
|
1334
|
-
# clear are not what refuses it. Her autonomy, the topic and the host's
|
|
1335
|
-
# cap block still decide.
|
|
1336
1366
|
policy = assistant_policy(agent, hand_back: true)
|
|
1337
1367
|
raise AssistantNotAllowed.new(policy, verb: :take) unless policy.may_hold?
|
|
1338
1368
|
end
|
|
1339
1369
|
|
|
1340
1370
|
# An assistant may take a case, and that is all: only herself, only when
|
|
1341
|
-
# nobody holds it,
|
|
1342
|
-
|
|
1371
|
+
# nobody holds it, only at a level that may answer, and only holding the
|
|
1372
|
+
# turn she read. Called under the row lock, from the reloaded row.
|
|
1373
|
+
def ensure_assistant_may_assign!(actor, to, turn:)
|
|
1343
1374
|
return unless SupportDesk.ai_actor?(actor)
|
|
1344
1375
|
|
|
1345
1376
|
assistant = resolve_assistant!(actor)
|
|
1377
|
+
ensure_current_turn!(turn)
|
|
1346
1378
|
policy = assistant_policy(assistant)
|
|
1347
1379
|
unless self.class.same_actor?(actor, to)
|
|
1348
1380
|
raise AssistantNotAllowed.new(policy, verb: :assign,
|
|
@@ -1402,12 +1434,16 @@ module SupportDesk
|
|
|
1402
1434
|
# `authorize:` is the console's hook (see Ticket.open_or_reply!): it runs
|
|
1403
1435
|
# under this lock, before any policy side effect, and a raise there rolls
|
|
1404
1436
|
# the whole thing back.
|
|
1405
|
-
def reply_under_lock!(body, by:, files:, request:, authorize: nil, metadata: {}, turn: nil)
|
|
1437
|
+
def reply_under_lock!(body, by:, files:, request:, authorize: nil, metadata: {}, turn: nil, outreach: false)
|
|
1406
1438
|
with_lock(requires_new: true) do
|
|
1407
1439
|
authorize&.call(self)
|
|
1408
1440
|
next assistant_reply_under_lock!(body, assistant: by, files: files, request: request,
|
|
1409
|
-
metadata: metadata, turn: turn) if SupportDesk.ai_actor?(by)
|
|
1441
|
+
metadata: metadata, turn: turn, outreach: outreach) if SupportDesk.ai_actor?(by)
|
|
1410
1442
|
|
|
1443
|
+
# Fold committed inputs before a human answer too. A delayed callback
|
|
1444
|
+
# must not later turn an already-answered question back into work.
|
|
1445
|
+
lock_conversation!
|
|
1446
|
+
reconcile_unregistered_messages!
|
|
1411
1447
|
ensure_writable!
|
|
1412
1448
|
apply_reply_policy!(by, request: request)
|
|
1413
1449
|
posted = post_agent_message!(body, files: files, by: by, metadata: metadata)
|
|
@@ -1423,13 +1459,13 @@ module SupportDesk
|
|
|
1423
1459
|
# `reply!` by the assistant: her full rules, under the lock the caller
|
|
1424
1460
|
# already holds. Everything here is also what `respond!` runs — this is
|
|
1425
1461
|
# the path for a host that decided to answer rather than ask policy.
|
|
1426
|
-
def assistant_reply_under_lock!(body, assistant:, files:, request:, metadata:, turn:)
|
|
1462
|
+
def assistant_reply_under_lock!(body, assistant:, files:, request:, metadata:, turn:, outreach: false)
|
|
1427
1463
|
assistant = resolve_assistant!(assistant)
|
|
1428
|
-
#
|
|
1429
|
-
#
|
|
1430
|
-
|
|
1431
|
-
turn = assistant_turn if turn == :current
|
|
1464
|
+
# Private outreach supplies a token only after acquiring both locks;
|
|
1465
|
+
# public replies compare the caller's actual observed token.
|
|
1466
|
+
lock_conversation!
|
|
1432
1467
|
reconcile_unregistered_messages!
|
|
1468
|
+
turn = assistant_turn if outreach
|
|
1433
1469
|
ensure_current_turn!(turn)
|
|
1434
1470
|
ensure_writable!
|
|
1435
1471
|
|
|
@@ -1458,14 +1494,16 @@ module SupportDesk
|
|
|
1458
1494
|
"take it first"
|
|
1459
1495
|
end
|
|
1460
1496
|
|
|
1461
|
-
|
|
1497
|
+
# This private path already validated the reply while holding the lock.
|
|
1498
|
+
# Seat-taking consumes the real current token, not a public sentinel.
|
|
1499
|
+
assign!(to: actor, by: actor, request: request, turn: assistant_turn)
|
|
1462
1500
|
elsif !assigned_to?(actor)
|
|
1463
1501
|
# Humans outrank assistants, under EVERY reply policy (I8). A person
|
|
1464
1502
|
# answering a case a machine is holding takes it over: there is
|
|
1465
1503
|
# nothing to ask about "who owns this" when one of the two can't
|
|
1466
1504
|
# want it, and leaving her seated would keep her answering next.
|
|
1467
1505
|
if held_by_assistant? && !SupportDesk.ai_actor?(actor)
|
|
1468
|
-
return assign!(to: actor, by: actor, reason: :drop_in_takeover, request: request)
|
|
1506
|
+
return assign!(to: actor, by: actor, reason: :drop_in_takeover, request: request, turn: assistant_turn)
|
|
1469
1507
|
end
|
|
1470
1508
|
# Belt: the `held_by_human` floor already turned this into a draft,
|
|
1471
1509
|
# so an assistant reaching here is a bug, not a policy question.
|
|
@@ -1476,7 +1514,7 @@ module SupportDesk
|
|
|
1476
1514
|
raise NotAllowed, "ticket #{reference} is held by #{describe_actor(assignee)} and this desk only " \
|
|
1477
1515
|
"lets the assignee reply"
|
|
1478
1516
|
when :take_over
|
|
1479
|
-
assign!(to: actor, by: actor, reason: :drop_in_takeover, request: request)
|
|
1517
|
+
assign!(to: actor, by: actor, reason: :drop_in_takeover, request: request, turn: assistant_turn)
|
|
1480
1518
|
else
|
|
1481
1519
|
write_transition!(:drop_in, actor: actor, request: request) do
|
|
1482
1520
|
{ "assignee" => SupportDesk.actor_key(assignee) }
|
|
@@ -1551,11 +1589,15 @@ module SupportDesk
|
|
|
1551
1589
|
# caller is not always the lock holder it thinks it is.
|
|
1552
1590
|
def record_registration!(message) # :nodoc:
|
|
1553
1591
|
return self if role_of(message) == :system
|
|
1592
|
+
unless message.persisted? && message.conversation_id == conversation_id
|
|
1593
|
+
raise ArgumentError, "message must belong to this ticket's conversation"
|
|
1594
|
+
end
|
|
1554
1595
|
# Re-check under the lock: the same message can reach us twice (a
|
|
1555
1596
|
# redelivered event, a hand-written replay), and an SLA clock that
|
|
1556
1597
|
# moves twice for one message is a lie.
|
|
1557
1598
|
return self if registered?(message)
|
|
1558
1599
|
|
|
1600
|
+
message_registrations.create!(message_id: message.id)
|
|
1559
1601
|
role = role_of(message)
|
|
1560
1602
|
opening = opening_message?
|
|
1561
1603
|
reopened = false
|
|
@@ -1565,10 +1607,10 @@ module SupportDesk
|
|
|
1565
1607
|
closed_by_assistant = false
|
|
1566
1608
|
case role
|
|
1567
1609
|
when :requester
|
|
1568
|
-
attributes[:last_requester_message_at] = message.created_at
|
|
1610
|
+
attributes[:last_requester_message_at] = [ last_requester_message_at, message.created_at ].compact.max
|
|
1569
1611
|
# The pointer the turn's reconciliation reads: which requester
|
|
1570
1612
|
# message the clocks are standing on, by id and not only by time.
|
|
1571
|
-
attributes[:last_requester_message_id] = message.id
|
|
1613
|
+
attributes[:last_requester_message_id] = message.id if after_requester_watermark?(message)
|
|
1572
1614
|
if closed? && message.created_at > closed_at && desk_config.closed_tickets == :reopen_on_reply
|
|
1573
1615
|
# Read before the merge below clears it — see #reopen!.
|
|
1574
1616
|
closed_by_assistant = closed_by_type == SupportDesk::Assistant.polymorphic_name
|
|
@@ -1577,7 +1619,7 @@ module SupportDesk
|
|
|
1577
1619
|
reopened = true
|
|
1578
1620
|
end
|
|
1579
1621
|
when :agent
|
|
1580
|
-
attributes[:last_agent_message_at] = message.created_at
|
|
1622
|
+
attributes[:last_agent_message_at] = [ last_agent_message_at, message.created_at ].compact.max
|
|
1581
1623
|
# A first REPLY answers something. An agent message with no earlier
|
|
1582
1624
|
# requester message is the desk opening the conversation, or a word
|
|
1583
1625
|
# into an empty case — neither is a reply, and a requester clock that
|
|
@@ -1592,7 +1634,13 @@ module SupportDesk
|
|
|
1592
1634
|
end
|
|
1593
1635
|
|
|
1594
1636
|
assign_attributes(attributes)
|
|
1595
|
-
self.awaiting = closed?
|
|
1637
|
+
self.awaiting = if closed?
|
|
1638
|
+
"none"
|
|
1639
|
+
elsif role == :requester
|
|
1640
|
+
"agent"
|
|
1641
|
+
else
|
|
1642
|
+
awaiting_from_clocks
|
|
1643
|
+
end
|
|
1596
1644
|
self.waiting_since = waiting_since_from_clocks
|
|
1597
1645
|
save!
|
|
1598
1646
|
# Every registered message moves the case on, so every one of them
|
|
@@ -1609,48 +1657,51 @@ module SupportDesk
|
|
|
1609
1657
|
|
|
1610
1658
|
# After the bump, and after any reopen: the hook decides about a case
|
|
1611
1659
|
# in the state this message left it in.
|
|
1612
|
-
evaluate_hand_off_phrase!(message) if role == :requester
|
|
1660
|
+
evaluate_hand_off_phrase!(message) if role == :requester && !closed?
|
|
1613
1661
|
|
|
1614
1662
|
publish_transition(reopen_event, :reopened, requester, nil) if reopen_event
|
|
1615
1663
|
announce_registration(message, role: role, opening: opening, reopened: reopened)
|
|
1616
1664
|
self
|
|
1617
1665
|
end
|
|
1618
1666
|
|
|
1619
|
-
#
|
|
1620
|
-
#
|
|
1621
|
-
# REPLAY can arrive in any order and an older message must never rewind
|
|
1622
|
-
# `awaiting`, restart an SLA clock, or reopen a case that was closed
|
|
1623
|
-
# after it.
|
|
1624
|
-
#
|
|
1625
|
-
# The comparison is `<=`, so a message whose timestamp already sits on
|
|
1626
|
-
# the clock counts as folded in: idempotency is the documented promise,
|
|
1627
|
-
# and the cost of the rare tie is one clock that doesn't advance until
|
|
1628
|
-
# the next message.
|
|
1667
|
+
# Receipt identity, not transcript order, decides whether a callback is a
|
|
1668
|
+
# replay. A later commit may have an earlier timestamp or a smaller UUID.
|
|
1629
1669
|
def registered?(message)
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
return true if
|
|
1639
|
-
return
|
|
1640
|
-
|
|
1641
|
-
#
|
|
1642
|
-
#
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1670
|
+
message_registrations.exists?(message_id: message.id)
|
|
1671
|
+
end
|
|
1672
|
+
|
|
1673
|
+
# Advance the requester clock pointer only in transcript order. Receipt
|
|
1674
|
+
# identity decides registration separately, so late arrivals cannot rewind
|
|
1675
|
+
# clocks or be discarded as replays.
|
|
1676
|
+
def after_requester_watermark?(message)
|
|
1677
|
+
watermark = last_requester_message_at
|
|
1678
|
+
return true if watermark.blank?
|
|
1679
|
+
return true if message.created_at > watermark
|
|
1680
|
+
return false if message.created_at < watermark
|
|
1681
|
+
# A tie with no pointer to break it (a 0.2 row whose backfill found
|
|
1682
|
+
# nothing) is new: the alternative silently drops it.
|
|
1683
|
+
return true if last_requester_message_id.blank?
|
|
1684
|
+
|
|
1685
|
+
compare_message_ids(message.id, last_requester_message_id).positive?
|
|
1686
|
+
end
|
|
1687
|
+
|
|
1688
|
+
# Order two chats message ids the way the DATABASE orders that column,
|
|
1689
|
+
# because the reconciliation query compares them in SQL and this one
|
|
1690
|
+
# compares them in Ruby. Integers compare numerically (10 is after 9);
|
|
1691
|
+
# anything else — a uuid, a ULID — compares as a string, which is how
|
|
1692
|
+
# every adapter orders those columns too.
|
|
1693
|
+
def compare_message_ids(one, other)
|
|
1694
|
+
if one.is_a?(Integer) && other.is_a?(Integer)
|
|
1695
|
+
one <=> other
|
|
1696
|
+
elsif integerish?(one) && integerish?(other)
|
|
1697
|
+
one.to_i <=> other.to_i
|
|
1698
|
+
else
|
|
1699
|
+
one.to_s <=> other.to_s
|
|
1651
1700
|
end
|
|
1652
1701
|
end
|
|
1653
1702
|
|
|
1703
|
+
def integerish?(value) = value.to_s.match?(/\A-?\d+\z/)
|
|
1704
|
+
|
|
1654
1705
|
def opening_message?
|
|
1655
1706
|
last_registered_message_id.blank? && last_requester_message_at.nil? && last_agent_message_at.nil?
|
|
1656
1707
|
end
|
|
@@ -29,14 +29,10 @@ module SupportDesk
|
|
|
29
29
|
#
|
|
30
30
|
# == Names survive renames
|
|
31
31
|
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
# transcript reads "Rose" while Rose is configured, keeps saying what the
|
|
37
|
-
# customer was actually shown for an assistant nobody declares any more,
|
|
38
|
-
# and never invents a name for a message written under a nameless
|
|
39
|
-
# disclosure mode.
|
|
32
|
+
# The assistant's original name is stored on new messages. Older assistant
|
|
33
|
+
# messages fall back to their stored display name, then to configuration or
|
|
34
|
+
# the author record only when there is no message snapshot. Exported turns
|
|
35
|
+
# retain AI provenance even when their requester-facing mode is nameless.
|
|
40
36
|
#
|
|
41
37
|
# == Deleted messages are turns too
|
|
42
38
|
#
|
|
@@ -204,12 +200,13 @@ module SupportDesk
|
|
|
204
200
|
end
|
|
205
201
|
end
|
|
206
202
|
|
|
207
|
-
#
|
|
208
|
-
#
|
|
209
|
-
# more. A message written by the record rather than by `speak!` (an
|
|
210
|
-
# import, a host that posts its own) has neither, so the record answers.
|
|
203
|
+
# Message snapshots survive configuration changes. Imports without a
|
|
204
|
+
# snapshot use the configured key or author as a legacy fallback.
|
|
211
205
|
def assistant_name(message)
|
|
212
206
|
stamp = provenance(message)
|
|
207
|
+
return stamp["name"] if stamp["name"].is_a?(String) && stamp["name"].present?
|
|
208
|
+
return stamp["display_name"] if stamp["display_name"].is_a?(String) && stamp["display_name"].present?
|
|
209
|
+
|
|
213
210
|
key = stamp["assistant"]
|
|
214
211
|
if key.present? && SupportDesk.config.assistant?(key)
|
|
215
212
|
return SupportDesk.config.assistant(key).name
|
data/lib/support_desk/version.rb
CHANGED
data/lib/support_desk.rb
CHANGED
|
@@ -124,7 +124,11 @@ module SupportDesk
|
|
|
124
124
|
"`config.assistant #{key.inspect} do |assistant| … end`"
|
|
125
125
|
end
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
# Resolving her is also when what she is CALLED is written down, so a
|
|
128
|
+
# message she signed still says the same thing after somebody takes
|
|
129
|
+
# her out of the initializer (R8). It writes only when configuration
|
|
130
|
+
# and the row disagree.
|
|
131
|
+
(assistants[key] ||= Assistant.for(key)).snapshot_disclosure!
|
|
128
132
|
end
|
|
129
133
|
|
|
130
134
|
# Every assistant record this process has resolved, keyed by key.
|
|
@@ -146,6 +150,31 @@ module SupportDesk
|
|
|
146
150
|
record.respond_to?(:support_agent_kind) && record.support_agent_kind == :ai
|
|
147
151
|
end
|
|
148
152
|
|
|
153
|
+
# Give back every seat an assistant can no longer sit in — she was
|
|
154
|
+
# switched off, her configuration was removed, or her policy no longer
|
|
155
|
+
# lets her hold a case — and ask for a person on each of them.
|
|
156
|
+
#
|
|
157
|
+
# Invalid-assignee recovery, NOT silence detection: it reads the seats
|
|
158
|
+
# that exist rather than the assistants a running process happens to
|
|
159
|
+
# have configured, so a `deactivate!`, a flag flipped off and a topic
|
|
160
|
+
# cap tightened are all covered, with no `responds_within` and no
|
|
161
|
+
# overdue clock anywhere in it (R6). `release_silent_assistants!` runs
|
|
162
|
+
# it first; `rake support_desk:reclaim_assistant_seats` runs it alone.
|
|
163
|
+
#
|
|
164
|
+
# Returns how many seats it reclaimed.
|
|
165
|
+
def reclaim_assistant_seats!
|
|
166
|
+
return 0 unless Assistant.table_exists?
|
|
167
|
+
|
|
168
|
+
reclaimed = 0
|
|
169
|
+
Ticket.open.held_by_assistants.find_each do |ticket|
|
|
170
|
+
reclaimed += 1 if ticket.reclaim_assistant_seat!
|
|
171
|
+
rescue StandardError => e
|
|
172
|
+
report_error(e, context: { hook: :reclaim_assistant_seats, ticket: ticket.id })
|
|
173
|
+
end
|
|
174
|
+
logger&.info("[support_desk] reclaimed #{reclaimed} stranded assistant seat(s)") if reclaimed.positive?
|
|
175
|
+
reclaimed
|
|
176
|
+
end
|
|
177
|
+
|
|
149
178
|
# Release every assistant who has sat on a case longer than her
|
|
150
179
|
# `responds_within` without answering — and ask for a person on it.
|
|
151
180
|
#
|
|
@@ -154,18 +183,19 @@ module SupportDesk
|
|
|
154
183
|
# Run it every minute (`rake support_desk:release_silent_assistants`).
|
|
155
184
|
# Returns how many cases it moved.
|
|
156
185
|
def release_silent_assistants!
|
|
157
|
-
|
|
186
|
+
# A seat nobody can sit in any more is not a silence problem, and it
|
|
187
|
+
# must not need a `responds_within` to be noticed (R6).
|
|
188
|
+
moved = reclaim_assistant_seats!
|
|
158
189
|
config.assistants.each_key do |key|
|
|
159
190
|
agent = assistant(key)
|
|
160
191
|
window = agent&.responds_within
|
|
161
192
|
next if window.nil?
|
|
162
193
|
|
|
163
194
|
Ticket.open.assigned_to(agent).awaiting_reply.waiting_over(window).find_each do |ticket|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
moved += 1 if ticket.human_required?
|
|
195
|
+
# Conditional, under the case's own lock: every predicate above was
|
|
196
|
+
# true when this row was SELECTED, and a person may have answered
|
|
197
|
+
# it since (R5).
|
|
198
|
+
moved += 1 if ticket.escalate_if_still_silent!(agent, window)
|
|
169
199
|
rescue StandardError => e
|
|
170
200
|
report_error(e, context: { hook: :release_silent_assistants, ticket: ticket.id })
|
|
171
201
|
end
|
|
@@ -186,6 +216,19 @@ module SupportDesk
|
|
|
186
216
|
|
|
187
217
|
config.desks.each_key do |key|
|
|
188
218
|
agent = desk(key)&.assistant
|
|
219
|
+
# Repair before deciding. A requester message whose registration was
|
|
220
|
+
# lost after commit leaves the clocks describing a case that no longer
|
|
221
|
+
# exists — and `assistant_idle_since` reads those clocks, so the case
|
|
222
|
+
# it most needs to find is the one it cannot see. Folding the message
|
|
223
|
+
# in COMMITS on its own and emits the turn it produces, so a dead
|
|
224
|
+
# process followed by nothing but this task still ends in an
|
|
225
|
+
# actionable turn (R3).
|
|
226
|
+
Ticket.for_desk(key).with_unregistered_requester_messages.find_each do |ticket|
|
|
227
|
+
emitted += 1 if ticket.reconcile_and_commit!.positive?
|
|
228
|
+
rescue StandardError => e
|
|
229
|
+
report_error(e, context: { hook: :redispatch_assistant_turns, ticket: ticket.id })
|
|
230
|
+
end
|
|
231
|
+
|
|
189
232
|
next if agent.nil?
|
|
190
233
|
|
|
191
234
|
Ticket.open.for_desk(key).assistant_idle_since(older_than.ago).find_each do |ticket|
|
data/lib/tasks/support_desk.rake
CHANGED
|
@@ -38,6 +38,17 @@ namespace :support_desk do
|
|
|
38
38
|
puts "[support_desk] #{moved} case(s) handed to a person."
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
desc "Give back every seat an assistant can no longer sit in (run every minute)"
|
|
42
|
+
task reclaim_assistant_seats: :environment do
|
|
43
|
+
# Invalid-assignee recovery: she was switched off, her configuration was
|
|
44
|
+
# taken away, or her policy no longer lets her hold a case. It needs no
|
|
45
|
+
# `responds_within` and no overdue clock — `release_silent_assistants`
|
|
46
|
+
# runs it first, and this is the same work on its own.
|
|
47
|
+
reclaimed = SupportDesk.reclaim_assistant_seats!
|
|
48
|
+
|
|
49
|
+
puts "[support_desk] #{reclaimed} stranded seat(s) given back to people."
|
|
50
|
+
end
|
|
51
|
+
|
|
41
52
|
desc "Re-emit the turn for cases nobody answered (OLDER_THAN=60 seconds; run every 5 minutes)"
|
|
42
53
|
task redispatch_assistant_turns: :environment do
|
|
43
54
|
# At-least-once, and that is safe: the turn is consumed by the first
|
|
@@ -48,7 +59,7 @@ namespace :support_desk do
|
|
|
48
59
|
puts "[support_desk] #{emitted} turn(s) re-emitted (idle for more than #{older_than.inspect})."
|
|
49
60
|
end
|
|
50
61
|
|
|
51
|
-
desc "
|
|
62
|
+
desc "Report assistant status without changing tickets"
|
|
52
63
|
task assistant_status: :environment do
|
|
53
64
|
if SupportDesk.config.assistants.empty?
|
|
54
65
|
puts "[support_desk] no assistant is configured."
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: support_desk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-09-
|
|
10
|
+
date: 2026-09-24 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: activerecord
|
|
@@ -191,6 +191,7 @@ files:
|
|
|
191
191
|
- lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb
|
|
192
192
|
- lib/generators/support_desk/templates/console/controller.rb.erb
|
|
193
193
|
- lib/generators/support_desk/templates/console/resource.rb.erb
|
|
194
|
+
- lib/generators/support_desk/templates/create_support_desk_message_registrations.rb.erb
|
|
194
195
|
- lib/generators/support_desk/templates/create_support_desk_tables.rb.erb
|
|
195
196
|
- lib/generators/support_desk/templates/initializer.rb
|
|
196
197
|
- lib/generators/support_desk/upgrade_generator.rb
|
|
@@ -209,6 +210,7 @@ files:
|
|
|
209
210
|
- lib/support_desk/errors.rb
|
|
210
211
|
- lib/support_desk/events.rb
|
|
211
212
|
- lib/support_desk/macros.rb
|
|
213
|
+
- lib/support_desk/message_signatures.rb
|
|
212
214
|
- lib/support_desk/models/application_record.rb
|
|
213
215
|
- lib/support_desk/models/assignment.rb
|
|
214
216
|
- lib/support_desk/models/assistant.rb
|
|
@@ -218,6 +220,7 @@ files:
|
|
|
218
220
|
- lib/support_desk/models/desk.rb
|
|
219
221
|
- lib/support_desk/models/draft.rb
|
|
220
222
|
- lib/support_desk/models/event.rb
|
|
223
|
+
- lib/support_desk/models/message_registration.rb
|
|
221
224
|
- lib/support_desk/models/ticket.rb
|
|
222
225
|
- lib/support_desk/models/ticket/assistance.rb
|
|
223
226
|
- lib/support_desk/outcome.rb
|