digital-workers 2.1.3 → 2.4.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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +17 -0
- package/README.md +2 -0
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +33 -21
- package/dist/actions.js.map +1 -1
- package/dist/agent-comms.d.ts.map +1 -1
- package/dist/agent-comms.js +36 -25
- package/dist/agent-comms.js.map +1 -1
- package/dist/approve.d.ts +40 -8
- package/dist/approve.d.ts.map +1 -1
- package/dist/approve.js +86 -20
- package/dist/approve.js.map +1 -1
- package/dist/ask.d.ts +38 -7
- package/dist/ask.d.ts.map +1 -1
- package/dist/ask.js +85 -25
- package/dist/ask.js.map +1 -1
- package/dist/browse.d.ts +223 -0
- package/dist/browse.d.ts.map +1 -0
- package/dist/browse.js +392 -0
- package/dist/browse.js.map +1 -0
- package/dist/capability-tiers.js +3 -3
- package/dist/capability-tiers.js.map +1 -1
- package/dist/cascade-context.d.ts +28 -28
- package/dist/client.d.ts +162 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +64 -0
- package/dist/client.js.map +1 -0
- package/dist/decide.d.ts +42 -6
- package/dist/decide.d.ts.map +1 -1
- package/dist/decide.js +54 -11
- package/dist/decide.js.map +1 -1
- package/dist/do.d.ts +36 -7
- package/dist/do.d.ts.map +1 -1
- package/dist/do.js +82 -39
- package/dist/do.js.map +1 -1
- package/dist/error-escalation.d.ts.map +1 -1
- package/dist/error-escalation.js +38 -38
- package/dist/error-escalation.js.map +1 -1
- package/dist/generate.d.ts +48 -7
- package/dist/generate.d.ts.map +1 -1
- package/dist/generate.js +49 -8
- package/dist/generate.js.map +1 -1
- package/dist/goals.d.ts +10 -9
- package/dist/goals.d.ts.map +1 -1
- package/dist/goals.js +30 -24
- package/dist/goals.js.map +1 -1
- package/dist/image.d.ts +189 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +528 -0
- package/dist/image.js.map +1 -0
- package/dist/index.d.ts +49 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +58 -2
- package/dist/index.js.map +1 -1
- package/dist/is.d.ts +45 -10
- package/dist/is.d.ts.map +1 -1
- package/dist/is.js +56 -21
- package/dist/is.js.map +1 -1
- package/dist/kpis.d.ts +24 -15
- package/dist/kpis.d.ts.map +1 -1
- package/dist/kpis.js +16 -14
- package/dist/kpis.js.map +1 -1
- package/dist/load-balancing.d.ts.map +1 -1
- package/dist/load-balancing.js +124 -38
- package/dist/load-balancing.js.map +1 -1
- package/dist/logger.d.ts +76 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +39 -0
- package/dist/logger.js.map +1 -0
- package/dist/notify.d.ts +38 -9
- package/dist/notify.d.ts.map +1 -1
- package/dist/notify.js +72 -17
- package/dist/notify.js.map +1 -1
- package/dist/role.d.ts +5 -4
- package/dist/role.d.ts.map +1 -1
- package/dist/role.js +13 -10
- package/dist/role.js.map +1 -1
- package/dist/runtime.d.ts +310 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +510 -0
- package/dist/runtime.js.map +1 -0
- package/dist/team.d.ts +11 -6
- package/dist/team.d.ts.map +1 -1
- package/dist/team.js +22 -15
- package/dist/team.js.map +1 -1
- package/dist/transports/email.d.ts +318 -0
- package/dist/transports/email.d.ts.map +1 -0
- package/dist/transports/email.js +779 -0
- package/dist/transports/email.js.map +1 -0
- package/dist/transports/slack.d.ts +515 -0
- package/dist/transports/slack.d.ts.map +1 -0
- package/dist/transports/slack.js +844 -0
- package/dist/transports/slack.js.map +1 -0
- package/dist/transports.d.ts.map +1 -1
- package/dist/transports.js +44 -25
- package/dist/transports.js.map +1 -1
- package/dist/types.d.ts +141 -19
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -1
- package/dist/utils/id.d.ts +19 -0
- package/dist/utils/id.d.ts.map +1 -0
- package/dist/utils/id.js +21 -0
- package/dist/utils/id.js.map +1 -0
- package/dist/video.d.ts +203 -0
- package/dist/video.d.ts.map +1 -0
- package/dist/video.js +528 -0
- package/dist/video.js.map +1 -0
- package/dist/worker.d.ts +343 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +698 -0
- package/dist/worker.js.map +1 -0
- package/package.json +32 -14
- package/src/actions.ts +39 -30
- package/src/agent-comms.ts +54 -92
- package/src/approve.ts +91 -20
- package/src/ask.ts +99 -25
- package/src/browse.ts +627 -0
- package/src/capability-tiers.ts +5 -5
- package/src/client.ts +221 -0
- package/src/decide.ts +81 -35
- package/src/do.ts +98 -52
- package/src/error-escalation.ts +55 -67
- package/src/generate.ts +52 -18
- package/src/goals.ts +36 -27
- package/src/image.ts +816 -0
- package/src/index.ts +187 -2
- package/src/is.ts +59 -25
- package/src/kpis.ts +41 -36
- package/src/load-balancing.ts +132 -46
- package/src/logger.ts +93 -0
- package/src/notify.ts +78 -17
- package/src/role.ts +30 -20
- package/src/runtime.ts +796 -0
- package/src/team.ts +24 -19
- package/src/transports/email.ts +1160 -0
- package/src/transports/slack.ts +1320 -0
- package/src/transports.ts +58 -43
- package/src/types.ts +174 -46
- package/src/utils/id.ts +21 -0
- package/src/video.ts +906 -0
- package/src/worker.ts +1007 -0
- package/test/approve.test.ts +305 -0
- package/test/ask.test.ts +274 -0
- package/test/browse.test.ts +361 -0
- package/test/decide.test.ts +252 -0
- package/test/do.test.ts +144 -0
- package/test/error-logging.test.ts +357 -0
- package/test/generate.test.ts +319 -0
- package/test/image.test.ts +398 -0
- package/test/is.test.ts +287 -0
- package/test/load-balancing-safety.test.ts +404 -0
- package/test/notify.test.ts +434 -0
- package/test/primitives.test.ts +320 -0
- package/test/runtime-integration.test.ts +892 -0
- package/test/transports/crypto.test.ts +230 -0
- package/test/transports/email.test.ts +866 -0
- package/test/transports/id-generation.test.ts +91 -0
- package/test/transports/slack.test.ts +760 -0
- package/test/type-safety.test.ts +834 -0
- package/test/types.test.ts +60 -2
- package/test/video.test.ts +530 -0
- package/test/worker.test.ts +1433 -0
- package/tsconfig.json +4 -1
- package/vitest.config.ts +42 -0
- package/wrangler.jsonc +36 -0
- package/LICENSE +0 -21
- package/src/actions.js +0 -436
- package/src/approve.js +0 -234
- package/src/ask.js +0 -226
- package/src/decide.js +0 -244
- package/src/do.js +0 -227
- package/src/generate.js +0 -298
- package/src/goals.js +0 -205
- package/src/index.js +0 -68
- package/src/is.js +0 -317
- package/src/kpis.js +0 -270
- package/src/notify.js +0 -219
- package/src/role.js +0 -110
- package/src/team.js +0 -130
- package/src/transports.js +0 -357
- package/src/types.js +0 -71
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime Integration for Human Request Processing
|
|
3
|
+
*
|
|
4
|
+
* Connects transport adapters (Slack, Email, etc.) to a request store and
|
|
5
|
+
* provides orchestration for approval workflows, questions, and notifications.
|
|
6
|
+
*
|
|
7
|
+
* The HumanRequestProcessor is the central coordinator that:
|
|
8
|
+
* - Sends requests via configured transports
|
|
9
|
+
* - Tracks request state in a store
|
|
10
|
+
* - Handles webhook callbacks from transports
|
|
11
|
+
* - Notifies when requests complete or timeout
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Transport, DeliveryResult } from './transports.js'
|
|
17
|
+
import type { WorkerRef } from './types.js'
|
|
18
|
+
import type { SlackTransport } from './transports/slack.js'
|
|
19
|
+
import type { EmailTransport } from './transports/email.js'
|
|
20
|
+
|
|
21
|
+
// =============================================================================
|
|
22
|
+
// Request Types
|
|
23
|
+
// =============================================================================
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Status of a human request
|
|
27
|
+
*/
|
|
28
|
+
export type RequestStatus = 'pending' | 'completed' | 'expired' | 'cancelled' | 'failed'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Type of human request
|
|
32
|
+
*/
|
|
33
|
+
export type RequestType = 'approval' | 'question' | 'notification'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Result of a completed request
|
|
37
|
+
*/
|
|
38
|
+
export interface RequestResult {
|
|
39
|
+
/** Whether the request was approved (for approval type) */
|
|
40
|
+
approved?: boolean
|
|
41
|
+
/** Answer to a question (for question type) */
|
|
42
|
+
answer?: unknown
|
|
43
|
+
/** Who responded to the request */
|
|
44
|
+
respondedBy?: WorkerRef
|
|
45
|
+
/** When the response was received */
|
|
46
|
+
respondedAt?: Date
|
|
47
|
+
/** Additional notes from the responder */
|
|
48
|
+
notes?: string
|
|
49
|
+
/** The channel used for the response */
|
|
50
|
+
via?: Transport
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A human request tracked by the runtime
|
|
55
|
+
*/
|
|
56
|
+
export interface HumanRequest {
|
|
57
|
+
/** Unique request identifier */
|
|
58
|
+
id: string
|
|
59
|
+
/** Type of request */
|
|
60
|
+
type: RequestType
|
|
61
|
+
/** Target for the request (channel, email, etc.) */
|
|
62
|
+
target: string
|
|
63
|
+
/** Request content/message */
|
|
64
|
+
content: string
|
|
65
|
+
/** Current status */
|
|
66
|
+
status: RequestStatus
|
|
67
|
+
/** Transport used to send the request */
|
|
68
|
+
transport: Transport
|
|
69
|
+
/** External message ID from the transport */
|
|
70
|
+
externalId?: string
|
|
71
|
+
/** Who initiated the request */
|
|
72
|
+
requestedBy?: WorkerRef
|
|
73
|
+
/** Additional context for the request */
|
|
74
|
+
context?: Record<string, unknown>
|
|
75
|
+
/** Channel to notify requestor of result */
|
|
76
|
+
notifyChannel?: string
|
|
77
|
+
/** When the request was created */
|
|
78
|
+
createdAt: Date
|
|
79
|
+
/** When the request was completed */
|
|
80
|
+
completedAt?: Date
|
|
81
|
+
/** When the request expires */
|
|
82
|
+
expiresAt?: Date
|
|
83
|
+
/** Result of the request (when completed) */
|
|
84
|
+
result?: RequestResult
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Data for creating a new request
|
|
89
|
+
*/
|
|
90
|
+
export interface CreateRequestData {
|
|
91
|
+
type: RequestType
|
|
92
|
+
target: string
|
|
93
|
+
content: string
|
|
94
|
+
transport: Transport
|
|
95
|
+
requestedBy?: WorkerRef
|
|
96
|
+
context?: Record<string, unknown>
|
|
97
|
+
notifyChannel?: string
|
|
98
|
+
expiresAt?: Date
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Data for updating a request
|
|
103
|
+
*/
|
|
104
|
+
export interface UpdateRequestData {
|
|
105
|
+
status?: RequestStatus
|
|
106
|
+
externalId?: string
|
|
107
|
+
result?: RequestResult
|
|
108
|
+
completedAt?: Date
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// =============================================================================
|
|
112
|
+
// Request Store Interface
|
|
113
|
+
// =============================================================================
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Store interface for persisting human requests
|
|
117
|
+
*/
|
|
118
|
+
export interface HumanRequestStore {
|
|
119
|
+
/** Create a new request */
|
|
120
|
+
create(data: CreateRequestData): Promise<HumanRequest>
|
|
121
|
+
/** Get a request by ID */
|
|
122
|
+
get(id: string): Promise<HumanRequest | undefined>
|
|
123
|
+
/** Update a request */
|
|
124
|
+
update(id: string, data: UpdateRequestData): Promise<HumanRequest | undefined>
|
|
125
|
+
/** Find a request by external ID (transport message ID) */
|
|
126
|
+
findByExternalId(externalId: string): Promise<HumanRequest | undefined>
|
|
127
|
+
/** List all pending requests */
|
|
128
|
+
listPending(): Promise<HumanRequest[]>
|
|
129
|
+
/** List expired requests that need processing */
|
|
130
|
+
listExpired(): Promise<HumanRequest[]>
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// =============================================================================
|
|
134
|
+
// In-Memory Request Store
|
|
135
|
+
// =============================================================================
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* In-memory implementation of HumanRequestStore
|
|
139
|
+
*
|
|
140
|
+
* Suitable for development and testing. For production, implement
|
|
141
|
+
* a store backed by Durable Objects, KV, or a database.
|
|
142
|
+
*/
|
|
143
|
+
export class InMemoryRequestStore implements HumanRequestStore {
|
|
144
|
+
private requests = new Map<string, HumanRequest>()
|
|
145
|
+
private externalIdIndex = new Map<string, string>()
|
|
146
|
+
private counter = 0
|
|
147
|
+
|
|
148
|
+
private generateId(): string {
|
|
149
|
+
this.counter++
|
|
150
|
+
return `req_${Date.now()}_${this.counter.toString().padStart(4, '0')}`
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async create(data: CreateRequestData): Promise<HumanRequest> {
|
|
154
|
+
const id = this.generateId()
|
|
155
|
+
const request: HumanRequest = {
|
|
156
|
+
id,
|
|
157
|
+
type: data.type,
|
|
158
|
+
target: data.target,
|
|
159
|
+
content: data.content,
|
|
160
|
+
status: 'pending',
|
|
161
|
+
transport: data.transport,
|
|
162
|
+
createdAt: new Date(),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (data.requestedBy !== undefined) {
|
|
166
|
+
request.requestedBy = data.requestedBy
|
|
167
|
+
}
|
|
168
|
+
if (data.context !== undefined) {
|
|
169
|
+
request.context = data.context
|
|
170
|
+
}
|
|
171
|
+
if (data.notifyChannel !== undefined) {
|
|
172
|
+
request.notifyChannel = data.notifyChannel
|
|
173
|
+
}
|
|
174
|
+
if (data.expiresAt !== undefined) {
|
|
175
|
+
request.expiresAt = data.expiresAt
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
this.requests.set(id, request)
|
|
179
|
+
return request
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async get(id: string): Promise<HumanRequest | undefined> {
|
|
183
|
+
return this.requests.get(id)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async update(id: string, data: UpdateRequestData): Promise<HumanRequest | undefined> {
|
|
187
|
+
const request = this.requests.get(id)
|
|
188
|
+
if (!request) {
|
|
189
|
+
return undefined
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (data.status !== undefined) {
|
|
193
|
+
request.status = data.status
|
|
194
|
+
}
|
|
195
|
+
if (data.externalId !== undefined) {
|
|
196
|
+
request.externalId = data.externalId
|
|
197
|
+
this.externalIdIndex.set(data.externalId, id)
|
|
198
|
+
}
|
|
199
|
+
if (data.result !== undefined) {
|
|
200
|
+
request.result = data.result
|
|
201
|
+
}
|
|
202
|
+
if (data.completedAt !== undefined) {
|
|
203
|
+
request.completedAt = data.completedAt
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Auto-set completedAt when status becomes terminal
|
|
207
|
+
if (data.status === 'completed' || data.status === 'expired' || data.status === 'cancelled') {
|
|
208
|
+
if (!request.completedAt) {
|
|
209
|
+
request.completedAt = new Date()
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
this.requests.set(id, request)
|
|
214
|
+
return request
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async findByExternalId(externalId: string): Promise<HumanRequest | undefined> {
|
|
218
|
+
const requestId = this.externalIdIndex.get(externalId)
|
|
219
|
+
if (!requestId) {
|
|
220
|
+
return undefined
|
|
221
|
+
}
|
|
222
|
+
return this.requests.get(requestId)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async listPending(): Promise<HumanRequest[]> {
|
|
226
|
+
const pending: HumanRequest[] = []
|
|
227
|
+
for (const request of this.requests.values()) {
|
|
228
|
+
if (request.status === 'pending') {
|
|
229
|
+
pending.push(request)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return pending
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async listExpired(): Promise<HumanRequest[]> {
|
|
236
|
+
const now = new Date()
|
|
237
|
+
const expired: HumanRequest[] = []
|
|
238
|
+
for (const request of this.requests.values()) {
|
|
239
|
+
if (request.status === 'pending' && request.expiresAt && request.expiresAt <= now) {
|
|
240
|
+
expired.push(request)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return expired
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// =============================================================================
|
|
248
|
+
// Processor Types
|
|
249
|
+
// =============================================================================
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Supported transport adapters
|
|
253
|
+
*/
|
|
254
|
+
export interface TransportAdapters {
|
|
255
|
+
slack?: SlackTransport
|
|
256
|
+
email?: EmailTransport
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Result of submitting a request
|
|
261
|
+
*/
|
|
262
|
+
export interface SubmitResult {
|
|
263
|
+
success: boolean
|
|
264
|
+
requestId?: string
|
|
265
|
+
externalId?: string
|
|
266
|
+
error?: string
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Data for submitting a new request
|
|
271
|
+
*/
|
|
272
|
+
export interface SubmitRequestData {
|
|
273
|
+
type: RequestType
|
|
274
|
+
target: string
|
|
275
|
+
content: string
|
|
276
|
+
transport: Transport
|
|
277
|
+
context?: Record<string, unknown>
|
|
278
|
+
requestedBy?: WorkerRef
|
|
279
|
+
notifyChannel?: string
|
|
280
|
+
timeout?: number
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Webhook payload for processing responses
|
|
285
|
+
*/
|
|
286
|
+
export interface WebhookPayload {
|
|
287
|
+
type: 'approval_response' | 'question_response' | 'email_reply'
|
|
288
|
+
requestId: string
|
|
289
|
+
approved?: boolean
|
|
290
|
+
answer?: unknown
|
|
291
|
+
respondedBy?: WorkerRef
|
|
292
|
+
notes?: string
|
|
293
|
+
from?: string
|
|
294
|
+
content?: string
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Result of processing a webhook
|
|
299
|
+
*/
|
|
300
|
+
export interface WebhookResult {
|
|
301
|
+
success: boolean
|
|
302
|
+
requestId?: string
|
|
303
|
+
error?: string
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Callback data when a request completes
|
|
308
|
+
*/
|
|
309
|
+
export interface CompleteCallbackData {
|
|
310
|
+
requestId: string
|
|
311
|
+
request: HumanRequest
|
|
312
|
+
result: RequestResult
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Callback data when a request times out
|
|
317
|
+
*/
|
|
318
|
+
export interface TimeoutCallbackData {
|
|
319
|
+
requestId: string
|
|
320
|
+
request: HumanRequest
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Result of cancelling a request
|
|
325
|
+
*/
|
|
326
|
+
export interface CancelResult {
|
|
327
|
+
success: boolean
|
|
328
|
+
error?: string
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Configuration for HumanRequestProcessor
|
|
333
|
+
*/
|
|
334
|
+
export interface ProcessorConfig {
|
|
335
|
+
/** Request store for persistence */
|
|
336
|
+
store: HumanRequestStore
|
|
337
|
+
/** Transport adapters */
|
|
338
|
+
transports: TransportAdapters
|
|
339
|
+
/** Callback when a request completes */
|
|
340
|
+
onComplete?: (data: CompleteCallbackData) => void | Promise<void>
|
|
341
|
+
/** Callback when a request times out */
|
|
342
|
+
onTimeout?: (data: TimeoutCallbackData) => void | Promise<void>
|
|
343
|
+
/** Whether to notify requestor of results */
|
|
344
|
+
notifyRequestor?: boolean
|
|
345
|
+
/** Interval for checking expired requests (ms) */
|
|
346
|
+
checkIntervalMs?: number
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// =============================================================================
|
|
350
|
+
// Human Request Processor
|
|
351
|
+
// =============================================================================
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Human Request Processor
|
|
355
|
+
*
|
|
356
|
+
* Coordinates between transport adapters and the request store to
|
|
357
|
+
* handle approval workflows, questions, and notifications.
|
|
358
|
+
*
|
|
359
|
+
* @example
|
|
360
|
+
* ```ts
|
|
361
|
+
* const processor = createHumanRequestProcessor({
|
|
362
|
+
* store: new InMemoryRequestStore(),
|
|
363
|
+
* transports: {
|
|
364
|
+
* slack: createSlackTransport({ botToken, signingSecret }),
|
|
365
|
+
* email: createEmailTransport({ apiKey }),
|
|
366
|
+
* },
|
|
367
|
+
* onComplete: (data) => {
|
|
368
|
+
* console.log(`Request ${data.requestId} completed:`, data.result)
|
|
369
|
+
* },
|
|
370
|
+
* })
|
|
371
|
+
*
|
|
372
|
+
* // Submit a request
|
|
373
|
+
* const result = await processor.submitRequest({
|
|
374
|
+
* type: 'approval',
|
|
375
|
+
* target: '#approvals',
|
|
376
|
+
* content: 'Approve deployment?',
|
|
377
|
+
* transport: 'slack',
|
|
378
|
+
* })
|
|
379
|
+
*
|
|
380
|
+
* // Handle webhook when user responds
|
|
381
|
+
* await processor.handleWebhook('slack', {
|
|
382
|
+
* type: 'approval_response',
|
|
383
|
+
* requestId: result.requestId,
|
|
384
|
+
* approved: true,
|
|
385
|
+
* })
|
|
386
|
+
* ```
|
|
387
|
+
*/
|
|
388
|
+
export class HumanRequestProcessor {
|
|
389
|
+
private store: HumanRequestStore
|
|
390
|
+
private transports: TransportAdapters
|
|
391
|
+
private onCompleteCallback: ((data: CompleteCallbackData) => void | Promise<void>) | null
|
|
392
|
+
private onTimeoutCallback: ((data: TimeoutCallbackData) => void | Promise<void>) | null
|
|
393
|
+
private notifyRequestor: boolean
|
|
394
|
+
private checkIntervalMs: number
|
|
395
|
+
private timeoutChecker: ReturnType<typeof setInterval> | null = null
|
|
396
|
+
private destroyed = false
|
|
397
|
+
|
|
398
|
+
constructor(config: ProcessorConfig) {
|
|
399
|
+
this.store = config.store
|
|
400
|
+
this.transports = config.transports
|
|
401
|
+
this.onCompleteCallback = config.onComplete ?? null
|
|
402
|
+
this.onTimeoutCallback = config.onTimeout ?? null
|
|
403
|
+
this.notifyRequestor = config.notifyRequestor ?? false
|
|
404
|
+
this.checkIntervalMs = config.checkIntervalMs ?? 30000
|
|
405
|
+
|
|
406
|
+
// Start timeout checker if onTimeout callback is provided
|
|
407
|
+
if (this.onTimeoutCallback) {
|
|
408
|
+
this.startTimeoutChecker()
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Submit a new human request via the specified transport
|
|
414
|
+
*/
|
|
415
|
+
async submitRequest(data: SubmitRequestData): Promise<SubmitResult> {
|
|
416
|
+
// Validate transport is configured
|
|
417
|
+
const transport = this.getTransport(data.transport)
|
|
418
|
+
if (!transport) {
|
|
419
|
+
return {
|
|
420
|
+
success: false,
|
|
421
|
+
error: `Transport '${data.transport}' not configured`,
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Calculate expiration time if timeout specified
|
|
426
|
+
let expiresAt: Date | undefined
|
|
427
|
+
if (data.timeout !== undefined) {
|
|
428
|
+
expiresAt = new Date(Date.now() + data.timeout)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Create request in store
|
|
432
|
+
const createData: CreateRequestData = {
|
|
433
|
+
type: data.type,
|
|
434
|
+
target: data.target,
|
|
435
|
+
content: data.content,
|
|
436
|
+
transport: data.transport,
|
|
437
|
+
}
|
|
438
|
+
if (data.requestedBy !== undefined) {
|
|
439
|
+
createData.requestedBy = data.requestedBy
|
|
440
|
+
}
|
|
441
|
+
if (data.context !== undefined) {
|
|
442
|
+
createData.context = data.context
|
|
443
|
+
}
|
|
444
|
+
if (data.notifyChannel !== undefined) {
|
|
445
|
+
createData.notifyChannel = data.notifyChannel
|
|
446
|
+
}
|
|
447
|
+
if (expiresAt !== undefined) {
|
|
448
|
+
createData.expiresAt = expiresAt
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const request = await this.store.create(createData)
|
|
452
|
+
|
|
453
|
+
// Send via transport
|
|
454
|
+
let deliveryResult: DeliveryResult
|
|
455
|
+
|
|
456
|
+
try {
|
|
457
|
+
deliveryResult = await this.sendViaTransport(data.transport, transport, request)
|
|
458
|
+
} catch (error) {
|
|
459
|
+
// Update request status to failed
|
|
460
|
+
await this.store.update(request.id, { status: 'failed' })
|
|
461
|
+
return {
|
|
462
|
+
success: false,
|
|
463
|
+
requestId: request.id,
|
|
464
|
+
error: error instanceof Error ? error.message : String(error),
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (!deliveryResult.success) {
|
|
469
|
+
// Update request status to failed
|
|
470
|
+
await this.store.update(request.id, { status: 'failed' })
|
|
471
|
+
const errorResult: SubmitResult = {
|
|
472
|
+
success: false,
|
|
473
|
+
requestId: request.id,
|
|
474
|
+
}
|
|
475
|
+
if (deliveryResult.error) {
|
|
476
|
+
errorResult.error = deliveryResult.error
|
|
477
|
+
}
|
|
478
|
+
return errorResult
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Update request with external ID
|
|
482
|
+
if (deliveryResult.messageId) {
|
|
483
|
+
await this.store.update(request.id, {
|
|
484
|
+
externalId: deliveryResult.messageId,
|
|
485
|
+
})
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const successResult: SubmitResult = {
|
|
489
|
+
success: true,
|
|
490
|
+
requestId: request.id,
|
|
491
|
+
}
|
|
492
|
+
if (deliveryResult.messageId) {
|
|
493
|
+
successResult.externalId = deliveryResult.messageId
|
|
494
|
+
}
|
|
495
|
+
return successResult
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Handle a webhook callback from a transport
|
|
500
|
+
*/
|
|
501
|
+
async handleWebhook(transport: Transport, payload: WebhookPayload): Promise<WebhookResult> {
|
|
502
|
+
const { requestId } = payload
|
|
503
|
+
|
|
504
|
+
// Find the request
|
|
505
|
+
const request = await this.store.get(requestId)
|
|
506
|
+
if (!request) {
|
|
507
|
+
return {
|
|
508
|
+
success: false,
|
|
509
|
+
requestId,
|
|
510
|
+
error: `Request '${requestId}' not found`,
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Check if already completed
|
|
515
|
+
if (request.status !== 'pending') {
|
|
516
|
+
return {
|
|
517
|
+
success: false,
|
|
518
|
+
requestId,
|
|
519
|
+
error: `Request '${requestId}' already completed with status '${request.status}'`,
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Build result based on payload type
|
|
524
|
+
let result: RequestResult
|
|
525
|
+
|
|
526
|
+
if (payload.type === 'email_reply') {
|
|
527
|
+
// Parse email content for approval/rejection
|
|
528
|
+
const approved = this.parseEmailApproval(payload.content ?? '')
|
|
529
|
+
result = {
|
|
530
|
+
approved,
|
|
531
|
+
via: transport,
|
|
532
|
+
respondedAt: new Date(),
|
|
533
|
+
}
|
|
534
|
+
if (payload.from) {
|
|
535
|
+
result.respondedBy = { id: payload.from }
|
|
536
|
+
}
|
|
537
|
+
} else {
|
|
538
|
+
result = {
|
|
539
|
+
respondedAt: new Date(),
|
|
540
|
+
via: transport,
|
|
541
|
+
}
|
|
542
|
+
if (payload.approved !== undefined) {
|
|
543
|
+
result.approved = payload.approved
|
|
544
|
+
}
|
|
545
|
+
if (payload.answer !== undefined) {
|
|
546
|
+
result.answer = payload.answer
|
|
547
|
+
}
|
|
548
|
+
if (payload.respondedBy !== undefined) {
|
|
549
|
+
result.respondedBy = payload.respondedBy
|
|
550
|
+
}
|
|
551
|
+
if (payload.notes !== undefined) {
|
|
552
|
+
result.notes = payload.notes
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Update the request
|
|
557
|
+
await this.store.update(requestId, {
|
|
558
|
+
status: 'completed',
|
|
559
|
+
result,
|
|
560
|
+
completedAt: new Date(),
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
// Notify requestor if configured
|
|
564
|
+
if (this.notifyRequestor && request.notifyChannel) {
|
|
565
|
+
await this.notifyRequestorOfResult(request, result)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Call onComplete callback
|
|
569
|
+
if (this.onCompleteCallback) {
|
|
570
|
+
const updatedRequest = await this.store.get(requestId)
|
|
571
|
+
if (updatedRequest) {
|
|
572
|
+
try {
|
|
573
|
+
await this.onCompleteCallback({
|
|
574
|
+
requestId,
|
|
575
|
+
request: updatedRequest,
|
|
576
|
+
result,
|
|
577
|
+
})
|
|
578
|
+
} catch {
|
|
579
|
+
// Swallow callback errors
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return {
|
|
585
|
+
success: true,
|
|
586
|
+
requestId,
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Get a request by ID
|
|
592
|
+
*/
|
|
593
|
+
async getRequest(requestId: string): Promise<HumanRequest | undefined> {
|
|
594
|
+
return this.store.get(requestId)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Cancel a pending request
|
|
599
|
+
*/
|
|
600
|
+
async cancelRequest(requestId: string): Promise<CancelResult> {
|
|
601
|
+
const request = await this.store.get(requestId)
|
|
602
|
+
if (!request) {
|
|
603
|
+
return {
|
|
604
|
+
success: false,
|
|
605
|
+
error: `Request '${requestId}' not found`,
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (request.status !== 'pending') {
|
|
610
|
+
return {
|
|
611
|
+
success: false,
|
|
612
|
+
error: `Request '${requestId}' already completed with status '${request.status}'`,
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
await this.store.update(requestId, {
|
|
617
|
+
status: 'cancelled',
|
|
618
|
+
completedAt: new Date(),
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
return { success: true }
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Stop the processor and clean up resources
|
|
626
|
+
*/
|
|
627
|
+
destroy(): void {
|
|
628
|
+
this.destroyed = true
|
|
629
|
+
if (this.timeoutChecker !== null) {
|
|
630
|
+
clearInterval(this.timeoutChecker)
|
|
631
|
+
this.timeoutChecker = null
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ===========================================================================
|
|
636
|
+
// Private Methods
|
|
637
|
+
// ===========================================================================
|
|
638
|
+
|
|
639
|
+
private getTransport(transport: Transport): SlackTransport | EmailTransport | undefined {
|
|
640
|
+
switch (transport) {
|
|
641
|
+
case 'slack':
|
|
642
|
+
return this.transports.slack
|
|
643
|
+
case 'email':
|
|
644
|
+
return this.transports.email
|
|
645
|
+
default:
|
|
646
|
+
return undefined
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
private async sendViaTransport(
|
|
651
|
+
transportName: Transport,
|
|
652
|
+
transport: SlackTransport | EmailTransport,
|
|
653
|
+
request: HumanRequest
|
|
654
|
+
): Promise<DeliveryResult> {
|
|
655
|
+
if (transportName === 'slack') {
|
|
656
|
+
const slackTransport = transport as SlackTransport
|
|
657
|
+
if (request.type === 'approval') {
|
|
658
|
+
const options: {
|
|
659
|
+
requestId: string
|
|
660
|
+
context?: Record<string, unknown>
|
|
661
|
+
} = { requestId: request.id }
|
|
662
|
+
if (request.context !== undefined) {
|
|
663
|
+
options.context = request.context
|
|
664
|
+
}
|
|
665
|
+
return slackTransport.sendApprovalRequest(request.target, request.content, options)
|
|
666
|
+
} else if (request.type === 'question') {
|
|
667
|
+
return slackTransport.sendQuestion(request.target, request.content, {
|
|
668
|
+
requestId: request.id,
|
|
669
|
+
})
|
|
670
|
+
} else {
|
|
671
|
+
return slackTransport.sendNotification(request.target, request.content)
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (transportName === 'email') {
|
|
676
|
+
const emailTransport = transport as EmailTransport
|
|
677
|
+
if (request.type === 'approval') {
|
|
678
|
+
const options: {
|
|
679
|
+
to: string
|
|
680
|
+
request: string
|
|
681
|
+
requestId: string
|
|
682
|
+
context?: Record<string, unknown>
|
|
683
|
+
} = {
|
|
684
|
+
to: request.target,
|
|
685
|
+
request: request.content,
|
|
686
|
+
requestId: request.id,
|
|
687
|
+
}
|
|
688
|
+
if (request.context !== undefined) {
|
|
689
|
+
options.context = request.context
|
|
690
|
+
}
|
|
691
|
+
return emailTransport.sendApprovalRequest(options)
|
|
692
|
+
} else {
|
|
693
|
+
return emailTransport.sendNotification({
|
|
694
|
+
to: request.target,
|
|
695
|
+
message: request.content,
|
|
696
|
+
})
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
return {
|
|
701
|
+
success: false,
|
|
702
|
+
transport: transportName,
|
|
703
|
+
error: `Unsupported transport: ${transportName}`,
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
private parseEmailApproval(content: string): boolean {
|
|
708
|
+
const lower = content.toLowerCase().trim()
|
|
709
|
+
const approvePatterns = [/^approved/i, /^yes/i, /^lgtm/i, /^ok\b/i]
|
|
710
|
+
|
|
711
|
+
for (const pattern of approvePatterns) {
|
|
712
|
+
if (pattern.test(lower)) {
|
|
713
|
+
return true
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return false
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
private async notifyRequestorOfResult(
|
|
721
|
+
request: HumanRequest,
|
|
722
|
+
result: RequestResult
|
|
723
|
+
): Promise<void> {
|
|
724
|
+
if (!request.notifyChannel) {
|
|
725
|
+
return
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Use Slack if available
|
|
729
|
+
const slackTransport = this.transports.slack
|
|
730
|
+
if (slackTransport) {
|
|
731
|
+
const status = result.approved ? 'approved' : 'rejected'
|
|
732
|
+
const message = `Your request "${request.content}" was ${status}${
|
|
733
|
+
result.notes ? `: ${result.notes}` : ''
|
|
734
|
+
}`
|
|
735
|
+
|
|
736
|
+
await slackTransport.sendNotification(request.notifyChannel, message)
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
private startTimeoutChecker(): void {
|
|
741
|
+
this.timeoutChecker = setInterval(async () => {
|
|
742
|
+
if (this.destroyed) {
|
|
743
|
+
return
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
try {
|
|
747
|
+
const expiredRequests = await this.store.listExpired()
|
|
748
|
+
|
|
749
|
+
for (const request of expiredRequests) {
|
|
750
|
+
// Update status to expired
|
|
751
|
+
await this.store.update(request.id, {
|
|
752
|
+
status: 'expired',
|
|
753
|
+
completedAt: new Date(),
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
// Call onTimeout callback
|
|
757
|
+
if (this.onTimeoutCallback) {
|
|
758
|
+
try {
|
|
759
|
+
await this.onTimeoutCallback({
|
|
760
|
+
requestId: request.id,
|
|
761
|
+
request,
|
|
762
|
+
})
|
|
763
|
+
} catch {
|
|
764
|
+
// Swallow callback errors
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
} catch {
|
|
769
|
+
// Swallow errors in timeout checker
|
|
770
|
+
}
|
|
771
|
+
}, this.checkIntervalMs)
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// =============================================================================
|
|
776
|
+
// Factory Functions
|
|
777
|
+
// =============================================================================
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Create a HumanRequestProcessor instance
|
|
781
|
+
*
|
|
782
|
+
* @example
|
|
783
|
+
* ```ts
|
|
784
|
+
* const processor = createHumanRequestProcessor({
|
|
785
|
+
* store: new InMemoryRequestStore(),
|
|
786
|
+
* transports: {
|
|
787
|
+
* slack: createSlackTransport({ botToken, signingSecret }),
|
|
788
|
+
* },
|
|
789
|
+
* onComplete: (data) => console.log('Completed:', data),
|
|
790
|
+
* onTimeout: (data) => console.log('Timed out:', data),
|
|
791
|
+
* })
|
|
792
|
+
* ```
|
|
793
|
+
*/
|
|
794
|
+
export function createHumanRequestProcessor(config: ProcessorConfig): HumanRequestProcessor {
|
|
795
|
+
return new HumanRequestProcessor(config)
|
|
796
|
+
}
|