@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/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
- async function planTicketDerivationMutation(records, before, after, rows, visibleChange = false, notice) {
1072
- const learning = await planResolutionLearningTicketMutation(records, before, after, notice !== undefined && visibleChange);
1073
- const engineering = notice === undefined
1074
- ? await planEngineeringTicketMutation(records, before, after, {
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 ticketRecord = await records.get(ticketKey(ticketId));
1503
- if (ticketRecord?.kind !== "ticket") {
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
- }, { maxAttempts: maxConflictAttempts });
1564
- return result.written ? "written" : "kept";
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 versioned = await records.getVersioned(ticketKey(command.ticketId));
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 = await records.get(commandKey(command.ticketId, command.commandId));
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 learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, rows, operation === "assistant_reply");
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 = await records.getVersioned(ticketQuotaKey(command.ticketId));
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
- : await assistantTurns.completionAction(ticket, rows, updated, assistantTurnCommit);
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();