-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdurable-interaction-submit.ts
More file actions
151 lines (141 loc) · 6.01 KB
/
Copy pathdurable-interaction-submit.ts
File metadata and controls
151 lines (141 loc) · 6.01 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
import {
INTERACTION_SUBMIT_TIMEOUT_MESSAGE,
INTERACTION_SUBMIT_TIMEOUT_MS,
responseErrorMessage,
type InteractionAnswerSubmission,
type InteractionAnswerSubmitterOptions,
type SubmitInteractionAnswer,
} from './interaction-card-support'
/** Manage storage and retrieval of interaction attempt keys by interaction and submission identifiers */
export interface InteractionAttemptStore {
get(interactionId: string, submissionSignature: string): string | null
set(interactionId: string, submissionSignature: string, attemptKey: string): void
delete(interactionId: string, submissionSignature: string): void
}
function attemptStorageKey(namespace: string, interactionId: string): string {
return `${namespace}:${encodeURIComponent(interactionId)}`
}
function storedAttempts(storage: Pick<Storage, 'getItem'>, key: string): Record<string, string> {
try {
const value = JSON.parse(storage.getItem(key) ?? '{}') as unknown
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, string>
: {}
} catch {
return {}
}
}
/** Create a session-based store to manage interaction attempts using provided storage and optional namespace */
export function createSessionInteractionAttemptStore(
storage: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>,
namespace = 'agent-app:interaction-attempt',
): InteractionAttemptStore {
return {
get(id, signature) {
return storedAttempts(storage, attemptStorageKey(namespace, id))[signature] ?? null
},
set(id, signature, attemptKey) {
const key = attemptStorageKey(namespace, id)
storage.setItem(key, JSON.stringify({ ...storedAttempts(storage, key), [signature]: attemptKey }))
},
delete(id, signature) {
const key = attemptStorageKey(namespace, id)
const attempts = storedAttempts(storage, key)
delete attempts[signature]
if (Object.keys(attempts).length === 0) storage.removeItem(key)
else storage.setItem(key, JSON.stringify(attempts))
},
}
}
/** Create an in-memory store to manage interaction attempts keyed by ID and signature */
export function createMemoryInteractionAttemptStore(): InteractionAttemptStore {
const attempts = new Map<string, string>()
const key = (id: string, signature: string) => `${id}\u0000${signature}`
return {
get: (id, signature) => attempts.get(key(id, signature)) ?? null,
set: (id, signature, attemptKey) => attempts.set(key(id, signature), attemptKey),
delete: (id, signature) => { attempts.delete(key(id, signature)) },
}
}
function stableValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stableValue)
if (!value || typeof value !== 'object') return value
return Object.fromEntries(Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, nested]) => [key, stableValue(nested)]))
}
/** Generate a stable string signature from an interaction answer submission */
export function interactionSubmissionSignature(submission: InteractionAnswerSubmission): string {
return JSON.stringify(stableValue(submission))
}
/** Define options for submitting durable interaction answers with attempt tracking and optional key creation */
export interface DurableInteractionAnswerSubmitterOptions extends InteractionAnswerSubmitterOptions {
attempts: InteractionAttemptStore
createAttemptKey?: () => string
}
function defaultAttemptKey(): string {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
return `attempt-${Date.now()}-${Math.random().toString(36).slice(2)}`
}
/** Answer submitter for a durable interaction route. One opaque attempt key is
* retained for an ambiguous transport/5xx result and reused after reload. A
* changed answer has a different signature and therefore a new attempt. */
export function createDurableInteractionAnswerSubmitter(
options: DurableInteractionAnswerSubmitterOptions,
): SubmitInteractionAnswer {
const timeoutMs = options.timeoutMs ?? INTERACTION_SUBMIT_TIMEOUT_MS
const fetchImpl = options.fetchImpl ?? fetch
return async (submission) => {
const signature = interactionSubmissionSignature(submission)
let attemptKey: string
try {
attemptKey = options.attempts.get(submission.id, signature) ?? ''
if (!attemptKey) {
attemptKey = (options.createAttemptKey ?? defaultAttemptKey)()
options.attempts.set(submission.id, signature, attemptKey)
}
} catch (cause) {
return {
ok: false,
expired: false,
message: cause instanceof Error ? cause.message : 'Failed to submit the answer',
}
}
const url = typeof options.url === 'function' ? options.url(submission) : options.url
const extra = typeof options.body === 'function' ? options.body(submission) : options.body ?? {}
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(INTERACTION_SUBMIT_TIMEOUT_MESSAGE), timeoutMs)
try {
const response = await fetchImpl(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
body: JSON.stringify({
...extra,
id: submission.id,
outcome: submission.outcome,
attemptKey,
...(submission.data ? { data: submission.data } : {}),
}),
})
if (response.ok) {
options.attempts.delete(submission.id, signature)
return { ok: true }
}
const failure = await responseErrorMessage(response)
if (response.status < 500) options.attempts.delete(submission.id, signature)
return { ok: false, expired: response.status === 410, message: failure.message }
} catch (cause) {
if (controller.signal.aborted) {
return { ok: false, expired: false, message: INTERACTION_SUBMIT_TIMEOUT_MESSAGE }
}
return {
ok: false,
expired: false,
message: cause instanceof Error ? cause.message : 'Failed to submit the answer',
}
} finally {
clearTimeout(timer)
}
}
}