@pegma/support-desk-application 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -10
- package/dist/assistant-turns.d.ts.map +1 -1
- package/dist/assistant-turns.js +30 -10
- package/dist/assistant-turns.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +131 -48
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { auditRecordId, defineAudit } from "@pegma/audit";
|
|
|
2
2
|
import { hasPermission } from "@pegma/authorization-core";
|
|
3
3
|
import { defineMail, maxMailAttempts } from "@pegma/mail";
|
|
4
4
|
import { noopLogger } from "@pegma/spine";
|
|
5
|
-
import { defineCollection, MAX_SCAN_PAGE_SIZE, } from "@pegma/storage-core";
|
|
5
|
+
import { ConcurrencyError, defineCollection, MAX_SCAN_PAGE_SIZE, } from "@pegma/storage-core";
|
|
6
6
|
import { applyTicketEvent, createTicket, effectiveTicketHandling, } from "@pegma/support-desk-core";
|
|
7
7
|
import { assertAssistantTurnRecord, createAssistantTurnRecord, resolveAssistantTurnOptions, } from "./assistant-turn-records.js";
|
|
8
8
|
import { createAssistantTurnServices, } from "./assistant-turns.js";
|
|
@@ -1068,12 +1068,34 @@ function enforcePartitionRecordLimit(recordCount, maximum, additional = 0) {
|
|
|
1068
1068
|
export const assistantHumanReserveRows = 12;
|
|
1069
1069
|
export const assistantHumanReserveMessages = 1;
|
|
1070
1070
|
const firstHumanReplyReserveRows = 4;
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1071
|
+
/** Settle unused speculative failures without changing their consumer order. */
|
|
1072
|
+
async function prepareRead(read) {
|
|
1073
|
+
try {
|
|
1074
|
+
return { outcome: "read", value: await read() };
|
|
1075
|
+
}
|
|
1076
|
+
catch (error) {
|
|
1077
|
+
return { outcome: "failed", error };
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
function readValue(observation) {
|
|
1081
|
+
if (observation.outcome === "failed")
|
|
1082
|
+
throw observation.error;
|
|
1083
|
+
return observation.value;
|
|
1084
|
+
}
|
|
1085
|
+
async function planTicketDerivationMutation(records, before, after, rows, visibleChange = false, notice, parallel = false) {
|
|
1086
|
+
const learningRead = planResolutionLearningTicketMutation(records, before, after, notice !== undefined && visibleChange);
|
|
1087
|
+
const engineeringRead = parallel && notice === undefined
|
|
1088
|
+
? prepareRead(() => planEngineeringTicketMutation(records, before, after, {
|
|
1075
1089
|
visibleChange,
|
|
1076
|
-
})
|
|
1090
|
+
}))
|
|
1091
|
+
: undefined;
|
|
1092
|
+
const learning = await learningRead;
|
|
1093
|
+
const engineering = notice === undefined
|
|
1094
|
+
? engineeringRead === undefined
|
|
1095
|
+
? await planEngineeringTicketMutation(records, before, after, {
|
|
1096
|
+
visibleChange,
|
|
1097
|
+
})
|
|
1098
|
+
: readValue(await engineeringRead)
|
|
1077
1099
|
: { actions: [], additionalRows: 0 };
|
|
1078
1100
|
const reopened = ["resolved", "closed"].includes(before.value.ticket.status) &&
|
|
1079
1101
|
!["resolved", "closed"].includes(after.status);
|
|
@@ -1499,41 +1521,8 @@ export async function projectTicketToQueue(ticketId, options) {
|
|
|
1499
1521
|
}
|
|
1500
1522
|
const records = store.collection(supportRecords);
|
|
1501
1523
|
const projections = store.collection(queueIndex);
|
|
1502
|
-
const
|
|
1503
|
-
|
|
1504
|
-
const existing = await projections.getVersioned(queueProjectionKey(ticketId));
|
|
1505
|
-
if (existing === null) {
|
|
1506
|
-
return "absent";
|
|
1507
|
-
}
|
|
1508
|
-
const removed = await projections.deleteIfUnchanged(queueProjectionKey(ticketId), existing.version);
|
|
1509
|
-
return removed ? "deleted" : "kept";
|
|
1510
|
-
}
|
|
1511
|
-
const ticket = ticketRecord.ticket;
|
|
1512
|
-
if (ticket.id !== ticketId) {
|
|
1513
|
-
throw new TypeError("stored ticket id does not match its partition");
|
|
1514
|
-
}
|
|
1515
|
-
// Sample the host clock only for terminal retention. Active tickets never
|
|
1516
|
-
// consult wall time, so post-commit projection does not consume sequence
|
|
1517
|
-
// clocks used by ticket mutations.
|
|
1518
|
-
if (isTerminalTicketStatus(ticket.status)) {
|
|
1519
|
-
const now = clock.now();
|
|
1520
|
-
if (ticketBeyondTerminalCutoff(ticket, now, terminalRetentionMilliseconds)) {
|
|
1521
|
-
const existing = await projections.getVersioned(queueProjectionKey(ticketId));
|
|
1522
|
-
if (existing === null) {
|
|
1523
|
-
return "absent";
|
|
1524
|
-
}
|
|
1525
|
-
// Only prune a projection that still matches this terminal revision.
|
|
1526
|
-
// A concurrent reopen can land a newer active row between the ticket
|
|
1527
|
-
// read and this delete; that row must be preserved for repair to see.
|
|
1528
|
-
if (existing.value.projectedRevision !== ticket.revision) {
|
|
1529
|
-
return "kept";
|
|
1530
|
-
}
|
|
1531
|
-
const removed = await projections.deleteIfUnchanged(queueProjectionKey(ticketId), existing.version);
|
|
1532
|
-
return removed ? "deleted" : "kept";
|
|
1533
|
-
}
|
|
1534
|
-
}
|
|
1535
|
-
const derived = deriveQueueIndexRecord(ticket);
|
|
1536
|
-
const result = await projections.update(queueProjectionKey(ticketId), (current) => {
|
|
1524
|
+
const key = queueProjectionKey(ticketId);
|
|
1525
|
+
function decide(current, derived) {
|
|
1537
1526
|
if (current === null) {
|
|
1538
1527
|
return { action: "write", value: derived };
|
|
1539
1528
|
}
|
|
@@ -1560,8 +1549,55 @@ export async function projectTicketToQueue(ticketId, options) {
|
|
|
1560
1549
|
if (queueIndexContentEqual(current, legacy))
|
|
1561
1550
|
return { action: "write", value: derived };
|
|
1562
1551
|
throw new SupportDeskConflictError("Queue projection revision collision with divergent content");
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1552
|
+
}
|
|
1553
|
+
for (let attempt = 0; attempt < maxConflictAttempts; attempt += 1) {
|
|
1554
|
+
const ticketRead = records.get(ticketKey(ticketId));
|
|
1555
|
+
const projectionRead = prepareRead(() => projections.getVersioned(key));
|
|
1556
|
+
const ticketRecord = await ticketRead;
|
|
1557
|
+
if (ticketRecord?.kind !== "ticket") {
|
|
1558
|
+
const existing = readValue(await projectionRead);
|
|
1559
|
+
if (existing === null)
|
|
1560
|
+
return "absent";
|
|
1561
|
+
if (await projections.deleteIfUnchanged(key, existing.version))
|
|
1562
|
+
return "deleted";
|
|
1563
|
+
if (attempt + 1 === maxConflictAttempts)
|
|
1564
|
+
return "kept";
|
|
1565
|
+
continue;
|
|
1566
|
+
}
|
|
1567
|
+
const ticket = ticketRecord.ticket;
|
|
1568
|
+
if (ticket.id !== ticketId)
|
|
1569
|
+
throw new TypeError("stored ticket id does not match its partition");
|
|
1570
|
+
// Active tickets never consult wall time. Retry terminal retention only
|
|
1571
|
+
// with the new authoritative Ticket and projection observations together.
|
|
1572
|
+
if (isTerminalTicketStatus(ticket.status)) {
|
|
1573
|
+
const now = clock.now();
|
|
1574
|
+
if (ticketBeyondTerminalCutoff(ticket, now, terminalRetentionMilliseconds)) {
|
|
1575
|
+
const existing = readValue(await projectionRead);
|
|
1576
|
+
if (existing === null)
|
|
1577
|
+
return "absent";
|
|
1578
|
+
// A newer active projection from a concurrent reopen must survive.
|
|
1579
|
+
if (existing.value.projectedRevision !== ticket.revision)
|
|
1580
|
+
return "kept";
|
|
1581
|
+
if (await projections.deleteIfUnchanged(key, existing.version))
|
|
1582
|
+
return "deleted";
|
|
1583
|
+
if (attempt + 1 === maxConflictAttempts)
|
|
1584
|
+
return "kept";
|
|
1585
|
+
continue;
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
const derived = deriveQueueIndexRecord(ticket);
|
|
1589
|
+
const existing = readValue(await projectionRead);
|
|
1590
|
+
const decision = decide(existing?.value ?? null, derived);
|
|
1591
|
+
if (decision.action !== "write")
|
|
1592
|
+
return "kept";
|
|
1593
|
+
const written = existing === null
|
|
1594
|
+
? (await projections.insertIfAbsent(decision.value)).inserted
|
|
1595
|
+
: await projections.putIfUnchanged(decision.value, existing.version);
|
|
1596
|
+
if (written)
|
|
1597
|
+
return "written";
|
|
1598
|
+
// Rebuild both observations; never keep a stale Ticket across a CAS retry.
|
|
1599
|
+
}
|
|
1600
|
+
throw new ConcurrencyError(queueIndex.name, key, maxConflictAttempts);
|
|
1565
1601
|
}
|
|
1566
1602
|
function toStaffQueueItem(ticket) {
|
|
1567
1603
|
return Object.freeze({
|
|
@@ -2625,13 +2661,21 @@ export function createSupportDeskApplication(options) {
|
|
|
2625
2661
|
? {}
|
|
2626
2662
|
: { assistantTurnFingerprint: assistantTurnCommit.fingerprint }),
|
|
2627
2663
|
});
|
|
2664
|
+
const completion = assistantTurnCommit;
|
|
2665
|
+
const parallel = completion?.kind === "complete";
|
|
2628
2666
|
for (let attempt = 0; attempt < maxConflictAttempts; attempt += 1) {
|
|
2629
|
-
const
|
|
2667
|
+
const ticketRead = records.getVersioned(ticketKey(command.ticketId));
|
|
2668
|
+
const duplicateRead = parallel
|
|
2669
|
+
? prepareRead(() => records.get(commandKey(command.ticketId, command.commandId)))
|
|
2670
|
+
: undefined;
|
|
2671
|
+
const versioned = await ticketRead;
|
|
2630
2672
|
if (versioned?.value.kind !== "ticket" ||
|
|
2631
2673
|
(customer && !owns(versioned.value.ticket, access.principalId)))
|
|
2632
2674
|
throw new SupportDeskNotFoundError();
|
|
2633
2675
|
const ticket = versioned.value.ticket;
|
|
2634
|
-
const duplicate =
|
|
2676
|
+
const duplicate = duplicateRead === undefined
|
|
2677
|
+
? await records.get(commandKey(command.ticketId, command.commandId))
|
|
2678
|
+
: readValue(await duplicateRead);
|
|
2635
2679
|
if (duplicateMatches(duplicate, operation, command.messageId, fingerprint)) {
|
|
2636
2680
|
if (duplicate?.kind !== "command" ||
|
|
2637
2681
|
duplicate.resultTicket === undefined)
|
|
@@ -2640,6 +2684,27 @@ export function createSupportDeskApplication(options) {
|
|
|
2640
2684
|
}
|
|
2641
2685
|
if (duplicate !== null)
|
|
2642
2686
|
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
2687
|
+
if (parallel &&
|
|
2688
|
+
(ticket.channel !== "web" ||
|
|
2689
|
+
isTerminalTicketStatus(ticket.status) ||
|
|
2690
|
+
effectiveTicketHandling(ticket).state !== "assistant" ||
|
|
2691
|
+
ticket.revision !== command.expectedRevision ||
|
|
2692
|
+
effectiveTicketHandling(ticket).generation !==
|
|
2693
|
+
command.expectedHandlingGeneration)) {
|
|
2694
|
+
// The speculative receipt may predate a concurrent exact completion
|
|
2695
|
+
// that this Ticket observation already contains. Recover its receipt
|
|
2696
|
+
// before rejecting the now-stale live fence; ordinary paired reads
|
|
2697
|
+
// retain their existing cost and original whole-attempt CAS versions.
|
|
2698
|
+
const accepted = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2699
|
+
if (duplicateMatches(accepted, operation, command.messageId, fingerprint)) {
|
|
2700
|
+
if (accepted?.kind !== "command" ||
|
|
2701
|
+
accepted.resultTicket === undefined)
|
|
2702
|
+
throw new SupportDeskConflictError("Handling receipt is incomplete");
|
|
2703
|
+
return accepted.resultTicket;
|
|
2704
|
+
}
|
|
2705
|
+
if (accepted !== null)
|
|
2706
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
2707
|
+
}
|
|
2643
2708
|
if (operation === "assistant_reply" && !assistantHandlingEnabled)
|
|
2644
2709
|
throw new SupportDeskConflictError("Assistant handling is disabled");
|
|
2645
2710
|
const rows = await records.list(command.ticketId);
|
|
@@ -2698,7 +2763,21 @@ export function createSupportDeskApplication(options) {
|
|
|
2698
2763
|
});
|
|
2699
2764
|
if (updated.revision === ticket.revision)
|
|
2700
2765
|
return ticket;
|
|
2701
|
-
const
|
|
2766
|
+
const originalTicket = {
|
|
2767
|
+
value: versioned.value,
|
|
2768
|
+
version: versioned.version,
|
|
2769
|
+
};
|
|
2770
|
+
const planMutation = () => planTicketDerivationMutation(records, originalTicket, updated, rows, operation === "assistant_reply", undefined, parallel);
|
|
2771
|
+
const mutationRead = parallel ? prepareRead(planMutation) : undefined;
|
|
2772
|
+
const quotaRead = parallel && operation === "assistant_reply"
|
|
2773
|
+
? prepareRead(() => records.getVersioned(ticketQuotaKey(command.ticketId)))
|
|
2774
|
+
: undefined;
|
|
2775
|
+
const assistantRead = parallel && completion !== undefined
|
|
2776
|
+
? prepareRead(() => assistantTurns.completionAction(ticket, rows, updated, completion))
|
|
2777
|
+
: undefined;
|
|
2778
|
+
const learningMutation = mutationRead === undefined
|
|
2779
|
+
? await planMutation()
|
|
2780
|
+
: readValue(await mutationRead);
|
|
2702
2781
|
const learningConfirmation = operation === "confirm_customer_success"
|
|
2703
2782
|
? await resolutionLearning.customerConfirmation(learningMutation.source, updated, access.principalId, command.lastSeenVisibleMessageId, command.commandId, rows)
|
|
2704
2783
|
: { actions: [], additionalRows: 0 };
|
|
@@ -2714,7 +2793,9 @@ export function createSupportDeskApplication(options) {
|
|
|
2714
2793
|
...learningConfirmation.actions,
|
|
2715
2794
|
];
|
|
2716
2795
|
if (operation === "assistant_reply") {
|
|
2717
|
-
const quota =
|
|
2796
|
+
const quota = quotaRead === undefined
|
|
2797
|
+
? await records.getVersioned(ticketQuotaKey(command.ticketId))
|
|
2798
|
+
: readValue(await quotaRead);
|
|
2718
2799
|
if (quota?.value.kind !== "ticket_quota")
|
|
2719
2800
|
throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
|
|
2720
2801
|
if (quota.value.messageCount >= limits.maxMessagesPerTicket)
|
|
@@ -2827,7 +2908,9 @@ export function createSupportDeskApplication(options) {
|
|
|
2827
2908
|
});
|
|
2828
2909
|
const assistantCommit = assistantTurnCommit === undefined
|
|
2829
2910
|
? undefined
|
|
2830
|
-
:
|
|
2911
|
+
: assistantRead === undefined
|
|
2912
|
+
? await assistantTurns.completionAction(ticket, rows, updated, assistantTurnCommit)
|
|
2913
|
+
: readValue(await assistantRead);
|
|
2831
2914
|
if (assistantCommit !== undefined) {
|
|
2832
2915
|
actions.push(assistantCommit.action);
|
|
2833
2916
|
await assistantCommit.verifyBeforeCommit();
|