digital-workers 2.1.3 → 2.3.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 (183) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +2 -0
  3. package/dist/actions.d.ts.map +1 -1
  4. package/dist/actions.js +33 -21
  5. package/dist/actions.js.map +1 -1
  6. package/dist/agent-comms.d.ts.map +1 -1
  7. package/dist/agent-comms.js +36 -25
  8. package/dist/agent-comms.js.map +1 -1
  9. package/dist/approve.d.ts +40 -8
  10. package/dist/approve.d.ts.map +1 -1
  11. package/dist/approve.js +86 -20
  12. package/dist/approve.js.map +1 -1
  13. package/dist/ask.d.ts +38 -7
  14. package/dist/ask.d.ts.map +1 -1
  15. package/dist/ask.js +85 -25
  16. package/dist/ask.js.map +1 -1
  17. package/dist/browse.d.ts +223 -0
  18. package/dist/browse.d.ts.map +1 -0
  19. package/dist/browse.js +392 -0
  20. package/dist/browse.js.map +1 -0
  21. package/dist/capability-tiers.js +3 -3
  22. package/dist/capability-tiers.js.map +1 -1
  23. package/dist/cascade-context.d.ts +28 -28
  24. package/dist/client.d.ts +162 -0
  25. package/dist/client.d.ts.map +1 -0
  26. package/dist/client.js +64 -0
  27. package/dist/client.js.map +1 -0
  28. package/dist/decide.d.ts +42 -6
  29. package/dist/decide.d.ts.map +1 -1
  30. package/dist/decide.js +54 -11
  31. package/dist/decide.js.map +1 -1
  32. package/dist/do.d.ts +36 -7
  33. package/dist/do.d.ts.map +1 -1
  34. package/dist/do.js +82 -39
  35. package/dist/do.js.map +1 -1
  36. package/dist/error-escalation.d.ts.map +1 -1
  37. package/dist/error-escalation.js +38 -38
  38. package/dist/error-escalation.js.map +1 -1
  39. package/dist/generate.d.ts +48 -7
  40. package/dist/generate.d.ts.map +1 -1
  41. package/dist/generate.js +49 -8
  42. package/dist/generate.js.map +1 -1
  43. package/dist/goals.d.ts +10 -9
  44. package/dist/goals.d.ts.map +1 -1
  45. package/dist/goals.js +30 -24
  46. package/dist/goals.js.map +1 -1
  47. package/dist/image.d.ts +189 -0
  48. package/dist/image.d.ts.map +1 -0
  49. package/dist/image.js +528 -0
  50. package/dist/image.js.map +1 -0
  51. package/dist/index.d.ts +49 -2
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +58 -2
  54. package/dist/index.js.map +1 -1
  55. package/dist/is.d.ts +45 -10
  56. package/dist/is.d.ts.map +1 -1
  57. package/dist/is.js +56 -21
  58. package/dist/is.js.map +1 -1
  59. package/dist/kpis.d.ts +24 -15
  60. package/dist/kpis.d.ts.map +1 -1
  61. package/dist/kpis.js +16 -14
  62. package/dist/kpis.js.map +1 -1
  63. package/dist/load-balancing.d.ts.map +1 -1
  64. package/dist/load-balancing.js +124 -38
  65. package/dist/load-balancing.js.map +1 -1
  66. package/dist/logger.d.ts +76 -0
  67. package/dist/logger.d.ts.map +1 -0
  68. package/dist/logger.js +39 -0
  69. package/dist/logger.js.map +1 -0
  70. package/dist/notify.d.ts +38 -9
  71. package/dist/notify.d.ts.map +1 -1
  72. package/dist/notify.js +72 -17
  73. package/dist/notify.js.map +1 -1
  74. package/dist/role.d.ts +5 -4
  75. package/dist/role.d.ts.map +1 -1
  76. package/dist/role.js +13 -10
  77. package/dist/role.js.map +1 -1
  78. package/dist/runtime.d.ts +310 -0
  79. package/dist/runtime.d.ts.map +1 -0
  80. package/dist/runtime.js +510 -0
  81. package/dist/runtime.js.map +1 -0
  82. package/dist/team.d.ts +11 -6
  83. package/dist/team.d.ts.map +1 -1
  84. package/dist/team.js +22 -15
  85. package/dist/team.js.map +1 -1
  86. package/dist/transports/email.d.ts +318 -0
  87. package/dist/transports/email.d.ts.map +1 -0
  88. package/dist/transports/email.js +779 -0
  89. package/dist/transports/email.js.map +1 -0
  90. package/dist/transports/slack.d.ts +515 -0
  91. package/dist/transports/slack.d.ts.map +1 -0
  92. package/dist/transports/slack.js +844 -0
  93. package/dist/transports/slack.js.map +1 -0
  94. package/dist/transports.d.ts.map +1 -1
  95. package/dist/transports.js +44 -25
  96. package/dist/transports.js.map +1 -1
  97. package/dist/types.d.ts +141 -19
  98. package/dist/types.d.ts.map +1 -1
  99. package/dist/types.js +5 -0
  100. package/dist/types.js.map +1 -1
  101. package/dist/utils/id.d.ts +19 -0
  102. package/dist/utils/id.d.ts.map +1 -0
  103. package/dist/utils/id.js +21 -0
  104. package/dist/utils/id.js.map +1 -0
  105. package/dist/video.d.ts +203 -0
  106. package/dist/video.d.ts.map +1 -0
  107. package/dist/video.js +528 -0
  108. package/dist/video.js.map +1 -0
  109. package/dist/worker.d.ts +343 -0
  110. package/dist/worker.d.ts.map +1 -0
  111. package/dist/worker.js +698 -0
  112. package/dist/worker.js.map +1 -0
  113. package/package.json +32 -14
  114. package/src/actions.ts +39 -30
  115. package/src/agent-comms.ts +54 -92
  116. package/src/approve.ts +91 -20
  117. package/src/ask.ts +99 -25
  118. package/src/browse.ts +627 -0
  119. package/src/capability-tiers.ts +5 -5
  120. package/src/client.ts +221 -0
  121. package/src/decide.ts +81 -35
  122. package/src/do.ts +98 -52
  123. package/src/error-escalation.ts +55 -67
  124. package/src/generate.ts +52 -18
  125. package/src/goals.ts +36 -27
  126. package/src/image.ts +816 -0
  127. package/src/index.ts +187 -2
  128. package/src/is.ts +59 -25
  129. package/src/kpis.ts +41 -36
  130. package/src/load-balancing.ts +132 -46
  131. package/src/logger.ts +93 -0
  132. package/src/notify.ts +78 -17
  133. package/src/role.ts +30 -20
  134. package/src/runtime.ts +796 -0
  135. package/src/team.ts +24 -19
  136. package/src/transports/email.ts +1160 -0
  137. package/src/transports/slack.ts +1320 -0
  138. package/src/transports.ts +58 -43
  139. package/src/types.ts +174 -46
  140. package/src/utils/id.ts +21 -0
  141. package/src/video.ts +906 -0
  142. package/src/worker.ts +1007 -0
  143. package/test/approve.test.ts +305 -0
  144. package/test/ask.test.ts +274 -0
  145. package/test/browse.test.ts +361 -0
  146. package/test/decide.test.ts +252 -0
  147. package/test/do.test.ts +144 -0
  148. package/test/error-logging.test.ts +357 -0
  149. package/test/generate.test.ts +319 -0
  150. package/test/image.test.ts +398 -0
  151. package/test/is.test.ts +287 -0
  152. package/test/load-balancing-safety.test.ts +404 -0
  153. package/test/notify.test.ts +434 -0
  154. package/test/primitives.test.ts +320 -0
  155. package/test/runtime-integration.test.ts +892 -0
  156. package/test/transports/crypto.test.ts +230 -0
  157. package/test/transports/email.test.ts +866 -0
  158. package/test/transports/id-generation.test.ts +91 -0
  159. package/test/transports/slack.test.ts +760 -0
  160. package/test/type-safety.test.ts +834 -0
  161. package/test/types.test.ts +60 -2
  162. package/test/video.test.ts +530 -0
  163. package/test/worker.test.ts +1433 -0
  164. package/tsconfig.json +4 -1
  165. package/vitest.config.ts +42 -0
  166. package/wrangler.jsonc +36 -0
  167. package/.turbo/turbo-build.log +0 -4
  168. package/LICENSE +0 -21
  169. package/src/actions.js +0 -436
  170. package/src/approve.js +0 -234
  171. package/src/ask.js +0 -226
  172. package/src/decide.js +0 -244
  173. package/src/do.js +0 -227
  174. package/src/generate.js +0 -298
  175. package/src/goals.js +0 -205
  176. package/src/index.js +0 -68
  177. package/src/is.js +0 -317
  178. package/src/kpis.js +0 -270
  179. package/src/notify.js +0 -219
  180. package/src/role.js +0 -110
  181. package/src/team.js +0 -130
  182. package/src/transports.js +0 -357
  183. 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
+ }