@pegma/support-desk-application 0.1.1 → 0.2.0

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.
Files changed (86) hide show
  1. package/README.md +228 -7
  2. package/dist/assistant-turn-capacity.d.ts +13 -0
  3. package/dist/assistant-turn-capacity.d.ts.map +1 -0
  4. package/dist/assistant-turn-capacity.js +111 -0
  5. package/dist/assistant-turn-capacity.js.map +1 -0
  6. package/dist/assistant-turn-records.d.ts +68 -0
  7. package/dist/assistant-turn-records.d.ts.map +1 -0
  8. package/dist/assistant-turn-records.js +418 -0
  9. package/dist/assistant-turn-records.js.map +1 -0
  10. package/dist/assistant-turn-types.d.ts +133 -0
  11. package/dist/assistant-turn-types.d.ts.map +1 -0
  12. package/dist/assistant-turn-types.js +2 -0
  13. package/dist/assistant-turn-types.js.map +1 -0
  14. package/dist/assistant-turns.d.ts +42 -0
  15. package/dist/assistant-turns.d.ts.map +1 -0
  16. package/dist/assistant-turns.js +640 -0
  17. package/dist/assistant-turns.js.map +1 -0
  18. package/dist/engineering-evidence.d.ts +20 -0
  19. package/dist/engineering-evidence.d.ts.map +1 -0
  20. package/dist/engineering-evidence.js +899 -0
  21. package/dist/engineering-evidence.js.map +1 -0
  22. package/dist/engineering-followup-internal.d.ts +45 -0
  23. package/dist/engineering-followup-internal.d.ts.map +1 -0
  24. package/dist/engineering-followup-internal.js +2 -0
  25. package/dist/engineering-followup-internal.js.map +1 -0
  26. package/dist/engineering-followup-records.d.ts +209 -0
  27. package/dist/engineering-followup-records.d.ts.map +1 -0
  28. package/dist/engineering-followup-records.js +1434 -0
  29. package/dist/engineering-followup-records.js.map +1 -0
  30. package/dist/engineering-followup-source.d.ts +168 -0
  31. package/dist/engineering-followup-source.d.ts.map +1 -0
  32. package/dist/engineering-followup-source.js +1144 -0
  33. package/dist/engineering-followup-source.js.map +1 -0
  34. package/dist/engineering-followup-types.d.ts +364 -0
  35. package/dist/engineering-followup-types.d.ts.map +1 -0
  36. package/dist/engineering-followup-types.js +2 -0
  37. package/dist/engineering-followup-types.js.map +1 -0
  38. package/dist/engineering-followup-work.d.ts +70 -0
  39. package/dist/engineering-followup-work.d.ts.map +1 -0
  40. package/dist/engineering-followup-work.js +1153 -0
  41. package/dist/engineering-followup-work.js.map +1 -0
  42. package/dist/engineering-internal.d.ts +38 -0
  43. package/dist/engineering-internal.d.ts.map +1 -0
  44. package/dist/engineering-internal.js +11 -0
  45. package/dist/engineering-internal.js.map +1 -0
  46. package/dist/engineering-policy.d.ts +47 -0
  47. package/dist/engineering-policy.d.ts.map +1 -0
  48. package/dist/engineering-policy.js +579 -0
  49. package/dist/engineering-policy.js.map +1 -0
  50. package/dist/engineering-reconciliation.d.ts +6 -0
  51. package/dist/engineering-reconciliation.d.ts.map +1 -0
  52. package/dist/engineering-reconciliation.js +464 -0
  53. package/dist/engineering-reconciliation.js.map +1 -0
  54. package/dist/engineering-records.d.ts +227 -0
  55. package/dist/engineering-records.d.ts.map +1 -0
  56. package/dist/engineering-records.js +1272 -0
  57. package/dist/engineering-records.js.map +1 -0
  58. package/dist/engineering-types.d.ts +505 -0
  59. package/dist/engineering-types.d.ts.map +1 -0
  60. package/dist/engineering-types.js +2 -0
  61. package/dist/engineering-types.js.map +1 -0
  62. package/dist/engineering-work.d.ts +32 -0
  63. package/dist/engineering-work.d.ts.map +1 -0
  64. package/dist/engineering-work.js +1757 -0
  65. package/dist/engineering-work.js.map +1 -0
  66. package/dist/index.d.ts +609 -14
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +3568 -204
  69. package/dist/index.js.map +1 -1
  70. package/dist/resolution-learning-records.d.ts +154 -0
  71. package/dist/resolution-learning-records.d.ts.map +1 -0
  72. package/dist/resolution-learning-records.js +657 -0
  73. package/dist/resolution-learning-records.js.map +1 -0
  74. package/dist/resolution-learning-types.d.ts +356 -0
  75. package/dist/resolution-learning-types.d.ts.map +1 -0
  76. package/dist/resolution-learning-types.js +2 -0
  77. package/dist/resolution-learning-types.js.map +1 -0
  78. package/dist/resolution-learning-worker.d.ts +21 -0
  79. package/dist/resolution-learning-worker.d.ts.map +1 -0
  80. package/dist/resolution-learning-worker.js +1027 -0
  81. package/dist/resolution-learning-worker.js.map +1 -0
  82. package/dist/resolution-learning.d.ts +38 -0
  83. package/dist/resolution-learning.d.ts.map +1 -0
  84. package/dist/resolution-learning.js +860 -0
  85. package/dist/resolution-learning.js.map +1 -0
  86. package/package.json +6 -6
@@ -0,0 +1,640 @@
1
+ import { hasPermission } from "@pegma/authorization-core";
2
+ import { MAX_SCAN_PAGE_SIZE } from "@pegma/storage-core";
3
+ import { effectiveTicketHandling } from "@pegma/support-desk-core";
4
+ import { SupportDeskAuthorizationError, SupportDeskConflictError, SupportDeskLimitError, supportPermissions, } from "./index.js";
5
+ import { assertAssistantTurnRecord, assistantTurnProcessingState, assistantTurnKey, canonical, dataArray, encodedBytes, fields, fingerprint, integer, maximumRecordBytes, preparedRecordBytes, snapshot, terminalTurn, text, timestamp, validateBinding, validateFence, validateProcessing, withoutAssistantTurnLease as withoutLease, } from "./assistant-turn-records.js";
6
+ import { maximumPostPreparationRecordBytes } from "./assistant-turn-capacity.js";
7
+ function permission(access, name) {
8
+ text(access.principalId, "principalId");
9
+ if (!hasPermission(access, name))
10
+ throw new SupportDeskAuthorizationError(name);
11
+ }
12
+ function workerPermission(access) {
13
+ permission(access, supportPermissions.assistantRead);
14
+ permission(access, supportPermissions.assistantReply);
15
+ permission(access, supportPermissions.assistantEscalate);
16
+ }
17
+ function latestCustomerTrigger(ticket, rows, job) {
18
+ if (ticket.requester.association === "authenticated")
19
+ text(ticket.requester.principalId, "requester principalId");
20
+ const handling = effectiveTicketHandling(ticket);
21
+ const visible = rows.filter((row) => row.kind === "message" && row.message.visibility === "customer");
22
+ let latest;
23
+ const ordinals = new Set();
24
+ for (const row of visible) {
25
+ if (row.kind !== "message")
26
+ continue;
27
+ integer(row.ordinal, "message ordinal", 1);
28
+ if (ordinals.has(row.ordinal))
29
+ throw new SupportDeskConflictError("Conversation ordinals are ambiguous");
30
+ ordinals.add(row.ordinal);
31
+ if (latest === undefined ||
32
+ (latest.kind === "message" && row.ordinal > latest.ordinal))
33
+ latest = row;
34
+ }
35
+ return (ticket.id === job.ticketId &&
36
+ ticket.requester.association === "authenticated" &&
37
+ ticket.status !== "resolved" &&
38
+ ticket.status !== "closed" &&
39
+ handling.state === "assistant" &&
40
+ handling.generation === job.triggerHandlingGeneration &&
41
+ latest?.kind === "message" &&
42
+ latest.message.id === job.triggerMessageId &&
43
+ latest.ordinal === job.triggerRevision &&
44
+ latest.partition === ticket.id &&
45
+ latest.id === `message:${job.triggerMessageId}` &&
46
+ latest.message.ticketId === ticket.id &&
47
+ job.triggerRevision <= ticket.revision &&
48
+ latest.message.authorKind === "customer" &&
49
+ latest.message.channel === "web" &&
50
+ latest.message.authorPrincipalId === ticket.requester.principalId);
51
+ }
52
+ function sameClaim(job, command) {
53
+ return (job.status === "claimed" &&
54
+ job.claim !== undefined &&
55
+ canonical(job.claim) ===
56
+ canonical(Object.fromEntries(Object.keys(job.claim).map((key) => [
57
+ key,
58
+ command[key],
59
+ ]))));
60
+ }
61
+ function ack(ticket, messageId) {
62
+ return {
63
+ ticketId: ticket.id,
64
+ revision: ticket.revision,
65
+ handlingGeneration: effectiveTicketHandling(ticket).generation,
66
+ ...(messageId === undefined ? {} : { messageId }),
67
+ };
68
+ }
69
+ function validateReason(value) {
70
+ if (value !== undefined &&
71
+ (typeof value !== "string" ||
72
+ ![
73
+ "no_guidance",
74
+ "unsuccessful",
75
+ "capacity",
76
+ "provider_failure",
77
+ "other",
78
+ ].includes(value)))
79
+ throw new TypeError("Invalid assistant escalation reason");
80
+ }
81
+ function validateSummary(value) {
82
+ if (value !== undefined &&
83
+ (typeof value !== "string" || value.length > 2_000))
84
+ throw new TypeError("Assistant handoff summary must be bounded text");
85
+ }
86
+ export function createAssistantTurnServices(dependencies) {
87
+ const { records, clock, options, limits, maxConflictAttempts } = dependencies;
88
+ function enabled() {
89
+ if (options === undefined)
90
+ throw new SupportDeskConflictError("Durable assistant turns are not configured");
91
+ return options;
92
+ }
93
+ function now() {
94
+ return timestamp(clock.now(), "clock.now()");
95
+ }
96
+ function activeClaim(job, command, exactTicket) {
97
+ if (!sameClaim(job, command) ||
98
+ job.leaseUntil === undefined ||
99
+ timestamp(job.leaseUntil, "leaseUntil") <= now())
100
+ throw new SupportDeskConflictError("Assistant turn claim was lost or expired");
101
+ if (exactTicket !== undefined &&
102
+ (exactTicket.revision !== command.expectedRevision ||
103
+ effectiveTicketHandling(exactTicket).generation !==
104
+ command.expectedHandlingGeneration))
105
+ throw new SupportDeskConflictError("Assistant turn ticket fence changed");
106
+ }
107
+ async function load(key) {
108
+ const result = await records.getVersioned(assistantTurnKey(key.ticketId, key.turnId));
109
+ if (result?.value.kind !== "assistant_turn")
110
+ throw new SupportDeskConflictError("Assistant turn is unavailable");
111
+ assertAssistantTurnRecord(result.value, assistantTurnKey(key.ticketId, key.turnId));
112
+ return { ...result, value: result.value };
113
+ }
114
+ function boundedRows(rows) {
115
+ if (rows.length > limits.maxRecordsPerTicket)
116
+ throw new SupportDeskLimitError("ticket_partition", limits.maxRecordsPerTicket);
117
+ }
118
+ function claimView(job, ticket, rows) {
119
+ if (job.claim === undefined ||
120
+ job.leaseUntil === undefined ||
121
+ ticket.requester.association !== "authenticated")
122
+ throw new SupportDeskConflictError("Assistant claim is incomplete");
123
+ text(ticket.requester.principalId, "requester principalId");
124
+ return snapshot({
125
+ fence: job.claim,
126
+ triggerRevision: job.triggerRevision,
127
+ leaseUntil: job.leaseUntil,
128
+ turnDeadline: job.turnDeadline,
129
+ requesterPrincipalId: ticket.requester.principalId,
130
+ conversation: dependencies.conversation(ticket, rows),
131
+ preparedAttempts: job.preparedAttempts,
132
+ });
133
+ }
134
+ async function escalateExhausted(access, job, ticket) {
135
+ const command = {
136
+ ticketId: job.ticketId,
137
+ commandId: `assistant-exhausted:${job.turnId}:${job.claimGeneration}`,
138
+ correlationId: job.correlationId,
139
+ triggerMessageId: job.triggerMessageId,
140
+ expectedRevision: ticket.revision,
141
+ expectedHandlingGeneration: effectiveTicketHandling(ticket).generation,
142
+ reason: "capacity",
143
+ };
144
+ const commit = {
145
+ kind: "exhausted",
146
+ ticketId: job.ticketId,
147
+ turnId: job.turnId,
148
+ claimGeneration: job.claimGeneration,
149
+ fingerprint: await fingerprint({
150
+ kind: "exhausted",
151
+ ticketId: job.ticketId,
152
+ turnId: job.turnId,
153
+ claimGeneration: job.claimGeneration,
154
+ }),
155
+ };
156
+ return ack(await dependencies.mutateHandling(access, command, "assistant_escalate", commit));
157
+ }
158
+ return {
159
+ async scanAssistantTurnCandidates(access, input) {
160
+ permission(access, supportPermissions.assistantRead);
161
+ enabled();
162
+ const raw = fields(input, "assistant turn scan", ["limit"], ["cursor"]);
163
+ const limit = integer(raw.limit, "scan.limit", 1, MAX_SCAN_PAGE_SIZE);
164
+ if (raw.cursor !== undefined)
165
+ text(raw.cursor, "scan.cursor", 16_384);
166
+ const page = await records.scan({
167
+ limit,
168
+ ...(raw.cursor === undefined ? {} : { cursor: raw.cursor }),
169
+ });
170
+ if (page.records.length > limit)
171
+ throw new SupportDeskConflictError("Storage exceeded the physical assistant scan bound");
172
+ const candidates = [];
173
+ const seen = new Set();
174
+ const sampled = now();
175
+ for (const entry of page.records) {
176
+ if (entry.key.id.startsWith("assistant-turn:") &&
177
+ entry.value.kind !== "assistant_turn")
178
+ throw new TypeError("Assistant turn physical row has another record kind");
179
+ if (entry.value.kind !== "assistant_turn")
180
+ continue;
181
+ assertAssistantTurnRecord(entry.value, entry.key);
182
+ const row = entry.value;
183
+ if (terminalTurn(row) ||
184
+ (row.status === "claimed" &&
185
+ timestamp(row.leaseUntil, "leaseUntil") > sampled &&
186
+ timestamp(row.turnDeadline, "turnDeadline") > sampled) ||
187
+ (row.status === "retry" &&
188
+ timestamp(row.nextAttemptAt, "nextAttemptAt") > sampled &&
189
+ timestamp(row.turnDeadline, "turnDeadline") > sampled))
190
+ continue;
191
+ const identity = canonical(entry.key);
192
+ if (!seen.has(identity))
193
+ candidates.push({
194
+ ticketId: entry.key.partition,
195
+ turnId: row.turnId,
196
+ });
197
+ seen.add(identity);
198
+ }
199
+ return {
200
+ candidates,
201
+ nextCursor: page.nextCursor,
202
+ physicalRows: page.records.length,
203
+ pages: 1,
204
+ };
205
+ },
206
+ async claimAssistantTurn(access, input) {
207
+ workerPermission(access);
208
+ const config = enabled();
209
+ const raw = fields(input, "claim assistant turn", [
210
+ "ticketId",
211
+ "turnId",
212
+ "workerId",
213
+ "claimId",
214
+ "leaseMilliseconds",
215
+ ]);
216
+ for (const key of ["ticketId", "turnId", "workerId", "claimId"])
217
+ text(raw[key], key);
218
+ const command = snapshot(input);
219
+ integer(command.leaseMilliseconds, "leaseMilliseconds", 1, config.maxLeaseMilliseconds);
220
+ for (let conflict = 0; conflict < maxConflictAttempts; conflict += 1) {
221
+ const versioned = await load(command);
222
+ const job = versioned.value;
223
+ if (terminalTurn(job))
224
+ return null;
225
+ const ticketVersion = await records.getVersioned({
226
+ partition: command.ticketId,
227
+ id: "ticket",
228
+ });
229
+ if (ticketVersion?.value.kind !== "ticket")
230
+ return null;
231
+ const ticket = ticketVersion.value.ticket;
232
+ const rows = await records.list(command.ticketId);
233
+ boundedRows(rows);
234
+ const sampled = now();
235
+ const eligible = latestCustomerTrigger(ticket, rows, job);
236
+ if (!eligible) {
237
+ const superseded = {
238
+ ...withoutLease(job),
239
+ status: "superseded",
240
+ };
241
+ assertAssistantTurnRecord(superseded);
242
+ if ((await records.transact(command.ticketId, [
243
+ {
244
+ action: "putIfUnchanged",
245
+ version: ticketVersion.version,
246
+ value: ticketVersion.value,
247
+ },
248
+ {
249
+ action: "putIfUnchanged",
250
+ version: versioned.version,
251
+ value: superseded,
252
+ },
253
+ ])).committed)
254
+ return null;
255
+ continue;
256
+ }
257
+ if (sampled >= timestamp(job.turnDeadline, "turnDeadline") ||
258
+ !dependencies.assistantHandlingEnabled ||
259
+ (job.attempt >= Math.min(job.maxAttempts, config.maxAttempts) &&
260
+ !(job.status === "claimed" &&
261
+ timestamp(job.leaseUntil, "leaseUntil") > sampled))) {
262
+ await escalateExhausted(access, job, ticket);
263
+ return null;
264
+ }
265
+ if (job.status === "claimed" &&
266
+ timestamp(job.leaseUntil, "leaseUntil") > sampled) {
267
+ if (job.claim?.workerId === command.workerId &&
268
+ job.claim.claimId === command.claimId)
269
+ return claimView(job, ticket, rows);
270
+ return null;
271
+ }
272
+ if (job.status === "retry" &&
273
+ timestamp(job.nextAttemptAt, "nextAttemptAt") > sampled)
274
+ return null;
275
+ const attempt = job.attempt + 1;
276
+ const claimGeneration = integer(job.claimGeneration + 1, "claimGeneration", 1);
277
+ const attemptId = `${job.turnId}:${attempt}`;
278
+ const fence = {
279
+ ticketId: job.ticketId,
280
+ turnId: job.turnId,
281
+ workerId: command.workerId,
282
+ claimId: command.claimId,
283
+ claimGeneration,
284
+ attempt,
285
+ attemptId,
286
+ commandId: `assistant-complete:${attemptId}`,
287
+ correlationId: job.correlationId,
288
+ expectedRevision: ticket.revision,
289
+ expectedHandlingGeneration: effectiveTicketHandling(ticket).generation,
290
+ triggerMessageId: job.triggerMessageId,
291
+ };
292
+ const claimed = {
293
+ ...withoutLease(job),
294
+ status: "claimed",
295
+ attempt,
296
+ claimGeneration,
297
+ claim: fence,
298
+ leaseUntil: new Date(Math.min(sampled + command.leaseMilliseconds, timestamp(job.turnDeadline, "turnDeadline"))).toISOString(),
299
+ };
300
+ assertAssistantTurnRecord(claimed);
301
+ if ((await records.transact(command.ticketId, [
302
+ {
303
+ action: "putIfUnchanged",
304
+ version: ticketVersion.version,
305
+ value: ticketVersion.value,
306
+ },
307
+ {
308
+ action: "putIfUnchanged",
309
+ version: versioned.version,
310
+ value: claimed,
311
+ },
312
+ ])).committed)
313
+ return claimView(claimed, ticket, rows);
314
+ }
315
+ throw new SupportDeskConflictError();
316
+ },
317
+ async prepareAssistantTurnAttempt(access, input) {
318
+ workerPermission(access);
319
+ enabled();
320
+ const raw = validateFence(input, ["binding"]);
321
+ const binding = validateBinding(raw.binding);
322
+ const command = snapshot({ ...raw, binding });
323
+ for (let conflict = 0; conflict < maxConflictAttempts; conflict += 1) {
324
+ const versioned = await load(command);
325
+ const job = versioned.value;
326
+ const ticketVersion = await records.getVersioned({
327
+ partition: command.ticketId,
328
+ id: "ticket",
329
+ });
330
+ if (ticketVersion?.value.kind !== "ticket")
331
+ throw new SupportDeskConflictError("Assistant ticket is unavailable");
332
+ const rows = await records.list(command.ticketId);
333
+ boundedRows(rows);
334
+ activeClaim(job, command, ticketVersion.value.ticket);
335
+ if (!dependencies.assistantHandlingEnabled ||
336
+ !latestCustomerTrigger(ticketVersion.value.ticket, rows, job))
337
+ throw new SupportDeskConflictError("Assistant turn was superseded");
338
+ const sampled = now();
339
+ if (timestamp(binding.deadline, "binding.deadline") <= sampled ||
340
+ timestamp(binding.deadline, "binding.deadline") >
341
+ Math.min(timestamp(job.leaseUntil, "leaseUntil"), timestamp(job.turnDeadline, "turnDeadline")))
342
+ throw new SupportDeskConflictError("Prepared request deadline is outside its current claim");
343
+ const previous = job.preparedAttempts.find((attempt) => attempt.attemptId === command.attemptId);
344
+ if (previous !== undefined) {
345
+ if (canonical(previous.binding) !== canonical(binding))
346
+ throw new SupportDeskConflictError("Assistant attempt input is immutable");
347
+ return snapshot({
348
+ attemptId: previous.attemptId,
349
+ inputFingerprint: previous.inputFingerprint,
350
+ binding: previous.binding,
351
+ });
352
+ }
353
+ const prepared = {
354
+ ...job,
355
+ preparedAttempts: [
356
+ ...job.preparedAttempts,
357
+ {
358
+ attempt: command.attempt,
359
+ attemptId: command.attemptId,
360
+ inputFingerprint: binding.inputFingerprint,
361
+ binding,
362
+ accounting: "unknown",
363
+ },
364
+ ],
365
+ };
366
+ assertAssistantTurnRecord(prepared);
367
+ if (encodedBytes(prepared) > preparedRecordBytes)
368
+ throw new SupportDeskLimitError("serialized_record", preparedRecordBytes);
369
+ if (maximumPostPreparationRecordBytes(prepared) > maximumRecordBytes)
370
+ throw new SupportDeskLimitError("serialized_record", maximumRecordBytes);
371
+ activeClaim(job, command, ticketVersion.value.ticket);
372
+ if (timestamp(binding.deadline, "binding.deadline") <= now())
373
+ throw new SupportDeskConflictError("Assistant request deadline expired before preparation");
374
+ if ((await records.transact(command.ticketId, [
375
+ {
376
+ action: "putIfUnchanged",
377
+ version: ticketVersion.version,
378
+ value: ticketVersion.value,
379
+ },
380
+ {
381
+ action: "putIfUnchanged",
382
+ version: versioned.version,
383
+ value: prepared,
384
+ },
385
+ ])).committed)
386
+ return snapshot({
387
+ attemptId: command.attemptId,
388
+ inputFingerprint: binding.inputFingerprint,
389
+ binding,
390
+ });
391
+ }
392
+ throw new SupportDeskConflictError();
393
+ },
394
+ async completeAssistantTurn(access, input, guard) {
395
+ const raw = validateFence(input, ["inputFingerprint", "outcome"], ["processing"]);
396
+ text(raw.inputFingerprint, "inputFingerprint");
397
+ if (typeof guard !== "function")
398
+ throw new TypeError("Assistant completion requires a read-only commit guard");
399
+ const outcome = fields(raw.outcome, "assistant outcome", ["kind"], ["messageId", "body", "reason", "summary", "attemptedGuideIds"]);
400
+ if (outcome.kind !== "answer" && outcome.kind !== "escalate")
401
+ throw new TypeError("Invalid assistant outcome");
402
+ permission(access, outcome.kind === "answer"
403
+ ? supportPermissions.assistantReply
404
+ : supportPermissions.assistantEscalate);
405
+ if (outcome.kind === "answer") {
406
+ fields(outcome, "assistant answer", ["kind", "messageId", "body"]);
407
+ text(outcome.messageId, "messageId");
408
+ if (typeof outcome.body !== "string" ||
409
+ outcome.body.trim().length === 0 ||
410
+ outcome.body.length > 20_000)
411
+ throw new SupportDeskLimitError("body", 20_000);
412
+ }
413
+ else {
414
+ fields(outcome, "assistant escalation", ["kind"], ["reason", "summary", "attemptedGuideIds"]);
415
+ validateReason(outcome.reason);
416
+ validateSummary(outcome.summary);
417
+ if (outcome.attemptedGuideIds !== undefined)
418
+ for (const guide of dataArray(outcome.attemptedGuideIds, "attemptedGuideIds", 16))
419
+ text(guide, "guideId");
420
+ }
421
+ const processing = validateProcessing(raw.processing);
422
+ const command = snapshot({
423
+ ...raw,
424
+ ...(processing === undefined ? {} : { processing }),
425
+ });
426
+ const commit = {
427
+ kind: "complete",
428
+ command,
429
+ fingerprint: await fingerprint({
430
+ actorId: access.principalId,
431
+ command,
432
+ }),
433
+ guard,
434
+ };
435
+ const handling = {
436
+ commandId: command.commandId,
437
+ correlationId: command.correlationId,
438
+ ticketId: command.ticketId,
439
+ expectedRevision: command.expectedRevision,
440
+ expectedHandlingGeneration: command.expectedHandlingGeneration,
441
+ triggerMessageId: command.triggerMessageId,
442
+ ...command.outcome,
443
+ };
444
+ // The handling executor checks its exact historical command receipt before
445
+ // validating a live claim, so an accepted but unacknowledged commit recovers.
446
+ const result = await dependencies.mutateHandling(access, handling, outcome.kind === "answer" ? "assistant_reply" : "assistant_escalate", commit);
447
+ return ack(result, command.outcome.kind === "answer"
448
+ ? command.outcome.messageId
449
+ : undefined);
450
+ },
451
+ async finishAssistantTurnAttempt(access, input) {
452
+ workerPermission(access);
453
+ enabled();
454
+ const raw = validateFence(input, ["disposition", "accounting"], ["retryAt", "reason", "summary", "processing"]);
455
+ if (typeof raw.disposition !== "string" ||
456
+ !["retry", "superseded", "escalate"].includes(raw.disposition) ||
457
+ typeof raw.accounting !== "string" ||
458
+ !["not_called", "accounted", "unknown"].includes(raw.accounting))
459
+ throw new TypeError("Invalid assistant attempt disposition");
460
+ if (raw.retryAt !== undefined)
461
+ timestamp(raw.retryAt, "retryAt");
462
+ validateReason(raw.reason);
463
+ validateSummary(raw.summary);
464
+ const processing = validateProcessing(raw.processing);
465
+ const command = snapshot({
466
+ ...raw,
467
+ ...(processing === undefined ? {} : { processing }),
468
+ });
469
+ const requestFingerprint = await fingerprint({
470
+ actorId: access.principalId,
471
+ command,
472
+ });
473
+ const key = { ticketId: command.ticketId, turnId: command.turnId };
474
+ for (let conflict = 0; conflict < maxConflictAttempts; conflict += 1) {
475
+ const versioned = await load(command);
476
+ const job = versioned.value;
477
+ if (job.lastFinish?.fingerprint === requestFingerprint)
478
+ return snapshot(job.lastFinish.result);
479
+ if (terminalTurn(job))
480
+ return {
481
+ ...key,
482
+ outcome: "already_terminal",
483
+ ...(job.terminalResult === undefined
484
+ ? {}
485
+ : { result: job.terminalResult }),
486
+ };
487
+ const ticketVersion = await records.getVersioned({
488
+ partition: command.ticketId,
489
+ id: "ticket",
490
+ });
491
+ if (ticketVersion?.value.kind !== "ticket")
492
+ throw new SupportDeskConflictError("Assistant ticket is unavailable");
493
+ const ticket = ticketVersion.value.ticket;
494
+ const rows = await records.list(command.ticketId);
495
+ boundedRows(rows);
496
+ const eligible = latestCustomerTrigger(ticket, rows, job);
497
+ const sampled = now();
498
+ if (!sameClaim(job, command))
499
+ throw new SupportDeskConflictError("Assistant turn claim was lost");
500
+ if (eligible &&
501
+ sampled >= timestamp(job.turnDeadline, "turnDeadline")) {
502
+ const result = await escalateExhausted(access, job, ticket);
503
+ return { ...key, outcome: "escalated", result };
504
+ }
505
+ activeClaim(job, command);
506
+ if (command.disposition === "superseded" && eligible)
507
+ throw new SupportDeskConflictError("Current assistant turn cannot be abandoned");
508
+ const nextAttemptAt = new Date(Math.max(sampled, command.retryAt === undefined
509
+ ? sampled
510
+ : timestamp(command.retryAt, "retryAt"))).toISOString();
511
+ const exhausted = job.attempt >= Math.min(job.maxAttempts, enabled().maxAttempts) ||
512
+ timestamp(nextAttemptAt, "nextAttemptAt") >=
513
+ timestamp(job.turnDeadline, "turnDeadline") ||
514
+ !dependencies.assistantHandlingEnabled;
515
+ if (eligible && (command.disposition === "escalate" || exhausted)) {
516
+ const handling = {
517
+ commandId: command.commandId,
518
+ correlationId: command.correlationId,
519
+ ticketId: command.ticketId,
520
+ expectedRevision: ticket.revision,
521
+ expectedHandlingGeneration: effectiveTicketHandling(ticket).generation,
522
+ triggerMessageId: job.triggerMessageId,
523
+ reason: command.reason ?? (exhausted ? "capacity" : "other"),
524
+ ...(command.summary === undefined
525
+ ? {}
526
+ : { summary: command.summary }),
527
+ };
528
+ const result = ack(await dependencies.mutateHandling(access, handling, "assistant_escalate", { kind: "finish", command, fingerprint: requestFingerprint }));
529
+ return { ...key, outcome: "escalated", result };
530
+ }
531
+ const result = eligible
532
+ ? { ...key, outcome: "retry_scheduled", nextAttemptAt }
533
+ : { ...key, outcome: "superseded" };
534
+ const updated = {
535
+ ...assistantTurnProcessingState(withoutLease(job), processing, {
536
+ attemptId: command.attemptId,
537
+ accounting: command.accounting,
538
+ }),
539
+ status: eligible ? "retry" : "superseded",
540
+ ...(eligible ? { nextAttemptAt } : {}),
541
+ lastFinish: { fingerprint: requestFingerprint, result },
542
+ };
543
+ assertAssistantTurnRecord(updated);
544
+ activeClaim(job, command);
545
+ if ((await records.transact(command.ticketId, [
546
+ {
547
+ action: "putIfUnchanged",
548
+ version: ticketVersion.version,
549
+ value: ticketVersion.value,
550
+ },
551
+ {
552
+ action: "putIfUnchanged",
553
+ version: versioned.version,
554
+ value: updated,
555
+ },
556
+ ])).committed)
557
+ return snapshot(result);
558
+ }
559
+ throw new SupportDeskConflictError();
560
+ },
561
+ async completionAction(ticket, rows, updated, commit) {
562
+ enabled();
563
+ if (commit.kind === "complete" &&
564
+ commit.command.outcome.kind === "answer" &&
565
+ commit.command.outcome.body.length > limits.maxMessageCharacters)
566
+ throw new SupportDeskLimitError("body", limits.maxMessageCharacters);
567
+ const key = commit.kind === "exhausted" ? commit : commit.command;
568
+ const versioned = await load(key);
569
+ const job = versioned.value;
570
+ if (terminalTurn(job) || !latestCustomerTrigger(ticket, rows, job))
571
+ throw new SupportDeskConflictError("Assistant turn was superseded");
572
+ if (commit.kind === "exhausted") {
573
+ if (job.claimGeneration !== commit.claimGeneration ||
574
+ (dependencies.assistantHandlingEnabled &&
575
+ now() < timestamp(job.turnDeadline, "turnDeadline") &&
576
+ job.attempt < Math.min(job.maxAttempts, enabled().maxAttempts)))
577
+ throw new SupportDeskConflictError("Assistant turn is no longer exhausted");
578
+ }
579
+ else {
580
+ activeClaim(job, commit.command, commit.kind === "complete" ? ticket : undefined);
581
+ }
582
+ const prepared = commit.kind === "complete"
583
+ ? job.preparedAttempts.find((attempt) => attempt.attemptId === commit.command.attemptId)
584
+ : undefined;
585
+ if (commit.kind === "complete" &&
586
+ (prepared === undefined ||
587
+ prepared.inputFingerprint !== commit.command.inputFingerprint))
588
+ throw new SupportDeskConflictError("Assistant completion does not match its prepared request");
589
+ const result = ack(updated, commit.kind === "complete" && commit.command.outcome.kind === "answer"
590
+ ? commit.command.outcome.messageId
591
+ : undefined);
592
+ const processing = commit.kind === "exhausted" ? undefined : commit.command.processing;
593
+ const terminal = {
594
+ ...assistantTurnProcessingState(withoutLease(job), processing, commit.kind === "finish"
595
+ ? {
596
+ attemptId: commit.command.attemptId,
597
+ accounting: commit.command.accounting,
598
+ }
599
+ : undefined),
600
+ status: commit.kind === "complete" && commit.command.outcome.kind === "answer"
601
+ ? "completed"
602
+ : "escalated",
603
+ terminalResult: result,
604
+ ...(commit.kind === "finish"
605
+ ? {
606
+ lastFinish: {
607
+ fingerprint: commit.fingerprint,
608
+ result: {
609
+ ticketId: job.ticketId,
610
+ turnId: job.turnId,
611
+ outcome: "escalated",
612
+ result,
613
+ },
614
+ },
615
+ }
616
+ : {}),
617
+ };
618
+ assertAssistantTurnRecord(terminal);
619
+ return {
620
+ action: {
621
+ action: "putIfUnchanged",
622
+ version: versioned.version,
623
+ value: terminal,
624
+ },
625
+ async verifyBeforeCommit() {
626
+ if (commit.kind === "complete")
627
+ await commit.guard();
628
+ // Sample again after every awaited authority/source check. A guard
629
+ // cannot prolong a lease or a prepared request's absolute deadline.
630
+ if (commit.kind !== "exhausted")
631
+ activeClaim(job, commit.command, commit.kind === "complete" ? ticket : undefined);
632
+ if (prepared !== undefined &&
633
+ now() >= timestamp(prepared.binding.deadline, "prepared deadline"))
634
+ throw new SupportDeskConflictError("Assistant prepared request expired");
635
+ },
636
+ };
637
+ },
638
+ };
639
+ }
640
+ //# sourceMappingURL=assistant-turns.js.map