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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 66ba1a32f86acca1b4068bbca4091fcb16c4e6840a9d57ae5dc44bea25572ea2
|
|
4
|
+
data.tar.gz: 2433e492636e42eeffde1652a2c7d21915267c6f0a9180bea8bb010d902cdfad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ac1246120f4c2aa2763f81866006f054e0dfd14901d42120ddc25d754b08dad350b1627552faaa9e4fd62d37a50d785eeae9abe6dab1c5dc9adbb9f9bb44acd1
|
|
7
|
+
data.tar.gz: 66fca7c4ddad8c9b41358448ec24beda2a3dcd9bade8bd373fe7433a565700d500c212f863c1755048faa7dd803c79c1fa92d3fb0b029a072322d1eff6672994
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,135 @@ All notable changes to this project are documented here.
|
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.3.2] - Unreleased
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Persist message-registration receipts atomically with clocks and turn revisions.
|
|
12
|
+
A new message with an earlier timestamp or smaller UUID is no longer dropped
|
|
13
|
+
as a replay. It invalidates the turn and triggers requester notifications and
|
|
14
|
+
handoff detection once, without rewinding clocks. This also fixes human-only
|
|
15
|
+
desks; no assistant configuration is needed to encounter the old defect.
|
|
16
|
+
- Recovery discovers unregistered replies on closed cases and honors the desk's
|
|
17
|
+
reopen/locked policy. Repair also runs when the assistant is disabled; only
|
|
18
|
+
actual assistant dispatch requires one to be configured.
|
|
19
|
+
- Public assistant assignment, release and reply refuse `turn: :current`.
|
|
20
|
+
Internal outreach and seat-taking use private paths and actual tokens.
|
|
21
|
+
- Default Chats signatures render assistant messages using per-message
|
|
22
|
+
disclosure snapshots, including old 0.3.0 messages with provenance but no
|
|
23
|
+
assistant-row snapshot. Rename, mode change and flag-off do not erase old
|
|
24
|
+
disclosure. Human replies and approved drafts keep human signatures, and host
|
|
25
|
+
signature overrides remain authoritative. Transcript names are snapshotted
|
|
26
|
+
for new messages; older ones use their stored display name.
|
|
27
|
+
- Correct the README signature override to preserve the human fallback.
|
|
28
|
+
|
|
29
|
+
### Upgrade
|
|
30
|
+
|
|
31
|
+
Run `rails generate support_desk:upgrade` and migrate **with support writes paused
|
|
32
|
+
and all old web/jobs drained**. The new receipt table follows the existing ticket
|
|
33
|
+
and message ID types and seeds historical messages up to the old role clocks.
|
|
34
|
+
Old clocks cannot reveal lost callbacks behind that baseline: review suspect
|
|
35
|
+
historical cases before resuming. Only 0.3.2 processes may write after cutover.
|
|
36
|
+
See README for recovery, rollback limitations and the unchanged SQLite caveat.
|
|
37
|
+
No gem publication or deployment is performed by this change.
|
|
38
|
+
|
|
39
|
+
## [0.3.1] - 2026-09-19
|
|
40
|
+
|
|
41
|
+
**Hardening the assistant, after an adversarial review of 0.3.0.** Nine
|
|
42
|
+
defects, every one of them in the concurrency, recovery and kill-switch
|
|
43
|
+
guarantees the release advertised rather than in what a green suite
|
|
44
|
+
covered. Nothing here changes what an assistant may do; it changes what
|
|
45
|
+
can happen to a case while she is doing it. `assign!` and `release!` take
|
|
46
|
+
a `turn:` when `by:` is an assistant — the one API change — and a host
|
|
47
|
+
with no assistant is untouched.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- **A customer's next question can no longer be answered around (R1).**
|
|
52
|
+
Reconciliation is a `SELECT`, and a requester never takes the case's row
|
|
53
|
+
lock, so a question could commit between the answer reading the case and
|
|
54
|
+
the answer writing to it: she replied to the previous one, and a human
|
|
55
|
+
approved a proposal from a page that predated the new one — `seen_turn`
|
|
56
|
+
was not evidence that the reviewer had seen the last committed message.
|
|
57
|
+
Every speaking path now takes the ticket's row lock and then the
|
|
58
|
+
conversation row chats updates inside every message insert, before
|
|
59
|
+
reconciling. The lock order is ticket → conversation everywhere, and it
|
|
60
|
+
is written down. **The guarantee is a row lock, so it holds on PostgreSQL
|
|
61
|
+
and MySQL and not on SQLite**, which has no row locks and whose WAL
|
|
62
|
+
snapshot reads straight through an open write transaction: a desk with an
|
|
63
|
+
assistant belongs on PostgreSQL or MySQL, and `doctor` now warns when it
|
|
64
|
+
is not.
|
|
65
|
+
- **A seat is decided under the lock (R2).** `assign!` asked whether
|
|
66
|
+
anybody held the case, and whether she could hold it, *before* the
|
|
67
|
+
transition acquired the row. No threads were needed to break it: load an
|
|
68
|
+
unassigned case into one instance, give it to a person through another,
|
|
69
|
+
and the first instance took the seat from them. A topic cap or a pause
|
|
70
|
+
committed while a take waited for the lock was read too late in the same
|
|
71
|
+
way.
|
|
72
|
+
- **A lost registration is repairable (R3).** A requester message that
|
|
73
|
+
committed while the process that should have registered it died left a
|
|
74
|
+
turn nothing could ever consume: reconciliation happened inside the
|
|
75
|
+
candidate answer's savepoint, the bumped revision made that very answer
|
|
76
|
+
stale, and the `StaleTurn` rolled the repair back with it — for ever,
|
|
77
|
+
while the clocks said `awaiting_requester` so no idle query could see the
|
|
78
|
+
case. The fold is now its own committed step, and
|
|
79
|
+
`redispatch_assistant_turns!` looks for unregistered messages before it
|
|
80
|
+
looks at clocks.
|
|
81
|
+
- **Messages that share an instant no longer deadlock the turn (R4).** The
|
|
82
|
+
unseen-message test was "its id is not the one the clock was set from",
|
|
83
|
+
so every other message on that timestamp read as new: reconciliation
|
|
84
|
+
folded an already-registered one in again, bumped the revision, and made
|
|
85
|
+
the answer that discovered it stale. The retry did the same thing. "Seen"
|
|
86
|
+
is now the pair `(created_at, id)` compared in chats' own transcript
|
|
87
|
+
order, by the same rule in Ruby and in SQL.
|
|
88
|
+
- **The silent sweep rechecks before it escalates (R5).** Its predicates
|
|
89
|
+
were true when it SELECTED its candidates. A person who answered, took
|
|
90
|
+
the case or reset the clock in between lost anyway: the case was marked
|
|
91
|
+
human-required, its priority went up, and the customer was told a person
|
|
92
|
+
was coming — on a case that already had one.
|
|
93
|
+
- **Stranded seats come back (R6).** `deactivate!` promised the sweep would
|
|
94
|
+
release her cases; the sweep only ever visited configured assistants with
|
|
95
|
+
a `responds_within`, and only cases already overdue. An assistant with no
|
|
96
|
+
promise kept her seat for ever, and one removed by the documented kill
|
|
97
|
+
switch — which takes her configuration away — was not visited at all,
|
|
98
|
+
while the doctor's assistant checks returned early on exactly that state.
|
|
99
|
+
`SupportDesk.reclaim_assistant_seats!` (and `rake
|
|
100
|
+
support_desk:reclaim_assistant_seats`) reads the seats that exist.
|
|
101
|
+
- **Running out of turns tells the host (R7).** The budget branch wrote the
|
|
102
|
+
`escalated` event, released her seat and posted the public hand-off line,
|
|
103
|
+
but only the outer `escalate!` published `:ticket_escalated` — so a
|
|
104
|
+
host's "a person is needed here" notifier never heard about a
|
|
105
|
+
conversation that ran out of turns. One private method now publishes it
|
|
106
|
+
for every hand-off.
|
|
107
|
+
- **Disclosure on old messages stops moving (R8).** The requester-facing
|
|
108
|
+
signature resolved live configuration, so taking her out of the
|
|
109
|
+
initializer rewrote "— Rose · asistente virtual" into "— Rose" on
|
|
110
|
+
messages nobody had touched. Her name and mode are snapshotted onto her
|
|
111
|
+
own row and read back from it when nothing declares her any more. A
|
|
112
|
+
rename still renames history, deliberately; the README shows the
|
|
113
|
+
`Chats.config.message_signature` lambda for hosts that want each bubble
|
|
114
|
+
frozen.
|
|
115
|
+
- **The kill switch is read after the wait (R9).** Eligibility was checked
|
|
116
|
+
before the case's row lock, and the policy then read `active?` off that
|
|
117
|
+
same stale copy, so an already-seated assistant whose job queued behind a
|
|
118
|
+
busy case could still speak after `deactivate!` had committed. Every verb
|
|
119
|
+
resolves her under the lock now, from the row. The ordinary in-flight
|
|
120
|
+
window — a switch that commits after that read — is documented rather
|
|
121
|
+
than claimed away.
|
|
122
|
+
|
|
123
|
+
### Changed
|
|
124
|
+
|
|
125
|
+
- `Ticket#assign!` and `Ticket#release!` take `turn:`. It is REQUIRED when
|
|
126
|
+
`by:` is an assistant (`ArgumentError` when omitted, `StaleTurn` when
|
|
127
|
+
stale) and ignored for a person, whose calls are unchanged.
|
|
128
|
+
- `SupportDesk.release_silent_assistants!` runs
|
|
129
|
+
`reclaim_assistant_seats!` first, and its count includes what that
|
|
130
|
+
reclaimed.
|
|
131
|
+
- The doctor's assistant invariants run whenever an assistant ROW exists,
|
|
132
|
+
not only while one is configured.
|
|
133
|
+
- `Ticket.with_unregistered_requester_messages` — the scope behind the
|
|
134
|
+
repair half of redispatch.
|
|
135
|
+
|
|
7
136
|
## [0.3.0] - 2026-09-19
|
|
8
137
|
|
|
9
138
|
**A machine can answer, and a person still owns every word it sends.** An
|
data/README.md
CHANGED
|
@@ -942,6 +942,13 @@ one raises `SupportDesk::StaleTurn` and writes nothing. That one integer is
|
|
|
942
942
|
also why this gem has no idempotency keys, no claim rows and no leases —
|
|
943
943
|
"is this still the case you read?" is already answered.
|
|
944
944
|
|
|
945
|
+
Every verb of hers takes it, and that includes taking and giving back the
|
|
946
|
+
seat: `assign!(to: rose, by: rose, turn:)` and `release!(by: rose, turn:)`
|
|
947
|
+
require it when `by:` is an assistant (an omitted turn is an
|
|
948
|
+
`ArgumentError`, a stale one a `StaleTurn`), because a run that finished
|
|
949
|
+
after the case moved on must not release the seat a newer one took. A
|
|
950
|
+
**person's** calls are unchanged and take no turn.
|
|
951
|
+
|
|
945
952
|
Two consequences worth knowing:
|
|
946
953
|
|
|
947
954
|
- Check `ticket.assistant_turn == turn` in your job **before you spend
|
|
@@ -949,6 +956,34 @@ Two consequences worth knowing:
|
|
|
949
956
|
- A retry after a committed action is a `StaleTurn`, and that is correct:
|
|
950
957
|
the work was done. Generated jobs `discard_on` it.
|
|
951
958
|
|
|
959
|
+
#### Ticket, then conversation
|
|
960
|
+
|
|
961
|
+
A turn is only as good as the reconciliation behind it, and reconciliation
|
|
962
|
+
is a `SELECT`: on its own it cannot exclude a message that commits a
|
|
963
|
+
millisecond later. Your customer never takes the case's row lock — they
|
|
964
|
+
press send, and chats writes a message.
|
|
965
|
+
|
|
966
|
+
What chats *does* take is the **conversation row**: every message insert
|
|
967
|
+
updates `chats_conversations` inside its own transaction. So every path
|
|
968
|
+
that speaks takes the ticket's row lock and then that row, before
|
|
969
|
+
reconciling. A question already in flight holds it, so we wait for it and
|
|
970
|
+
the turn goes stale; a question that starts after we hold it waits for us
|
|
971
|
+
and raises a turn of its own. **The order is always ticket → conversation**,
|
|
972
|
+
in this gem and in anything you add to it.
|
|
973
|
+
|
|
974
|
+
What that does not cover: a message whose `INSERT` was already stamped when
|
|
975
|
+
we won the row is still stamped earlier than the answer, so a transcript can
|
|
976
|
+
show a question above an answer that did not address it. That is a
|
|
977
|
+
genuinely simultaneous send, and its own turn follows.
|
|
978
|
+
|
|
979
|
+
> [!IMPORTANT]
|
|
980
|
+
> **This is a row lock, so it is PostgreSQL and MySQL.** SQLite has none:
|
|
981
|
+
> it serializes writes, and in WAL mode a reconciliation `SELECT` reads the
|
|
982
|
+
> last committed snapshot straight through a requester's open write
|
|
983
|
+
> transaction, so a question committing behind an answer is still missed
|
|
984
|
+
> there. Run PostgreSQL or MySQL for a production desk with an assistant —
|
|
985
|
+
> `SupportDesk.doctor` warns when you haven't.
|
|
986
|
+
|
|
952
987
|
### Proposals in the console
|
|
953
988
|
|
|
954
989
|
A pending proposal renders above the composer with its confidence, its
|
|
@@ -1020,6 +1055,32 @@ the turn and the whole policy that allowed it, `ticket.export` labels it
|
|
|
1020
1055
|
marks it for staff. What a customer is told is a product decision; what your
|
|
1021
1056
|
records say a machine wrote is not.
|
|
1022
1057
|
|
|
1058
|
+
**Historical disclosure is captured per message.** The default Chats signature
|
|
1059
|
+
renderer uses the assistant name and disclosure stored when the message was
|
|
1060
|
+
sent. Renaming, changing mode or removing configuration does not rewrite old
|
|
1061
|
+
signed bubbles. Human replies and human-approved drafts retain their normal
|
|
1062
|
+
human signatures. The assistant row snapshot is a fallback for legacy messages
|
|
1063
|
+
without provenance; their original disclosure cannot be reconstructed if it
|
|
1064
|
+
was never stored.
|
|
1065
|
+
|
|
1066
|
+
A host's `Chats.config.message_signature` override remains authoritative. If
|
|
1067
|
+
you customize it, preserve the human fallback as well as the assistant snapshot:
|
|
1068
|
+
|
|
1069
|
+
```ruby
|
|
1070
|
+
Chats.configure do |config|
|
|
1071
|
+
config.message_signature = lambda do |message|
|
|
1072
|
+
stamp = message.metadata["support_desk"]
|
|
1073
|
+
name = if stamp.is_a?(Hash) && stamp["kind"] == "ai" && stamp["signed"] == true &&
|
|
1074
|
+
stamp["display_name"].is_a?(String) && stamp["display_name"].present?
|
|
1075
|
+
stamp["display_name"]
|
|
1076
|
+
else
|
|
1077
|
+
Chats.display_name_for(message.author)
|
|
1078
|
+
end
|
|
1079
|
+
I18n.t("chats.message.signature", name: name)
|
|
1080
|
+
end
|
|
1081
|
+
end
|
|
1082
|
+
```
|
|
1083
|
+
|
|
1023
1084
|
### Humans outrank
|
|
1024
1085
|
|
|
1025
1086
|
- A person may answer a case she holds under **every** `reply_policy`.
|
|
@@ -1071,8 +1132,27 @@ whatever she would have said. `redispatch_assistant_turns` re-emits the turn
|
|
|
1071
1132
|
for cases nobody acted on, which is safe precisely because the turn is
|
|
1072
1133
|
consumed by the first action and every later one is a `StaleTurn`.
|
|
1073
1134
|
|
|
1074
|
-
`
|
|
1075
|
-
|
|
1135
|
+
`release_silent_assistants` runs `SupportDesk.reclaim_assistant_seats!`
|
|
1136
|
+
first, and you can run that on its own
|
|
1137
|
+
(`rake support_desk:reclaim_assistant_seats`). It is a different question
|
|
1138
|
+
from silence: it reads the **seats that exist** rather than the assistants
|
|
1139
|
+
this process happens to have configured, and gives back every one whose
|
|
1140
|
+
holder is switched off, no longer declared, or no longer allowed to hold a
|
|
1141
|
+
case — with no `responds_within` and no overdue clock anywhere in it. That
|
|
1142
|
+
is what makes `deactivate!` and a flag flipped off actually release her
|
|
1143
|
+
cases, and it is why the doctor's seat checks keep running when the
|
|
1144
|
+
configuration is gone.
|
|
1145
|
+
|
|
1146
|
+
`redispatch_assistant_turns` also repairs before it decides: a requester
|
|
1147
|
+
message whose registration was lost after its commit leaves the clocks
|
|
1148
|
+
describing a case that no longer exists, so the task looks for unregistered
|
|
1149
|
+
**messages** and not only for idle clocks. The repair commits on its own,
|
|
1150
|
+
which is what makes a dead process followed by nothing but this task end in
|
|
1151
|
+
an actionable turn.
|
|
1152
|
+
|
|
1153
|
+
`rake support_desk:assistant_status` reports current counts without changing
|
|
1154
|
+
tickets. Resolving configured assistants may create their identity rows or
|
|
1155
|
+
refresh their name/disclosure snapshots. `SupportDesk.doctor` covers the same ground
|
|
1076
1156
|
with verdicts.
|
|
1077
1157
|
|
|
1078
1158
|
### What the model sees
|
|
@@ -1166,7 +1246,8 @@ acts_as_support_agent kind: :ai # validated; see
|
|
|
1166
1246
|
SupportDesk.assistant(key = nil) # the Assistant record, memoised; nil when none is configured
|
|
1167
1247
|
SupportDesk.reset_assistants!
|
|
1168
1248
|
SupportDesk.ai_actor?(record)
|
|
1169
|
-
SupportDesk.release_silent_assistants! # → Integer
|
|
1249
|
+
SupportDesk.release_silent_assistants! # → Integer (runs reclaim_assistant_seats! first)
|
|
1250
|
+
SupportDesk.reclaim_assistant_seats! # → Integer; seats she may no longer sit in
|
|
1170
1251
|
SupportDesk.redispatch_assistant_turns!(older_than: 1.minute) # → Integer
|
|
1171
1252
|
```
|
|
1172
1253
|
|
|
@@ -1176,7 +1257,7 @@ SupportDesk.redispatch_assistant_turns!(older_than: 1.minute) # → Integer
|
|
|
1176
1257
|
|---|---|
|
|
1177
1258
|
| `SupportDesk::Assistant.for(key)` / `.active` | found or created; the on-duty scope |
|
|
1178
1259
|
| `config` / `configured?` | her slice of the configuration; whether anything still declares her |
|
|
1179
|
-
| `name` `avatar` `autonomy` `disclosure` `max_turns` `responds_within` `may_open_conversations?` | read through the configuration,
|
|
1260
|
+
| `name` `avatar` `autonomy` `disclosure` `max_turns` `responds_within` `may_open_conversations?` | read through the configuration; `name` and `disclosure` fall back to the snapshot on her row, the rest to the safe answer |
|
|
1180
1261
|
| `disclosed?` `signs?` `notice?` | the mode, as predicates |
|
|
1181
1262
|
| `disclosed_name` `display_name` `to_s` `support_agent_name` `support_agent_avatar` | what a requester sees |
|
|
1182
1263
|
| `on_duty?` `support_capacity` | the agent contract |
|
|
@@ -1201,6 +1282,8 @@ assistant" it was).
|
|
|
1201
1282
|
respond!(body, by:, turn:, files:, confidence:, sources:, metadata:, request:) # → Outcome
|
|
1202
1283
|
draft!(body, by:, turn:, …) # → Draft
|
|
1203
1284
|
escalate!(by:, reason:, summary:, turn:, request:)
|
|
1285
|
+
assign!(to:, by:, reason:, note:, request:, turn:) # turn: required when by: is an assistant
|
|
1286
|
+
release!(by:, reason: :released, request:, turn:) # idem
|
|
1204
1287
|
request_human!(by:, request:) # by: must be the requester
|
|
1205
1288
|
pause_assistant!(by:, reason:) / resume_assistant!(by:)
|
|
1206
1289
|
|
|
@@ -1213,6 +1296,7 @@ drafts pending_draft notes
|
|
|
1213
1296
|
# scopes
|
|
1214
1297
|
held_by_assistants held_by_humans needs_human assistant_paused assistant_capped
|
|
1215
1298
|
resolved_by_assistant with_pending_draft assistant_idle_since(time)
|
|
1299
|
+
with_unregistered_requester_messages
|
|
1216
1300
|
```
|
|
1217
1301
|
|
|
1218
1302
|
Columns: `assistant_revision`, `last_requester_message_id`,
|
|
@@ -1288,12 +1372,13 @@ the count is non-zero.
|
|
|
1288
1372
|
`support_human_door(ticket)` helper.
|
|
1289
1373
|
|
|
1290
1374
|
**Rake** — `support_desk:release_silent_assistants` ·
|
|
1375
|
+
`support_desk:reclaim_assistant_seats` ·
|
|
1291
1376
|
`support_desk:redispatch_assistant_turns` (`OLDER_THAN=60`) ·
|
|
1292
1377
|
`support_desk:assistant_status`.
|
|
1293
1378
|
|
|
1294
1379
|
**Doctor** — `assistants (config)` · `assistant turn subscriber` ·
|
|
1295
1380
|
`assistant authorship` · `assistant silence` · `assistant seats` ·
|
|
1296
|
-
`assistant idle turns` · `drafts` · `ai agents without policy`.
|
|
1381
|
+
`assistant idle turns` · `drafts` · `ai agents without policy` · `message registrations`.
|
|
1297
1382
|
|
|
1298
1383
|
**Test helpers**
|
|
1299
1384
|
|
|
@@ -1310,10 +1395,54 @@ the count is non-zero.
|
|
|
1310
1395
|
| `with_assistant_config(key = nil, **overrides) { … }` | |
|
|
1311
1396
|
| `with_topic_assistant_cap(path, level) { … }` | rebuilds the frozen tree with one cap |
|
|
1312
1397
|
|
|
1313
|
-
### Upgrading to 0.3
|
|
1398
|
+
### Upgrading to 0.3.2 (including hosts without assistants)
|
|
1399
|
+
|
|
1400
|
+
0.3.2 adds `support_desk_message_registrations`, an internal receipt per text
|
|
1401
|
+
message. A receipt, written with the ticket clocks and revision in one
|
|
1402
|
+
transaction, distinguishes a replay from an unseen message. Timestamp and UUID
|
|
1403
|
+
order are not evidence of delivery. Every unseen requester message invalidates
|
|
1404
|
+
the turn, notifies the host and runs handoff detection once, even if its timestamp
|
|
1405
|
+
is older; the SLA timestamps themselves never move backwards.
|
|
1406
|
+
|
|
1407
|
+
**This upgrade requires a drained cutover; it is not rolling-safe.** The table
|
|
1408
|
+
is additive, but old processes cannot write receipts.
|
|
1409
|
+
|
|
1410
|
+
1. Pause support writes and drain old web requests and jobs. Keep the assistant
|
|
1411
|
+
disabled throughout the cutover.
|
|
1412
|
+
2. Run `rails generate support_desk:upgrade`, inspect the new migration, then
|
|
1413
|
+
`rails db:migrate` while writes remain paused. Fresh installs get the same
|
|
1414
|
+
migration from `support_desk:install`.
|
|
1415
|
+
3. The migration seeds existing text messages at or before each role's old
|
|
1416
|
+
clock as the historical baseline. Messages beyond those clocks remain
|
|
1417
|
+
discoverable by recovery. **Old data cannot prove whether a message behind
|
|
1418
|
+
the clock lost its callback.** Review suspect historical cases explicitly;
|
|
1419
|
+
the migration does not replay old notifications or reopen history en masse.
|
|
1420
|
+
4. Start only 0.3.2 processes. Run `SupportDesk.doctor` and
|
|
1421
|
+
`SupportDesk.redispatch_assistant_turns!` before resuming support traffic.
|
|
1422
|
+
Recovery checks closed cases as well: `:reopen_on_reply` reopens them, while
|
|
1423
|
+
`:locked` preserves closure and dispatches no model work. Registration repair
|
|
1424
|
+
also runs on desks with no assistant configured.
|
|
1425
|
+
5. Resume writes. An assistant rollout still requires its own staging checks
|
|
1426
|
+
and disclosure decision. The serialization guarantee still requires
|
|
1427
|
+
PostgreSQL/MySQL; SQLite does not acquire row locks.
|
|
1428
|
+
|
|
1429
|
+
Do not roll back to an old writer while serving traffic: it would leave missing
|
|
1430
|
+
receipts. If a rollback is necessary, pause and drain first, keep the receipt
|
|
1431
|
+
table, and re-establish the historical baseline before a later upgrade. Removing
|
|
1432
|
+
a receipt deliberately permits processing that message again; it is an internal
|
|
1433
|
+
recovery action, not a normal host API.
|
|
1434
|
+
|
|
1435
|
+
Public assistant `turn:` arguments must be the observed opaque token. Symbols
|
|
1436
|
+
such as `:current` are refused; private outreach and automatic seat-taking do
|
|
1437
|
+
not expose a public bypass. No new host-facing registration API is required.
|
|
1438
|
+
|
|
1439
|
+
### Historical upgrade from 0.2 to 0.3.0
|
|
1440
|
+
|
|
1441
|
+
The following describes the original assistants migration. When installing the
|
|
1442
|
+
current release, also follow the 0.3.2 cutover above.
|
|
1314
1443
|
|
|
1315
1444
|
```bash
|
|
1316
|
-
rails generate support_desk:upgrade #
|
|
1445
|
+
rails generate support_desk:upgrade # current releases also copy the receipt migration
|
|
1317
1446
|
rails db:migrate
|
|
1318
1447
|
```
|
|
1319
1448
|
|
|
@@ -1464,10 +1593,13 @@ supportable), `find_requester` (callable, one argument), `engine mount`,
|
|
|
1464
1593
|
(no half-NULL `opened_by`; warns on legacy NULL rows and names the backfill
|
|
1465
1594
|
task), `awaiting` (agrees with the transcript), `references` (unique).
|
|
1466
1595
|
Assistants (only where one is configured): `assistants (config)`,
|
|
1467
|
-
`assistant turn subscriber`, `assistant
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1596
|
+
`assistant turn subscriber`, `assistant serialization` — which warns when
|
|
1597
|
+
the adapter takes no row locks, because the turn rests on them —
|
|
1598
|
+
`assistant authorship`, `assistant silence`, `assistant seats`, `assistant
|
|
1599
|
+
idle turns`, `drafts`, and `ai agents without policy`, which warns about any
|
|
1600
|
+
host class declared `kind: :ai`, since every support write by it is refused.
|
|
1601
|
+
The seat and draft checks also run once she is no longer configured, since
|
|
1602
|
+
that is exactly when a seat gets stranded.
|
|
1471
1603
|
|
|
1472
1604
|
## Compatibility
|
|
1473
1605
|
|
|
@@ -1536,6 +1668,7 @@ SupportDesk.desk(key = :default) # the Desk record, found or created, memo
|
|
|
1536
1668
|
SupportDesk.assistant(key = nil) # the Assistant record, memoised; nil when none is configured
|
|
1537
1669
|
SupportDesk.reset_assistants! SupportDesk.ai_actor?(record)
|
|
1538
1670
|
SupportDesk.release_silent_assistants! # the net under a dead harness (schedule it)
|
|
1671
|
+
SupportDesk.reclaim_assistant_seats! # seats an assistant may no longer sit in
|
|
1539
1672
|
SupportDesk.redispatch_assistant_turns!(older_than: 1.minute)
|
|
1540
1673
|
SupportDesk.find_topic("billing/invoice")
|
|
1541
1674
|
SupportDesk.on(event, key: nil) { … } SupportDesk.off(event, key)
|
|
@@ -44,6 +44,12 @@ module SupportDesk
|
|
|
44
44
|
File.join(db_migrate_path, "add_assistants_to_support_desk.rb")
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
def create_message_registrations_migration
|
|
48
|
+
migration_template "create_support_desk_message_registrations.rb.erb",
|
|
49
|
+
File.join(db_migrate_path, "create_support_desk_message_registrations.rb")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
|
|
47
53
|
# The annotated initializer — every setting the gem has, with what it
|
|
48
54
|
# means and what it defaults to.
|
|
49
55
|
def create_initializer
|
|
@@ -90,9 +96,9 @@ module SupportDesk
|
|
|
90
96
|
say " # routing(/^support@/i => :support_desk)"
|
|
91
97
|
|
|
92
98
|
say "\n Already installed and bumping the version? 'rails g support_desk:upgrade' copies"
|
|
93
|
-
say " only the migrations the new version needs (0.2
|
|
94
|
-
say " nothing you own. Follow the
|
|
95
|
-
say "
|
|
99
|
+
say " only the migrations the new version needs (including 0.3.2 receipts), and"
|
|
100
|
+
say " nothing you own. Follow the README drained cutover: pause support writes,"
|
|
101
|
+
say " drain old web/workers, migrate, then start only the new version."
|
|
96
102
|
|
|
97
103
|
say "\n Assistants (0.3): when a machine should answer first, 'rails g support_desk:assistant"
|
|
98
104
|
say " Rose --disclosure signature' writes the harness and prints the stanza. Read the"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Stop support writes and drain old web/jobs before migrating. Old processes
|
|
4
|
+
# cannot write receipts. Existing messages at/before the legacy clocks form
|
|
5
|
+
# the historical baseline; messages ahead of those clocks remain repairable.
|
|
6
|
+
# A pre-upgrade lost callback behind a clock cannot be inferred from old data:
|
|
7
|
+
# review historical suspect cases before resuming writes (see README).
|
|
8
|
+
class CreateSupportDeskMessageRegistrations < ActiveRecord::Migration<%= migration_version %>
|
|
9
|
+
def up
|
|
10
|
+
return if table_exists?(:support_desk_message_registrations)
|
|
11
|
+
|
|
12
|
+
create_table :support_desk_message_registrations, id: false do |t|
|
|
13
|
+
t.column :message_id, column_type(:chats_messages, "id"), null: false
|
|
14
|
+
t.column :ticket_id, column_type(:support_desk_tickets, "id"), null: false
|
|
15
|
+
end
|
|
16
|
+
add_index :support_desk_message_registrations, :message_id, unique: true, name: "index_sd_registrations_message"
|
|
17
|
+
add_index :support_desk_message_registrations, :ticket_id, name: "index_sd_registrations_ticket"
|
|
18
|
+
add_foreign_key :support_desk_message_registrations, :support_desk_tickets, column: :ticket_id, on_delete: :cascade
|
|
19
|
+
add_foreign_key :support_desk_message_registrations, :chats_messages, column: :message_id, on_delete: :cascade
|
|
20
|
+
|
|
21
|
+
execute <<~SQL
|
|
22
|
+
INSERT INTO support_desk_message_registrations (message_id, ticket_id)
|
|
23
|
+
SELECT m.id, t.id FROM chats_messages m
|
|
24
|
+
JOIN support_desk_tickets t ON t.conversation_id = m.conversation_id
|
|
25
|
+
WHERE m.kind = 'text' AND (
|
|
26
|
+
(m.sender_type = t.requester_type AND m.sender_id = t.requester_id
|
|
27
|
+
AND m.created_at <= t.last_requester_message_at)
|
|
28
|
+
OR (m.sender_type = 'SupportDesk::Desk' AND m.sender_id = t.desk_id
|
|
29
|
+
AND m.created_at <= t.last_agent_message_at))
|
|
30
|
+
SQL
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def down
|
|
34
|
+
drop_table :support_desk_message_registrations, if_exists: true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def column_type(table, name)
|
|
40
|
+
connection.columns(table).find { |column| column.name == name }.sql_type
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -9,16 +9,14 @@ module SupportDesk
|
|
|
9
9
|
# bump needs into an EXISTING install. Nothing else: the initializer, the
|
|
10
10
|
# views, the console and the routes you already own stay untouched.
|
|
11
11
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# same rollback. Running this twice writes nothing the second time: the
|
|
16
|
-
# migration already sitting in db/migrate is identical, and Rails skips it.
|
|
12
|
+
# Install and upgrade use the same templates for opened_by, assistants,
|
|
13
|
+
# and message receipts. See the README for the drained receipt cutover;
|
|
14
|
+
# old writers do not know how to record message identities.
|
|
17
15
|
class UpgradeGenerator < Rails::Generators::Base
|
|
18
16
|
include ActiveRecord::Generators::Migration
|
|
19
17
|
|
|
20
18
|
source_root File.expand_path("templates", __dir__)
|
|
21
|
-
desc "Add the migrations a support_desk version bump needs (
|
|
19
|
+
desc "Add the migrations a support_desk version bump needs (opened_by, assistants, message registration receipts)"
|
|
22
20
|
|
|
23
21
|
# Rails' migration numbering, borrowed from ActiveRecord's generators.
|
|
24
22
|
def self.next_migration_number(dir)
|
|
@@ -35,6 +33,12 @@ module SupportDesk
|
|
|
35
33
|
File.join(db_migrate_path, "add_assistants_to_support_desk.rb")
|
|
36
34
|
end
|
|
37
35
|
|
|
36
|
+
def create_message_registrations_migration
|
|
37
|
+
migration_template "create_support_desk_message_registrations.rb.erb",
|
|
38
|
+
File.join(db_migrate_path, "create_support_desk_message_registrations.rb")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
|
|
38
42
|
def display_post_upgrade_message
|
|
39
43
|
say "\n🎫 support_desk upgrade migrations copied.", :green
|
|
40
44
|
say "\n 1. Run 'rails db:migrate'. It adds `opened_by` and points every existing"
|
|
@@ -53,7 +57,11 @@ module SupportDesk
|
|
|
53
57
|
say " rails g support_desk:assistant Rose --disclosure signature"
|
|
54
58
|
say " writes the harness and prints the stanza, the subscription and the two"
|
|
55
59
|
say " scheduled tasks. It never edits your initializer."
|
|
56
|
-
say " 5.
|
|
60
|
+
say " 5. 0.3.2 receipts require a drained cutover: pause support writes and stop"
|
|
61
|
+
say " old web/jobs, migrate, then start only the new version. Review historical"
|
|
62
|
+
say " suspect callbacks before resuming; old clocks cannot prove delivery."
|
|
63
|
+
say " Do not roll code back to a writer that cannot record receipts."
|
|
64
|
+
say " 6. See the CHANGELOG for the full list.\n", :green
|
|
57
65
|
end
|
|
58
66
|
|
|
59
67
|
private
|
data/lib/support_desk/doctor.rb
CHANGED
|
@@ -162,6 +162,14 @@ module SupportDesk
|
|
|
162
162
|
ok_with("#{SupportDesk.config.assistants.size} assistant(s) configured")
|
|
163
163
|
end
|
|
164
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
|
+
|
|
165
173
|
checks << check("assistant turn subscriber") do
|
|
166
174
|
next ok_with("no assistant on any desk") if assistant_desks.empty?
|
|
167
175
|
next warn_with("nothing subscribes to :assistant_turn — no harness will ever answer. See the " \
|
|
@@ -245,6 +253,12 @@ module SupportDesk
|
|
|
245
253
|
tables?
|
|
246
254
|
|
|
247
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,
|
|
248
262
|
check("conversations") do
|
|
249
263
|
orphans = Ticket.where(conversation_id: nil).count
|
|
250
264
|
next fail_with("#{orphans} ticket(s) without a conversation") if orphans.positive?
|
|
@@ -317,10 +331,13 @@ module SupportDesk
|
|
|
317
331
|
# and never of the policy's own verdict. A policy cannot page anybody
|
|
318
332
|
# about its own bug.
|
|
319
333
|
def assistant_invariant_checks
|
|
320
|
-
# Nothing about a feature nobody turned on: a host with no assistant
|
|
321
|
-
# configured runs not one extra query (I1).
|
|
322
|
-
return [] if SupportDesk.config.assistants.empty?
|
|
323
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?
|
|
324
341
|
|
|
325
342
|
checks = []
|
|
326
343
|
|
|
@@ -376,6 +393,16 @@ module SupportDesk
|
|
|
376
393
|
checks
|
|
377
394
|
end
|
|
378
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
|
+
|
|
379
406
|
# The desks that actually have an assistant, as Desk records.
|
|
380
407
|
def assistant_desks
|
|
381
408
|
SupportDesk.config.desks.each_key.filter_map do |key|
|
data/lib/support_desk/engine.rb
CHANGED
|
@@ -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
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# The default Chats renderer uses the name/disclosure captured when an
|
|
5
|
+
# assistant spoke. Host signature overrides remain authoritative. Human
|
|
6
|
+
# replies (including approved drafts) still use Chats' normal signature.
|
|
7
|
+
module MessageSignatures
|
|
8
|
+
def message_signature_for(message)
|
|
9
|
+
return super if config.message_signature || !message&.signed?
|
|
10
|
+
|
|
11
|
+
stamp = message.metadata.is_a?(Hash) && message.metadata["support_desk"]
|
|
12
|
+
return super unless stamp.is_a?(Hash) && stamp["kind"] == "ai" && stamp["signed"] == true
|
|
13
|
+
|
|
14
|
+
name = stamp["display_name"]
|
|
15
|
+
return super unless name.is_a?(String) && name.present?
|
|
16
|
+
|
|
17
|
+
I18n.t("chats.message.signature", name: name)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|