@pegma/support-desk-application 0.2.0 → 0.2.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.
- package/README.md +20 -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 +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +132 -48
- package/dist/index.js.map +1 -1
- package/dist/resolution-learning-types.d.ts +19 -0
- package/dist/resolution-learning-types.d.ts.map +1 -1
- package/dist/resolution-learning-worker.d.ts +1 -1
- package/dist/resolution-learning-worker.d.ts.map +1 -1
- package/dist/resolution-learning-worker.js +155 -31
- package/dist/resolution-learning-worker.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";
|
|
@@ -149,6 +149,7 @@ export const supportTicketAuditActions = Object.freeze({
|
|
|
149
149
|
learningEvidenceInvalidated: "support.learning.evidence_invalidated",
|
|
150
150
|
learningWithdrawn: "support.learning.withdrawn",
|
|
151
151
|
learningReviewed: "support.learning.reviewed",
|
|
152
|
+
learningReviewCancelled: "support.learning.review_cancelled",
|
|
152
153
|
engineeringEnrolled: "support.engineering.enrolled",
|
|
153
154
|
engineeringEvidenceRecorded: "support.engineering.evidence_recorded",
|
|
154
155
|
engineeringEvidenceInvalidated: "support.engineering.evidence_invalidated",
|
|
@@ -1068,12 +1069,34 @@ function enforcePartitionRecordLimit(recordCount, maximum, additional = 0) {
|
|
|
1068
1069
|
export const assistantHumanReserveRows = 12;
|
|
1069
1070
|
export const assistantHumanReserveMessages = 1;
|
|
1070
1071
|
const firstHumanReplyReserveRows = 4;
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1072
|
+
/** Settle unused speculative failures without changing their consumer order. */
|
|
1073
|
+
async function prepareRead(read) {
|
|
1074
|
+
try {
|
|
1075
|
+
return { outcome: "read", value: await read() };
|
|
1076
|
+
}
|
|
1077
|
+
catch (error) {
|
|
1078
|
+
return { outcome: "failed", error };
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
function readValue(observation) {
|
|
1082
|
+
if (observation.outcome === "failed")
|
|
1083
|
+
throw observation.error;
|
|
1084
|
+
return observation.value;
|
|
1085
|
+
}
|
|
1086
|
+
async function planTicketDerivationMutation(records, before, after, rows, visibleChange = false, notice, parallel = false) {
|
|
1087
|
+
const learningRead = planResolutionLearningTicketMutation(records, before, after, notice !== undefined && visibleChange);
|
|
1088
|
+
const engineeringRead = parallel && notice === undefined
|
|
1089
|
+
? prepareRead(() => planEngineeringTicketMutation(records, before, after, {
|
|
1075
1090
|
visibleChange,
|
|
1076
|
-
})
|
|
1091
|
+
}))
|
|
1092
|
+
: undefined;
|
|
1093
|
+
const learning = await learningRead;
|
|
1094
|
+
const engineering = notice === undefined
|
|
1095
|
+
? engineeringRead === undefined
|
|
1096
|
+
? await planEngineeringTicketMutation(records, before, after, {
|
|
1097
|
+
visibleChange,
|
|
1098
|
+
})
|
|
1099
|
+
: readValue(await engineeringRead)
|
|
1077
1100
|
: { actions: [], additionalRows: 0 };
|
|
1078
1101
|
const reopened = ["resolved", "closed"].includes(before.value.ticket.status) &&
|
|
1079
1102
|
!["resolved", "closed"].includes(after.status);
|
|
@@ -1499,41 +1522,8 @@ export async function projectTicketToQueue(ticketId, options) {
|
|
|
1499
1522
|
}
|
|
1500
1523
|
const records = store.collection(supportRecords);
|
|
1501
1524
|
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) => {
|
|
1525
|
+
const key = queueProjectionKey(ticketId);
|
|
1526
|
+
function decide(current, derived) {
|
|
1537
1527
|
if (current === null) {
|
|
1538
1528
|
return { action: "write", value: derived };
|
|
1539
1529
|
}
|
|
@@ -1560,8 +1550,55 @@ export async function projectTicketToQueue(ticketId, options) {
|
|
|
1560
1550
|
if (queueIndexContentEqual(current, legacy))
|
|
1561
1551
|
return { action: "write", value: derived };
|
|
1562
1552
|
throw new SupportDeskConflictError("Queue projection revision collision with divergent content");
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1553
|
+
}
|
|
1554
|
+
for (let attempt = 0; attempt < maxConflictAttempts; attempt += 1) {
|
|
1555
|
+
const ticketRead = records.get(ticketKey(ticketId));
|
|
1556
|
+
const projectionRead = prepareRead(() => projections.getVersioned(key));
|
|
1557
|
+
const ticketRecord = await ticketRead;
|
|
1558
|
+
if (ticketRecord?.kind !== "ticket") {
|
|
1559
|
+
const existing = readValue(await projectionRead);
|
|
1560
|
+
if (existing === null)
|
|
1561
|
+
return "absent";
|
|
1562
|
+
if (await projections.deleteIfUnchanged(key, existing.version))
|
|
1563
|
+
return "deleted";
|
|
1564
|
+
if (attempt + 1 === maxConflictAttempts)
|
|
1565
|
+
return "kept";
|
|
1566
|
+
continue;
|
|
1567
|
+
}
|
|
1568
|
+
const ticket = ticketRecord.ticket;
|
|
1569
|
+
if (ticket.id !== ticketId)
|
|
1570
|
+
throw new TypeError("stored ticket id does not match its partition");
|
|
1571
|
+
// Active tickets never consult wall time. Retry terminal retention only
|
|
1572
|
+
// with the new authoritative Ticket and projection observations together.
|
|
1573
|
+
if (isTerminalTicketStatus(ticket.status)) {
|
|
1574
|
+
const now = clock.now();
|
|
1575
|
+
if (ticketBeyondTerminalCutoff(ticket, now, terminalRetentionMilliseconds)) {
|
|
1576
|
+
const existing = readValue(await projectionRead);
|
|
1577
|
+
if (existing === null)
|
|
1578
|
+
return "absent";
|
|
1579
|
+
// A newer active projection from a concurrent reopen must survive.
|
|
1580
|
+
if (existing.value.projectedRevision !== ticket.revision)
|
|
1581
|
+
return "kept";
|
|
1582
|
+
if (await projections.deleteIfUnchanged(key, existing.version))
|
|
1583
|
+
return "deleted";
|
|
1584
|
+
if (attempt + 1 === maxConflictAttempts)
|
|
1585
|
+
return "kept";
|
|
1586
|
+
continue;
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
const derived = deriveQueueIndexRecord(ticket);
|
|
1590
|
+
const existing = readValue(await projectionRead);
|
|
1591
|
+
const decision = decide(existing?.value ?? null, derived);
|
|
1592
|
+
if (decision.action !== "write")
|
|
1593
|
+
return "kept";
|
|
1594
|
+
const written = existing === null
|
|
1595
|
+
? (await projections.insertIfAbsent(decision.value)).inserted
|
|
1596
|
+
: await projections.putIfUnchanged(decision.value, existing.version);
|
|
1597
|
+
if (written)
|
|
1598
|
+
return "written";
|
|
1599
|
+
// Rebuild both observations; never keep a stale Ticket across a CAS retry.
|
|
1600
|
+
}
|
|
1601
|
+
throw new ConcurrencyError(queueIndex.name, key, maxConflictAttempts);
|
|
1565
1602
|
}
|
|
1566
1603
|
function toStaffQueueItem(ticket) {
|
|
1567
1604
|
return Object.freeze({
|
|
@@ -2625,13 +2662,21 @@ export function createSupportDeskApplication(options) {
|
|
|
2625
2662
|
? {}
|
|
2626
2663
|
: { assistantTurnFingerprint: assistantTurnCommit.fingerprint }),
|
|
2627
2664
|
});
|
|
2665
|
+
const completion = assistantTurnCommit;
|
|
2666
|
+
const parallel = completion?.kind === "complete";
|
|
2628
2667
|
for (let attempt = 0; attempt < maxConflictAttempts; attempt += 1) {
|
|
2629
|
-
const
|
|
2668
|
+
const ticketRead = records.getVersioned(ticketKey(command.ticketId));
|
|
2669
|
+
const duplicateRead = parallel
|
|
2670
|
+
? prepareRead(() => records.get(commandKey(command.ticketId, command.commandId)))
|
|
2671
|
+
: undefined;
|
|
2672
|
+
const versioned = await ticketRead;
|
|
2630
2673
|
if (versioned?.value.kind !== "ticket" ||
|
|
2631
2674
|
(customer && !owns(versioned.value.ticket, access.principalId)))
|
|
2632
2675
|
throw new SupportDeskNotFoundError();
|
|
2633
2676
|
const ticket = versioned.value.ticket;
|
|
2634
|
-
const duplicate =
|
|
2677
|
+
const duplicate = duplicateRead === undefined
|
|
2678
|
+
? await records.get(commandKey(command.ticketId, command.commandId))
|
|
2679
|
+
: readValue(await duplicateRead);
|
|
2635
2680
|
if (duplicateMatches(duplicate, operation, command.messageId, fingerprint)) {
|
|
2636
2681
|
if (duplicate?.kind !== "command" ||
|
|
2637
2682
|
duplicate.resultTicket === undefined)
|
|
@@ -2640,6 +2685,27 @@ export function createSupportDeskApplication(options) {
|
|
|
2640
2685
|
}
|
|
2641
2686
|
if (duplicate !== null)
|
|
2642
2687
|
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
2688
|
+
if (parallel &&
|
|
2689
|
+
(ticket.channel !== "web" ||
|
|
2690
|
+
isTerminalTicketStatus(ticket.status) ||
|
|
2691
|
+
effectiveTicketHandling(ticket).state !== "assistant" ||
|
|
2692
|
+
ticket.revision !== command.expectedRevision ||
|
|
2693
|
+
effectiveTicketHandling(ticket).generation !==
|
|
2694
|
+
command.expectedHandlingGeneration)) {
|
|
2695
|
+
// The speculative receipt may predate a concurrent exact completion
|
|
2696
|
+
// that this Ticket observation already contains. Recover its receipt
|
|
2697
|
+
// before rejecting the now-stale live fence; ordinary paired reads
|
|
2698
|
+
// retain their existing cost and original whole-attempt CAS versions.
|
|
2699
|
+
const accepted = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2700
|
+
if (duplicateMatches(accepted, operation, command.messageId, fingerprint)) {
|
|
2701
|
+
if (accepted?.kind !== "command" ||
|
|
2702
|
+
accepted.resultTicket === undefined)
|
|
2703
|
+
throw new SupportDeskConflictError("Handling receipt is incomplete");
|
|
2704
|
+
return accepted.resultTicket;
|
|
2705
|
+
}
|
|
2706
|
+
if (accepted !== null)
|
|
2707
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
2708
|
+
}
|
|
2643
2709
|
if (operation === "assistant_reply" && !assistantHandlingEnabled)
|
|
2644
2710
|
throw new SupportDeskConflictError("Assistant handling is disabled");
|
|
2645
2711
|
const rows = await records.list(command.ticketId);
|
|
@@ -2698,7 +2764,21 @@ export function createSupportDeskApplication(options) {
|
|
|
2698
2764
|
});
|
|
2699
2765
|
if (updated.revision === ticket.revision)
|
|
2700
2766
|
return ticket;
|
|
2701
|
-
const
|
|
2767
|
+
const originalTicket = {
|
|
2768
|
+
value: versioned.value,
|
|
2769
|
+
version: versioned.version,
|
|
2770
|
+
};
|
|
2771
|
+
const planMutation = () => planTicketDerivationMutation(records, originalTicket, updated, rows, operation === "assistant_reply", undefined, parallel);
|
|
2772
|
+
const mutationRead = parallel ? prepareRead(planMutation) : undefined;
|
|
2773
|
+
const quotaRead = parallel && operation === "assistant_reply"
|
|
2774
|
+
? prepareRead(() => records.getVersioned(ticketQuotaKey(command.ticketId)))
|
|
2775
|
+
: undefined;
|
|
2776
|
+
const assistantRead = parallel && completion !== undefined
|
|
2777
|
+
? prepareRead(() => assistantTurns.completionAction(ticket, rows, updated, completion))
|
|
2778
|
+
: undefined;
|
|
2779
|
+
const learningMutation = mutationRead === undefined
|
|
2780
|
+
? await planMutation()
|
|
2781
|
+
: readValue(await mutationRead);
|
|
2702
2782
|
const learningConfirmation = operation === "confirm_customer_success"
|
|
2703
2783
|
? await resolutionLearning.customerConfirmation(learningMutation.source, updated, access.principalId, command.lastSeenVisibleMessageId, command.commandId, rows)
|
|
2704
2784
|
: { actions: [], additionalRows: 0 };
|
|
@@ -2714,7 +2794,9 @@ export function createSupportDeskApplication(options) {
|
|
|
2714
2794
|
...learningConfirmation.actions,
|
|
2715
2795
|
];
|
|
2716
2796
|
if (operation === "assistant_reply") {
|
|
2717
|
-
const quota =
|
|
2797
|
+
const quota = quotaRead === undefined
|
|
2798
|
+
? await records.getVersioned(ticketQuotaKey(command.ticketId))
|
|
2799
|
+
: readValue(await quotaRead);
|
|
2718
2800
|
if (quota?.value.kind !== "ticket_quota")
|
|
2719
2801
|
throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
|
|
2720
2802
|
if (quota.value.messageCount >= limits.maxMessagesPerTicket)
|
|
@@ -2827,7 +2909,9 @@ export function createSupportDeskApplication(options) {
|
|
|
2827
2909
|
});
|
|
2828
2910
|
const assistantCommit = assistantTurnCommit === undefined
|
|
2829
2911
|
? undefined
|
|
2830
|
-
:
|
|
2912
|
+
: assistantRead === undefined
|
|
2913
|
+
? await assistantTurns.completionAction(ticket, rows, updated, assistantTurnCommit)
|
|
2914
|
+
: readValue(await assistantRead);
|
|
2831
2915
|
if (assistantCommit !== undefined) {
|
|
2832
2916
|
actions.push(assistantCommit.action);
|
|
2833
2917
|
await assistantCommit.verifyBeforeCommit();
|