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
|
@@ -0,0 +1,1433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker Export Tests for digital-workers (RED phase)
|
|
3
|
+
*
|
|
4
|
+
* Tests the /worker export which provides DigitalWorkersService as a WorkerEntrypoint.
|
|
5
|
+
* Uses @cloudflare/vitest-pool-workers for real Workers environment testing.
|
|
6
|
+
*
|
|
7
|
+
* NO MOCKS - tests use real AI Gateway binding for cached responses
|
|
8
|
+
* and real Durable Objects for worker state management.
|
|
9
|
+
*
|
|
10
|
+
* These tests will FAIL until src/worker.ts is implemented (GREEN phase).
|
|
11
|
+
*
|
|
12
|
+
* The WorkerEntrypoint provides:
|
|
13
|
+
* - Worker lifecycle management (spawn, terminate, pause, resume)
|
|
14
|
+
* - Worker communication/messaging
|
|
15
|
+
* - Worker state management
|
|
16
|
+
* - Worker coordination patterns
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
20
|
+
import { env } from 'cloudflare:test'
|
|
21
|
+
|
|
22
|
+
// These imports will fail until worker.ts is implemented
|
|
23
|
+
import { DigitalWorkersService, DigitalWorkersServiceCore } from '../src/worker.js'
|
|
24
|
+
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// Type Definitions for Expected Service Interface
|
|
27
|
+
// =============================================================================
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Worker instance state
|
|
31
|
+
*/
|
|
32
|
+
interface WorkerInstance {
|
|
33
|
+
id: string
|
|
34
|
+
name: string
|
|
35
|
+
status: WorkerInstanceStatus
|
|
36
|
+
type: 'agent' | 'human'
|
|
37
|
+
tier?: string
|
|
38
|
+
createdAt: Date
|
|
39
|
+
updatedAt: Date
|
|
40
|
+
metadata?: Record<string, unknown>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Worker instance status
|
|
45
|
+
*/
|
|
46
|
+
type WorkerInstanceStatus = 'spawning' | 'running' | 'paused' | 'terminated' | 'error'
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Message sent between workers
|
|
50
|
+
*/
|
|
51
|
+
interface WorkerMessage<T = unknown> {
|
|
52
|
+
id: string
|
|
53
|
+
from: string
|
|
54
|
+
to: string
|
|
55
|
+
type: string
|
|
56
|
+
payload: T
|
|
57
|
+
timestamp: Date
|
|
58
|
+
acknowledged?: boolean
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Worker spawn options
|
|
63
|
+
*/
|
|
64
|
+
interface SpawnOptions {
|
|
65
|
+
name?: string
|
|
66
|
+
type?: 'agent' | 'human'
|
|
67
|
+
tier?: string
|
|
68
|
+
metadata?: Record<string, unknown>
|
|
69
|
+
timeout?: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Worker coordination task
|
|
74
|
+
*/
|
|
75
|
+
interface CoordinationTask<T = unknown> {
|
|
76
|
+
id: string
|
|
77
|
+
type: 'fanout' | 'pipeline' | 'race' | 'consensus'
|
|
78
|
+
workers: string[]
|
|
79
|
+
status: 'pending' | 'running' | 'completed' | 'failed'
|
|
80
|
+
result?: T
|
|
81
|
+
errors?: Error[]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// =============================================================================
|
|
85
|
+
// DigitalWorkersServiceCore (RpcTarget) Tests
|
|
86
|
+
// =============================================================================
|
|
87
|
+
|
|
88
|
+
describe('DigitalWorkersServiceCore (RpcTarget)', () => {
|
|
89
|
+
let core: InstanceType<typeof DigitalWorkersServiceCore>
|
|
90
|
+
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
core = new DigitalWorkersServiceCore(env)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('constructor', () => {
|
|
96
|
+
it('creates a new DigitalWorkersServiceCore instance', () => {
|
|
97
|
+
expect(core).toBeInstanceOf(DigitalWorkersServiceCore)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('accepts env with AI and DO bindings', () => {
|
|
101
|
+
const serviceWithEnv = new DigitalWorkersServiceCore(env)
|
|
102
|
+
expect(serviceWithEnv).toBeDefined()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('extends RpcTarget for RPC communication', () => {
|
|
106
|
+
expect(core.constructor.name).toBe('DigitalWorkersServiceCore')
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// ===========================================================================
|
|
111
|
+
// Worker Lifecycle Management
|
|
112
|
+
// ===========================================================================
|
|
113
|
+
|
|
114
|
+
describe('spawn()', () => {
|
|
115
|
+
it('creates a new worker instance with auto-generated ID', async () => {
|
|
116
|
+
const worker = await core.spawn()
|
|
117
|
+
|
|
118
|
+
expect(worker).toBeDefined()
|
|
119
|
+
expect(worker.id).toBeDefined()
|
|
120
|
+
expect(worker.id.length).toBeGreaterThan(0)
|
|
121
|
+
expect(worker.status).toBe('running')
|
|
122
|
+
expect(worker.createdAt).toBeInstanceOf(Date)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('creates a worker with custom name', async () => {
|
|
126
|
+
const worker = await core.spawn({ name: 'test-worker' })
|
|
127
|
+
|
|
128
|
+
expect(worker.name).toBe('test-worker')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('creates an agent worker type by default', async () => {
|
|
132
|
+
const worker = await core.spawn()
|
|
133
|
+
|
|
134
|
+
expect(worker.type).toBe('agent')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('creates a human worker type when specified', async () => {
|
|
138
|
+
const worker = await core.spawn({ type: 'human' })
|
|
139
|
+
|
|
140
|
+
expect(worker.type).toBe('human')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('assigns capability tier when specified', async () => {
|
|
144
|
+
const worker = await core.spawn({ tier: 'generative' })
|
|
145
|
+
|
|
146
|
+
expect(worker.tier).toBe('generative')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('stores metadata on the worker', async () => {
|
|
150
|
+
const worker = await core.spawn({
|
|
151
|
+
metadata: { department: 'engineering', role: 'reviewer' },
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
expect(worker.metadata).toEqual({ department: 'engineering', role: 'reviewer' })
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('sets spawning status initially before running', async () => {
|
|
158
|
+
// This tests the state transition
|
|
159
|
+
const worker = await core.spawn()
|
|
160
|
+
|
|
161
|
+
// Worker should be running after spawn completes
|
|
162
|
+
expect(['spawning', 'running']).toContain(worker.status)
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
describe('terminate()', () => {
|
|
167
|
+
let workerId: string
|
|
168
|
+
|
|
169
|
+
beforeEach(async () => {
|
|
170
|
+
const worker = await core.spawn({ name: 'to-terminate' })
|
|
171
|
+
workerId = worker.id
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('terminates a running worker', async () => {
|
|
175
|
+
const result = await core.terminate(workerId)
|
|
176
|
+
|
|
177
|
+
expect(result).toBe(true)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('sets worker status to terminated', async () => {
|
|
181
|
+
await core.terminate(workerId)
|
|
182
|
+
const worker = await core.getState(workerId)
|
|
183
|
+
|
|
184
|
+
expect(worker?.status).toBe('terminated')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('returns false for non-existent worker', async () => {
|
|
188
|
+
const result = await core.terminate('nonexistent-worker-id')
|
|
189
|
+
|
|
190
|
+
expect(result).toBe(false)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('cannot terminate an already terminated worker', async () => {
|
|
194
|
+
await core.terminate(workerId)
|
|
195
|
+
const result = await core.terminate(workerId)
|
|
196
|
+
|
|
197
|
+
// Should return false or throw - implementation dependent
|
|
198
|
+
expect(result).toBe(false)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('updates the updatedAt timestamp', async () => {
|
|
202
|
+
const before = await core.getState(workerId)
|
|
203
|
+
await core.terminate(workerId)
|
|
204
|
+
const after = await core.getState(workerId)
|
|
205
|
+
|
|
206
|
+
expect(after?.updatedAt.getTime()).toBeGreaterThanOrEqual(before!.updatedAt.getTime())
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
describe('pause()', () => {
|
|
211
|
+
let workerId: string
|
|
212
|
+
|
|
213
|
+
beforeEach(async () => {
|
|
214
|
+
const worker = await core.spawn({ name: 'to-pause' })
|
|
215
|
+
workerId = worker.id
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('pauses a running worker', async () => {
|
|
219
|
+
const result = await core.pause(workerId)
|
|
220
|
+
|
|
221
|
+
expect(result).toBe(true)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('sets worker status to paused', async () => {
|
|
225
|
+
await core.pause(workerId)
|
|
226
|
+
const worker = await core.getState(workerId)
|
|
227
|
+
|
|
228
|
+
expect(worker?.status).toBe('paused')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('returns false for non-existent worker', async () => {
|
|
232
|
+
const result = await core.pause('nonexistent-worker-id')
|
|
233
|
+
|
|
234
|
+
expect(result).toBe(false)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('cannot pause a terminated worker', async () => {
|
|
238
|
+
await core.terminate(workerId)
|
|
239
|
+
const result = await core.pause(workerId)
|
|
240
|
+
|
|
241
|
+
expect(result).toBe(false)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('is idempotent - pausing already paused worker succeeds', async () => {
|
|
245
|
+
await core.pause(workerId)
|
|
246
|
+
const result = await core.pause(workerId)
|
|
247
|
+
|
|
248
|
+
expect(result).toBe(true)
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
describe('resume()', () => {
|
|
253
|
+
let workerId: string
|
|
254
|
+
|
|
255
|
+
beforeEach(async () => {
|
|
256
|
+
const worker = await core.spawn({ name: 'to-resume' })
|
|
257
|
+
workerId = worker.id
|
|
258
|
+
await core.pause(workerId)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('resumes a paused worker', async () => {
|
|
262
|
+
const result = await core.resume(workerId)
|
|
263
|
+
|
|
264
|
+
expect(result).toBe(true)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('sets worker status back to running', async () => {
|
|
268
|
+
await core.resume(workerId)
|
|
269
|
+
const worker = await core.getState(workerId)
|
|
270
|
+
|
|
271
|
+
expect(worker?.status).toBe('running')
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('returns false for non-existent worker', async () => {
|
|
275
|
+
const result = await core.resume('nonexistent-worker-id')
|
|
276
|
+
|
|
277
|
+
expect(result).toBe(false)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('cannot resume a terminated worker', async () => {
|
|
281
|
+
await core.terminate(workerId)
|
|
282
|
+
const result = await core.resume(workerId)
|
|
283
|
+
|
|
284
|
+
expect(result).toBe(false)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('is idempotent - resuming already running worker succeeds', async () => {
|
|
288
|
+
await core.resume(workerId)
|
|
289
|
+
const result = await core.resume(workerId)
|
|
290
|
+
|
|
291
|
+
expect(result).toBe(true)
|
|
292
|
+
})
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
// ===========================================================================
|
|
296
|
+
// Worker Communication / Messaging
|
|
297
|
+
// ===========================================================================
|
|
298
|
+
|
|
299
|
+
describe('send()', () => {
|
|
300
|
+
let worker1Id: string
|
|
301
|
+
let worker2Id: string
|
|
302
|
+
|
|
303
|
+
beforeEach(async () => {
|
|
304
|
+
const worker1 = await core.spawn({ name: 'sender' })
|
|
305
|
+
const worker2 = await core.spawn({ name: 'receiver' })
|
|
306
|
+
worker1Id = worker1.id
|
|
307
|
+
worker2Id = worker2.id
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('sends a message between workers', async () => {
|
|
311
|
+
const message = await core.send(worker1Id, worker2Id, 'greeting', { text: 'hello' })
|
|
312
|
+
|
|
313
|
+
expect(message).toBeDefined()
|
|
314
|
+
expect(message.id).toBeDefined()
|
|
315
|
+
expect(message.from).toBe(worker1Id)
|
|
316
|
+
expect(message.to).toBe(worker2Id)
|
|
317
|
+
expect(message.type).toBe('greeting')
|
|
318
|
+
expect(message.payload).toEqual({ text: 'hello' })
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('includes timestamp on message', async () => {
|
|
322
|
+
const message = await core.send(worker1Id, worker2Id, 'test', {})
|
|
323
|
+
|
|
324
|
+
expect(message.timestamp).toBeInstanceOf(Date)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('throws when sender does not exist', async () => {
|
|
328
|
+
await expect(core.send('nonexistent', worker2Id, 'test', {})).rejects.toThrow()
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('throws when receiver does not exist', async () => {
|
|
332
|
+
await expect(core.send(worker1Id, 'nonexistent', 'test', {})).rejects.toThrow()
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('cannot send from a terminated worker', async () => {
|
|
336
|
+
await core.terminate(worker1Id)
|
|
337
|
+
|
|
338
|
+
await expect(core.send(worker1Id, worker2Id, 'test', {})).rejects.toThrow()
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('cannot send to a terminated worker', async () => {
|
|
342
|
+
await core.terminate(worker2Id)
|
|
343
|
+
|
|
344
|
+
await expect(core.send(worker1Id, worker2Id, 'test', {})).rejects.toThrow()
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('can send to a paused worker (message queued)', async () => {
|
|
348
|
+
await core.pause(worker2Id)
|
|
349
|
+
const message = await core.send(worker1Id, worker2Id, 'queued', { data: 'test' })
|
|
350
|
+
|
|
351
|
+
expect(message).toBeDefined()
|
|
352
|
+
expect(message.acknowledged).toBe(false)
|
|
353
|
+
})
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
describe('receive()', () => {
|
|
357
|
+
let worker1Id: string
|
|
358
|
+
let worker2Id: string
|
|
359
|
+
|
|
360
|
+
beforeEach(async () => {
|
|
361
|
+
const worker1 = await core.spawn({ name: 'sender' })
|
|
362
|
+
const worker2 = await core.spawn({ name: 'receiver' })
|
|
363
|
+
worker1Id = worker1.id
|
|
364
|
+
worker2Id = worker2.id
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('receives messages for a worker', async () => {
|
|
368
|
+
await core.send(worker1Id, worker2Id, 'test', { msg: 'hello' })
|
|
369
|
+
const messages = await core.receive(worker2Id)
|
|
370
|
+
|
|
371
|
+
expect(messages).toHaveLength(1)
|
|
372
|
+
expect(messages[0].type).toBe('test')
|
|
373
|
+
expect(messages[0].payload).toEqual({ msg: 'hello' })
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it('returns empty array when no messages', async () => {
|
|
377
|
+
const messages = await core.receive(worker2Id)
|
|
378
|
+
|
|
379
|
+
expect(messages).toEqual([])
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it('receives multiple messages in order', async () => {
|
|
383
|
+
await core.send(worker1Id, worker2Id, 'first', { order: 1 })
|
|
384
|
+
await core.send(worker1Id, worker2Id, 'second', { order: 2 })
|
|
385
|
+
await core.send(worker1Id, worker2Id, 'third', { order: 3 })
|
|
386
|
+
|
|
387
|
+
const messages = await core.receive(worker2Id)
|
|
388
|
+
|
|
389
|
+
expect(messages).toHaveLength(3)
|
|
390
|
+
expect(messages[0].payload).toEqual({ order: 1 })
|
|
391
|
+
expect(messages[1].payload).toEqual({ order: 2 })
|
|
392
|
+
expect(messages[2].payload).toEqual({ order: 3 })
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('throws for non-existent worker', async () => {
|
|
396
|
+
await expect(core.receive('nonexistent')).rejects.toThrow()
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('can optionally filter by message type', async () => {
|
|
400
|
+
await core.send(worker1Id, worker2Id, 'typeA', { data: 'a' })
|
|
401
|
+
await core.send(worker1Id, worker2Id, 'typeB', { data: 'b' })
|
|
402
|
+
await core.send(worker1Id, worker2Id, 'typeA', { data: 'a2' })
|
|
403
|
+
|
|
404
|
+
const messages = await core.receive(worker2Id, { type: 'typeA' })
|
|
405
|
+
|
|
406
|
+
expect(messages).toHaveLength(2)
|
|
407
|
+
expect(messages.every((m) => m.type === 'typeA')).toBe(true)
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
it('can limit number of messages received', async () => {
|
|
411
|
+
await core.send(worker1Id, worker2Id, 'test', { n: 1 })
|
|
412
|
+
await core.send(worker1Id, worker2Id, 'test', { n: 2 })
|
|
413
|
+
await core.send(worker1Id, worker2Id, 'test', { n: 3 })
|
|
414
|
+
|
|
415
|
+
const messages = await core.receive(worker2Id, { limit: 2 })
|
|
416
|
+
|
|
417
|
+
expect(messages).toHaveLength(2)
|
|
418
|
+
})
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
describe('acknowledge()', () => {
|
|
422
|
+
let worker1Id: string
|
|
423
|
+
let worker2Id: string
|
|
424
|
+
let messageId: string
|
|
425
|
+
|
|
426
|
+
beforeEach(async () => {
|
|
427
|
+
const worker1 = await core.spawn({ name: 'sender' })
|
|
428
|
+
const worker2 = await core.spawn({ name: 'receiver' })
|
|
429
|
+
worker1Id = worker1.id
|
|
430
|
+
worker2Id = worker2.id
|
|
431
|
+
const message = await core.send(worker1Id, worker2Id, 'test', {})
|
|
432
|
+
messageId = message.id
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('acknowledges a received message', async () => {
|
|
436
|
+
const result = await core.acknowledge(worker2Id, messageId)
|
|
437
|
+
|
|
438
|
+
expect(result).toBe(true)
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it('marks message as acknowledged', async () => {
|
|
442
|
+
await core.acknowledge(worker2Id, messageId)
|
|
443
|
+
const messages = await core.receive(worker2Id, { acknowledged: true })
|
|
444
|
+
|
|
445
|
+
expect(messages).toHaveLength(1)
|
|
446
|
+
expect(messages[0].acknowledged).toBe(true)
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
it('returns false for non-existent message', async () => {
|
|
450
|
+
const result = await core.acknowledge(worker2Id, 'nonexistent-message')
|
|
451
|
+
|
|
452
|
+
expect(result).toBe(false)
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
it('throws for non-existent worker', async () => {
|
|
456
|
+
await expect(core.acknowledge('nonexistent', messageId)).rejects.toThrow()
|
|
457
|
+
})
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
describe('broadcast()', () => {
|
|
461
|
+
let broadcasterId: string
|
|
462
|
+
let receiverIds: string[]
|
|
463
|
+
|
|
464
|
+
beforeEach(async () => {
|
|
465
|
+
const broadcaster = await core.spawn({ name: 'broadcaster' })
|
|
466
|
+
broadcasterId = broadcaster.id
|
|
467
|
+
receiverIds = []
|
|
468
|
+
for (let i = 0; i < 3; i++) {
|
|
469
|
+
const receiver = await core.spawn({ name: `receiver-${i}` })
|
|
470
|
+
receiverIds.push(receiver.id)
|
|
471
|
+
}
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
it('sends message to multiple workers', async () => {
|
|
475
|
+
const results = await core.broadcast(broadcasterId, receiverIds, 'announcement', {
|
|
476
|
+
text: 'hello all',
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
expect(results).toHaveLength(3)
|
|
480
|
+
expect(results.every((r) => r.success)).toBe(true)
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
it('each receiver gets the message', async () => {
|
|
484
|
+
await core.broadcast(broadcasterId, receiverIds, 'broadcast', { data: 'test' })
|
|
485
|
+
|
|
486
|
+
for (const receiverId of receiverIds) {
|
|
487
|
+
const messages = await core.receive(receiverId)
|
|
488
|
+
expect(messages).toHaveLength(1)
|
|
489
|
+
expect(messages[0].type).toBe('broadcast')
|
|
490
|
+
}
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
it('handles partial failures gracefully', async () => {
|
|
494
|
+
await core.terminate(receiverIds[1]) // Terminate one receiver
|
|
495
|
+
|
|
496
|
+
const results = await core.broadcast(broadcasterId, receiverIds, 'test', {})
|
|
497
|
+
|
|
498
|
+
expect(results[0].success).toBe(true)
|
|
499
|
+
expect(results[1].success).toBe(false)
|
|
500
|
+
expect(results[2].success).toBe(true)
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
it('returns empty array for empty receiver list', async () => {
|
|
504
|
+
const results = await core.broadcast(broadcasterId, [], 'test', {})
|
|
505
|
+
|
|
506
|
+
expect(results).toEqual([])
|
|
507
|
+
})
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
// ===========================================================================
|
|
511
|
+
// Worker State Management
|
|
512
|
+
// ===========================================================================
|
|
513
|
+
|
|
514
|
+
describe('getState()', () => {
|
|
515
|
+
let workerId: string
|
|
516
|
+
|
|
517
|
+
beforeEach(async () => {
|
|
518
|
+
const worker = await core.spawn({
|
|
519
|
+
name: 'stateful-worker',
|
|
520
|
+
tier: 'agentic',
|
|
521
|
+
metadata: { region: 'us-west' },
|
|
522
|
+
})
|
|
523
|
+
workerId = worker.id
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
it('retrieves full worker state', async () => {
|
|
527
|
+
const state = await core.getState(workerId)
|
|
528
|
+
|
|
529
|
+
expect(state).toBeDefined()
|
|
530
|
+
expect(state?.id).toBe(workerId)
|
|
531
|
+
expect(state?.name).toBe('stateful-worker')
|
|
532
|
+
expect(state?.status).toBe('running')
|
|
533
|
+
expect(state?.tier).toBe('agentic')
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
it('returns null for non-existent worker', async () => {
|
|
537
|
+
const state = await core.getState('nonexistent-id')
|
|
538
|
+
|
|
539
|
+
expect(state).toBeNull()
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
it('includes metadata in state', async () => {
|
|
543
|
+
const state = await core.getState(workerId)
|
|
544
|
+
|
|
545
|
+
expect(state?.metadata).toEqual({ region: 'us-west' })
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
it('includes timestamps', async () => {
|
|
549
|
+
const state = await core.getState(workerId)
|
|
550
|
+
|
|
551
|
+
expect(state?.createdAt).toBeInstanceOf(Date)
|
|
552
|
+
expect(state?.updatedAt).toBeInstanceOf(Date)
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
it('returns terminated state for terminated worker', async () => {
|
|
556
|
+
await core.terminate(workerId)
|
|
557
|
+
const state = await core.getState(workerId)
|
|
558
|
+
|
|
559
|
+
expect(state?.status).toBe('terminated')
|
|
560
|
+
})
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
describe('setState()', () => {
|
|
564
|
+
let workerId: string
|
|
565
|
+
|
|
566
|
+
beforeEach(async () => {
|
|
567
|
+
const worker = await core.spawn({ name: 'updatable-worker' })
|
|
568
|
+
workerId = worker.id
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
it('updates worker metadata', async () => {
|
|
572
|
+
await core.setState(workerId, { metadata: { updated: true, count: 42 } })
|
|
573
|
+
const state = await core.getState(workerId)
|
|
574
|
+
|
|
575
|
+
expect(state?.metadata).toEqual({ updated: true, count: 42 })
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
it('merges metadata by default', async () => {
|
|
579
|
+
await core.spawn({
|
|
580
|
+
name: 'merge-test',
|
|
581
|
+
metadata: { a: 1, b: 2 },
|
|
582
|
+
})
|
|
583
|
+
const worker = await core.spawn({ name: 'merge-worker', metadata: { a: 1, b: 2 } })
|
|
584
|
+
|
|
585
|
+
await core.setState(worker.id, { metadata: { b: 20, c: 3 } })
|
|
586
|
+
const state = await core.getState(worker.id)
|
|
587
|
+
|
|
588
|
+
expect(state?.metadata).toEqual({ a: 1, b: 20, c: 3 })
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
it('updates the updatedAt timestamp', async () => {
|
|
592
|
+
const before = await core.getState(workerId)
|
|
593
|
+
await new Promise((resolve) => setTimeout(resolve, 10)) // Small delay
|
|
594
|
+
await core.setState(workerId, { metadata: { test: true } })
|
|
595
|
+
const after = await core.getState(workerId)
|
|
596
|
+
|
|
597
|
+
expect(after?.updatedAt.getTime()).toBeGreaterThan(before!.updatedAt.getTime())
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
it('throws for non-existent worker', async () => {
|
|
601
|
+
await expect(core.setState('nonexistent', { metadata: {} })).rejects.toThrow()
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
it('cannot update terminated worker', async () => {
|
|
605
|
+
await core.terminate(workerId)
|
|
606
|
+
|
|
607
|
+
await expect(core.setState(workerId, { metadata: { test: true } })).rejects.toThrow()
|
|
608
|
+
})
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
describe('list()', () => {
|
|
612
|
+
beforeEach(async () => {
|
|
613
|
+
await core.spawn({ name: 'worker-1', type: 'agent', tier: 'code' })
|
|
614
|
+
await core.spawn({ name: 'worker-2', type: 'agent', tier: 'generative' })
|
|
615
|
+
await core.spawn({ name: 'worker-3', type: 'human' })
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
it('lists all workers', async () => {
|
|
619
|
+
const workers = await core.list()
|
|
620
|
+
|
|
621
|
+
expect(workers.length).toBeGreaterThanOrEqual(3)
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
it('filters by status', async () => {
|
|
625
|
+
const worker = await core.spawn({ name: 'paused-worker' })
|
|
626
|
+
await core.pause(worker.id)
|
|
627
|
+
|
|
628
|
+
const pausedWorkers = await core.list({ status: 'paused' })
|
|
629
|
+
|
|
630
|
+
expect(pausedWorkers.length).toBeGreaterThanOrEqual(1)
|
|
631
|
+
expect(pausedWorkers.every((w) => w.status === 'paused')).toBe(true)
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
it('filters by type', async () => {
|
|
635
|
+
const humanWorkers = await core.list({ type: 'human' })
|
|
636
|
+
|
|
637
|
+
expect(humanWorkers.length).toBeGreaterThanOrEqual(1)
|
|
638
|
+
expect(humanWorkers.every((w) => w.type === 'human')).toBe(true)
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
it('filters by tier', async () => {
|
|
642
|
+
const generativeWorkers = await core.list({ tier: 'generative' })
|
|
643
|
+
|
|
644
|
+
expect(generativeWorkers.length).toBeGreaterThanOrEqual(1)
|
|
645
|
+
expect(generativeWorkers.every((w) => w.tier === 'generative')).toBe(true)
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
it('supports limit option', async () => {
|
|
649
|
+
const workers = await core.list({ limit: 2 })
|
|
650
|
+
|
|
651
|
+
expect(workers).toHaveLength(2)
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
it('excludes terminated workers by default', async () => {
|
|
655
|
+
const worker = await core.spawn({ name: 'to-terminate' })
|
|
656
|
+
await core.terminate(worker.id)
|
|
657
|
+
|
|
658
|
+
const workers = await core.list()
|
|
659
|
+
const terminated = workers.find((w) => w.id === worker.id)
|
|
660
|
+
|
|
661
|
+
expect(terminated).toBeUndefined()
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
it('includes terminated workers when requested', async () => {
|
|
665
|
+
const worker = await core.spawn({ name: 'to-terminate' })
|
|
666
|
+
await core.terminate(worker.id)
|
|
667
|
+
|
|
668
|
+
const workers = await core.list({ includeTerminated: true })
|
|
669
|
+
const terminated = workers.find((w) => w.id === worker.id)
|
|
670
|
+
|
|
671
|
+
expect(terminated).toBeDefined()
|
|
672
|
+
expect(terminated?.status).toBe('terminated')
|
|
673
|
+
})
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
// ===========================================================================
|
|
677
|
+
// Worker Coordination Patterns
|
|
678
|
+
// ===========================================================================
|
|
679
|
+
|
|
680
|
+
describe('fanOut()', () => {
|
|
681
|
+
let coordinatorId: string
|
|
682
|
+
let workerIds: string[]
|
|
683
|
+
|
|
684
|
+
beforeEach(async () => {
|
|
685
|
+
const coordinator = await core.spawn({ name: 'coordinator' })
|
|
686
|
+
coordinatorId = coordinator.id
|
|
687
|
+
workerIds = []
|
|
688
|
+
for (let i = 0; i < 3; i++) {
|
|
689
|
+
const worker = await core.spawn({ name: `worker-${i}` })
|
|
690
|
+
workerIds.push(worker.id)
|
|
691
|
+
}
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
it('distributes work to multiple workers', async () => {
|
|
695
|
+
const task = await core.fanOut(coordinatorId, workerIds, 'process', {
|
|
696
|
+
data: [1, 2, 3],
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
expect(task).toBeDefined()
|
|
700
|
+
expect(task.id).toBeDefined()
|
|
701
|
+
expect(task.type).toBe('fanout')
|
|
702
|
+
expect(task.workers).toEqual(workerIds)
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
it('tracks task status', async () => {
|
|
706
|
+
const task = await core.fanOut(coordinatorId, workerIds, 'process', {})
|
|
707
|
+
|
|
708
|
+
expect(['pending', 'running']).toContain(task.status)
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
it('sends message to each worker', async () => {
|
|
712
|
+
await core.fanOut(coordinatorId, workerIds, 'fanout-task', { job: 'test' })
|
|
713
|
+
|
|
714
|
+
for (const workerId of workerIds) {
|
|
715
|
+
const messages = await core.receive(workerId)
|
|
716
|
+
expect(messages.length).toBeGreaterThanOrEqual(1)
|
|
717
|
+
expect(messages.some((m) => m.type === 'fanout-task')).toBe(true)
|
|
718
|
+
}
|
|
719
|
+
})
|
|
720
|
+
|
|
721
|
+
it('handles empty worker list', async () => {
|
|
722
|
+
await expect(core.fanOut(coordinatorId, [], 'test', {})).rejects.toThrow()
|
|
723
|
+
})
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
describe('pipeline()', () => {
|
|
727
|
+
let workerIds: string[]
|
|
728
|
+
|
|
729
|
+
beforeEach(async () => {
|
|
730
|
+
workerIds = []
|
|
731
|
+
for (let i = 0; i < 3; i++) {
|
|
732
|
+
const worker = await core.spawn({ name: `stage-${i}` })
|
|
733
|
+
workerIds.push(worker.id)
|
|
734
|
+
}
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
it('creates a sequential processing pipeline', async () => {
|
|
738
|
+
const task = await core.pipeline(workerIds, 'transform', { input: 'start' })
|
|
739
|
+
|
|
740
|
+
expect(task).toBeDefined()
|
|
741
|
+
expect(task.type).toBe('pipeline')
|
|
742
|
+
expect(task.workers).toEqual(workerIds)
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
it('sends initial data to first worker', async () => {
|
|
746
|
+
await core.pipeline(workerIds, 'pipeline-start', { data: 'initial' })
|
|
747
|
+
|
|
748
|
+
const messages = await core.receive(workerIds[0])
|
|
749
|
+
expect(messages.length).toBeGreaterThanOrEqual(1)
|
|
750
|
+
expect(messages.some((m) => m.type === 'pipeline-start')).toBe(true)
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
it('requires at least one worker', async () => {
|
|
754
|
+
await expect(core.pipeline([], 'test', {})).rejects.toThrow()
|
|
755
|
+
})
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
describe('race()', () => {
|
|
759
|
+
let workerIds: string[]
|
|
760
|
+
|
|
761
|
+
beforeEach(async () => {
|
|
762
|
+
workerIds = []
|
|
763
|
+
for (let i = 0; i < 3; i++) {
|
|
764
|
+
const worker = await core.spawn({ name: `racer-${i}` })
|
|
765
|
+
workerIds.push(worker.id)
|
|
766
|
+
}
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
it('creates a race task (first to complete wins)', async () => {
|
|
770
|
+
const task = await core.race(workerIds, 'compete', { query: 'test' })
|
|
771
|
+
|
|
772
|
+
expect(task).toBeDefined()
|
|
773
|
+
expect(task.type).toBe('race')
|
|
774
|
+
expect(task.workers).toEqual(workerIds)
|
|
775
|
+
})
|
|
776
|
+
|
|
777
|
+
it('sends same task to all workers', async () => {
|
|
778
|
+
await core.race(workerIds, 'race-task', { challenge: 'solve' })
|
|
779
|
+
|
|
780
|
+
for (const workerId of workerIds) {
|
|
781
|
+
const messages = await core.receive(workerId)
|
|
782
|
+
expect(messages.length).toBeGreaterThanOrEqual(1)
|
|
783
|
+
expect(messages.some((m) => m.type === 'race-task')).toBe(true)
|
|
784
|
+
}
|
|
785
|
+
})
|
|
786
|
+
|
|
787
|
+
it('requires at least one worker', async () => {
|
|
788
|
+
await expect(core.race([], 'test', {})).rejects.toThrow()
|
|
789
|
+
})
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
describe('consensus()', () => {
|
|
793
|
+
let workerIds: string[]
|
|
794
|
+
|
|
795
|
+
beforeEach(async () => {
|
|
796
|
+
workerIds = []
|
|
797
|
+
for (let i = 0; i < 3; i++) {
|
|
798
|
+
const worker = await core.spawn({ name: `voter-${i}` })
|
|
799
|
+
workerIds.push(worker.id)
|
|
800
|
+
}
|
|
801
|
+
})
|
|
802
|
+
|
|
803
|
+
it('creates a consensus task (all must agree)', async () => {
|
|
804
|
+
const task = await core.consensus(workerIds, 'vote', { proposal: 'approve' })
|
|
805
|
+
|
|
806
|
+
expect(task).toBeDefined()
|
|
807
|
+
expect(task.type).toBe('consensus')
|
|
808
|
+
expect(task.workers).toEqual(workerIds)
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
it('sends proposal to all workers', async () => {
|
|
812
|
+
await core.consensus(workerIds, 'consensus-vote', { decision: 'yes/no' })
|
|
813
|
+
|
|
814
|
+
for (const workerId of workerIds) {
|
|
815
|
+
const messages = await core.receive(workerId)
|
|
816
|
+
expect(messages.length).toBeGreaterThanOrEqual(1)
|
|
817
|
+
expect(messages.some((m) => m.type === 'consensus-vote')).toBe(true)
|
|
818
|
+
}
|
|
819
|
+
})
|
|
820
|
+
|
|
821
|
+
it('requires at least one worker', async () => {
|
|
822
|
+
await expect(core.consensus([], 'test', {})).rejects.toThrow()
|
|
823
|
+
})
|
|
824
|
+
|
|
825
|
+
it('supports quorum threshold option', async () => {
|
|
826
|
+
const task = await core.consensus(workerIds, 'vote', { proposal: 'test' }, { quorum: 2 })
|
|
827
|
+
|
|
828
|
+
expect(task).toBeDefined()
|
|
829
|
+
// Quorum of 2 out of 3 workers
|
|
830
|
+
})
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
describe('getTaskStatus()', () => {
|
|
834
|
+
let workerId: string
|
|
835
|
+
let taskId: string
|
|
836
|
+
|
|
837
|
+
beforeEach(async () => {
|
|
838
|
+
const worker = await core.spawn({ name: 'task-worker' })
|
|
839
|
+
workerId = worker.id
|
|
840
|
+
const task = await core.fanOut(workerId, [workerId], 'test', {})
|
|
841
|
+
taskId = task.id
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
it('retrieves coordination task status', async () => {
|
|
845
|
+
const status = await core.getTaskStatus(taskId)
|
|
846
|
+
|
|
847
|
+
expect(status).toBeDefined()
|
|
848
|
+
expect(status?.id).toBe(taskId)
|
|
849
|
+
expect(status?.type).toBe('fanout')
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
it('returns null for non-existent task', async () => {
|
|
853
|
+
const status = await core.getTaskStatus('nonexistent-task')
|
|
854
|
+
|
|
855
|
+
expect(status).toBeNull()
|
|
856
|
+
})
|
|
857
|
+
})
|
|
858
|
+
})
|
|
859
|
+
|
|
860
|
+
// =============================================================================
|
|
861
|
+
// DigitalWorkersService (WorkerEntrypoint) Tests
|
|
862
|
+
// =============================================================================
|
|
863
|
+
|
|
864
|
+
describe('DigitalWorkersService (WorkerEntrypoint)', () => {
|
|
865
|
+
it('exports DigitalWorkersService class', () => {
|
|
866
|
+
expect(DigitalWorkersService).toBeDefined()
|
|
867
|
+
expect(typeof DigitalWorkersService).toBe('function')
|
|
868
|
+
})
|
|
869
|
+
|
|
870
|
+
it('DigitalWorkersService has connect method in prototype', () => {
|
|
871
|
+
expect(typeof DigitalWorkersService.prototype.connect).toBe('function')
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
describe('connect()', () => {
|
|
875
|
+
it('returns a DigitalWorkersServiceCore instance', () => {
|
|
876
|
+
const service = new DigitalWorkersService({ env } as any, {} as any)
|
|
877
|
+
const core = service.connect()
|
|
878
|
+
|
|
879
|
+
expect(core).toBeInstanceOf(DigitalWorkersServiceCore)
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
it('returns RpcTarget that can be used over RPC', () => {
|
|
883
|
+
const service = new DigitalWorkersService({ env } as any, {} as any)
|
|
884
|
+
const core = service.connect()
|
|
885
|
+
|
|
886
|
+
// RpcTarget instances should have these characteristics
|
|
887
|
+
expect(core).toBeDefined()
|
|
888
|
+
expect(typeof core.spawn).toBe('function')
|
|
889
|
+
expect(typeof core.terminate).toBe('function')
|
|
890
|
+
expect(typeof core.pause).toBe('function')
|
|
891
|
+
expect(typeof core.resume).toBe('function')
|
|
892
|
+
expect(typeof core.send).toBe('function')
|
|
893
|
+
expect(typeof core.receive).toBe('function')
|
|
894
|
+
expect(typeof core.getState).toBe('function')
|
|
895
|
+
expect(typeof core.list).toBe('function')
|
|
896
|
+
})
|
|
897
|
+
})
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
// =============================================================================
|
|
901
|
+
// Default Export Tests
|
|
902
|
+
// =============================================================================
|
|
903
|
+
|
|
904
|
+
describe('Default export', () => {
|
|
905
|
+
it('exports DigitalWorkersService as default', async () => {
|
|
906
|
+
const { default: DefaultExport } = await import('../src/worker.js')
|
|
907
|
+
expect(DefaultExport).toBe(DigitalWorkersService)
|
|
908
|
+
})
|
|
909
|
+
})
|
|
910
|
+
|
|
911
|
+
// =============================================================================
|
|
912
|
+
// Real Bindings Integration Tests
|
|
913
|
+
// =============================================================================
|
|
914
|
+
|
|
915
|
+
describe('Real AI Gateway integration', () => {
|
|
916
|
+
let core: InstanceType<typeof DigitalWorkersServiceCore>
|
|
917
|
+
|
|
918
|
+
beforeEach(() => {
|
|
919
|
+
core = new DigitalWorkersServiceCore(env)
|
|
920
|
+
})
|
|
921
|
+
|
|
922
|
+
it('can access AI Gateway through env binding', () => {
|
|
923
|
+
// env.AI should be available in workers environment
|
|
924
|
+
expect(env).toBeDefined()
|
|
925
|
+
})
|
|
926
|
+
|
|
927
|
+
it('worker operations use real Durable Objects', async () => {
|
|
928
|
+
// This tests real DO persistence
|
|
929
|
+
const worker = await core.spawn({ name: 'persistent-worker' })
|
|
930
|
+
const retrieved = await core.getState(worker.id)
|
|
931
|
+
|
|
932
|
+
expect(retrieved).toBeDefined()
|
|
933
|
+
expect(retrieved?.id).toBe(worker.id)
|
|
934
|
+
})
|
|
935
|
+
})
|
|
936
|
+
|
|
937
|
+
describe('State Persistence', () => {
|
|
938
|
+
it('worker state persists across service instances', async () => {
|
|
939
|
+
// First service instance - create worker
|
|
940
|
+
const core1 = new DigitalWorkersServiceCore(env)
|
|
941
|
+
const worker = await core1.spawn({
|
|
942
|
+
name: 'persistent',
|
|
943
|
+
metadata: { key: 'value' },
|
|
944
|
+
})
|
|
945
|
+
const workerId = worker.id
|
|
946
|
+
|
|
947
|
+
// Second service instance - verify persistence
|
|
948
|
+
const core2 = new DigitalWorkersServiceCore(env)
|
|
949
|
+
const retrieved = await core2.getState(workerId)
|
|
950
|
+
|
|
951
|
+
expect(retrieved).not.toBeNull()
|
|
952
|
+
expect(retrieved?.name).toBe('persistent')
|
|
953
|
+
expect(retrieved?.metadata).toEqual({ key: 'value' })
|
|
954
|
+
})
|
|
955
|
+
|
|
956
|
+
it('messages persist and can be received after reconnection', async () => {
|
|
957
|
+
const core1 = new DigitalWorkersServiceCore(env)
|
|
958
|
+
const sender = await core1.spawn({ name: 'sender' })
|
|
959
|
+
const receiver = await core1.spawn({ name: 'receiver' })
|
|
960
|
+
|
|
961
|
+
// Send message
|
|
962
|
+
await core1.send(sender.id, receiver.id, 'persistent-msg', { data: 'test' })
|
|
963
|
+
|
|
964
|
+
// New service instance - receive message
|
|
965
|
+
const core2 = new DigitalWorkersServiceCore(env)
|
|
966
|
+
const messages = await core2.receive(receiver.id)
|
|
967
|
+
|
|
968
|
+
expect(messages.length).toBeGreaterThanOrEqual(1)
|
|
969
|
+
expect(messages.some((m) => m.type === 'persistent-msg')).toBe(true)
|
|
970
|
+
})
|
|
971
|
+
})
|
|
972
|
+
|
|
973
|
+
describe('Concurrent Operations', () => {
|
|
974
|
+
let core: InstanceType<typeof DigitalWorkersServiceCore>
|
|
975
|
+
|
|
976
|
+
beforeEach(() => {
|
|
977
|
+
core = new DigitalWorkersServiceCore(env)
|
|
978
|
+
})
|
|
979
|
+
|
|
980
|
+
it('handles concurrent worker spawning', async () => {
|
|
981
|
+
const promises = []
|
|
982
|
+
for (let i = 0; i < 5; i++) {
|
|
983
|
+
promises.push(core.spawn({ name: `concurrent-${i}` }))
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const workers = await Promise.all(promises)
|
|
987
|
+
|
|
988
|
+
expect(workers).toHaveLength(5)
|
|
989
|
+
// All IDs should be unique
|
|
990
|
+
const ids = workers.map((w) => w.id)
|
|
991
|
+
expect(new Set(ids).size).toBe(5)
|
|
992
|
+
})
|
|
993
|
+
|
|
994
|
+
it('handles concurrent message sending', async () => {
|
|
995
|
+
const sender = await core.spawn({ name: 'concurrent-sender' })
|
|
996
|
+
const receiver = await core.spawn({ name: 'concurrent-receiver' })
|
|
997
|
+
|
|
998
|
+
const promises = []
|
|
999
|
+
for (let i = 0; i < 10; i++) {
|
|
1000
|
+
promises.push(core.send(sender.id, receiver.id, 'concurrent', { n: i }))
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const messages = await Promise.all(promises)
|
|
1004
|
+
|
|
1005
|
+
expect(messages).toHaveLength(10)
|
|
1006
|
+
// All message IDs should be unique
|
|
1007
|
+
const ids = messages.map((m) => m.id)
|
|
1008
|
+
expect(new Set(ids).size).toBe(10)
|
|
1009
|
+
})
|
|
1010
|
+
|
|
1011
|
+
it('handles concurrent state updates', async () => {
|
|
1012
|
+
const worker = await core.spawn({ name: 'concurrent-update' })
|
|
1013
|
+
|
|
1014
|
+
const promises = []
|
|
1015
|
+
for (let i = 0; i < 5; i++) {
|
|
1016
|
+
promises.push(core.setState(worker.id, { metadata: { update: i } }))
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// All updates should complete without error
|
|
1020
|
+
await Promise.all(promises)
|
|
1021
|
+
|
|
1022
|
+
const state = await core.getState(worker.id)
|
|
1023
|
+
expect(state?.metadata?.update).toBeDefined()
|
|
1024
|
+
})
|
|
1025
|
+
})
|
|
1026
|
+
|
|
1027
|
+
describe('Error Handling', () => {
|
|
1028
|
+
let core: InstanceType<typeof DigitalWorkersServiceCore>
|
|
1029
|
+
|
|
1030
|
+
beforeEach(() => {
|
|
1031
|
+
core = new DigitalWorkersServiceCore(env)
|
|
1032
|
+
})
|
|
1033
|
+
|
|
1034
|
+
it('provides meaningful error for invalid worker ID format', async () => {
|
|
1035
|
+
await expect(core.getState('')).rejects.toThrow()
|
|
1036
|
+
})
|
|
1037
|
+
|
|
1038
|
+
it('provides meaningful error for invalid message payload', async () => {
|
|
1039
|
+
const sender = await core.spawn({ name: 'sender' })
|
|
1040
|
+
const receiver = await core.spawn({ name: 'receiver' })
|
|
1041
|
+
|
|
1042
|
+
// Circular reference should fail
|
|
1043
|
+
const circular: any = {}
|
|
1044
|
+
circular.self = circular
|
|
1045
|
+
|
|
1046
|
+
await expect(core.send(sender.id, receiver.id, 'test', circular)).rejects.toThrow()
|
|
1047
|
+
})
|
|
1048
|
+
|
|
1049
|
+
it('handles worker in error state', async () => {
|
|
1050
|
+
const worker = await core.spawn({ name: 'error-worker' })
|
|
1051
|
+
|
|
1052
|
+
// Force error state (implementation specific)
|
|
1053
|
+
// The test verifies that error state is properly tracked
|
|
1054
|
+
const state = await core.getState(worker.id)
|
|
1055
|
+
expect(state?.status).not.toBe('error') // Fresh worker shouldn't be in error
|
|
1056
|
+
})
|
|
1057
|
+
})
|
|
1058
|
+
|
|
1059
|
+
// =============================================================================
|
|
1060
|
+
// Stateless Actions on WorkerEntrypoint (RED phase - not yet implemented)
|
|
1061
|
+
// =============================================================================
|
|
1062
|
+
//
|
|
1063
|
+
// These tests verify that DigitalWorkersServiceCore exposes stateless action
|
|
1064
|
+
// methods (notify, decide, askAI) directly on the RpcTarget. This enables
|
|
1065
|
+
// consuming workers to call these actions without managing worker lifecycle.
|
|
1066
|
+
//
|
|
1067
|
+
// Pattern:
|
|
1068
|
+
// const service = env.DIGITAL_WORKERS.connect()
|
|
1069
|
+
// const decision = await service.decide({ options: ['A', 'B'] })
|
|
1070
|
+
// await service.notify({ target: 'alice', message: 'Done' })
|
|
1071
|
+
// const answer = await service.askAI('What should we do?')
|
|
1072
|
+
//
|
|
1073
|
+
// These methods are STATELESS - they do not require spawning a worker first.
|
|
1074
|
+
// They use the AI binding from env for real AI Gateway calls.
|
|
1075
|
+
// =============================================================================
|
|
1076
|
+
|
|
1077
|
+
describe('Stateless Actions - notify()', () => {
|
|
1078
|
+
let core: InstanceType<typeof DigitalWorkersServiceCore>
|
|
1079
|
+
|
|
1080
|
+
beforeEach(() => {
|
|
1081
|
+
core = new DigitalWorkersServiceCore(env)
|
|
1082
|
+
})
|
|
1083
|
+
|
|
1084
|
+
it('exposes notify as a method on DigitalWorkersServiceCore', () => {
|
|
1085
|
+
expect(typeof core.notify).toBe('function')
|
|
1086
|
+
})
|
|
1087
|
+
|
|
1088
|
+
it('sends a notification and returns a result', async () => {
|
|
1089
|
+
const result = await core.notify({
|
|
1090
|
+
target: 'test-user',
|
|
1091
|
+
message: 'Hello from stateless action',
|
|
1092
|
+
})
|
|
1093
|
+
|
|
1094
|
+
expect(result).toBeDefined()
|
|
1095
|
+
expect(result.sent).toBe(true)
|
|
1096
|
+
expect(result.messageId).toBeDefined()
|
|
1097
|
+
expect(typeof result.messageId).toBe('string')
|
|
1098
|
+
})
|
|
1099
|
+
|
|
1100
|
+
it('includes channel information in result', async () => {
|
|
1101
|
+
const result = await core.notify({
|
|
1102
|
+
target: 'test-user',
|
|
1103
|
+
message: 'Test notification',
|
|
1104
|
+
via: 'webhook',
|
|
1105
|
+
})
|
|
1106
|
+
|
|
1107
|
+
expect(result.via).toBeDefined()
|
|
1108
|
+
expect(Array.isArray(result.via)).toBe(true)
|
|
1109
|
+
expect(result.via.length).toBeGreaterThan(0)
|
|
1110
|
+
})
|
|
1111
|
+
|
|
1112
|
+
it('supports priority levels', async () => {
|
|
1113
|
+
const result = await core.notify({
|
|
1114
|
+
target: 'test-user',
|
|
1115
|
+
message: 'Urgent: Server is down!',
|
|
1116
|
+
priority: 'urgent',
|
|
1117
|
+
})
|
|
1118
|
+
|
|
1119
|
+
expect(result.sent).toBe(true)
|
|
1120
|
+
})
|
|
1121
|
+
|
|
1122
|
+
it('supports sending to multiple targets', async () => {
|
|
1123
|
+
const result = await core.notify({
|
|
1124
|
+
target: ['user-a', 'user-b', 'user-c'],
|
|
1125
|
+
message: 'Team broadcast notification',
|
|
1126
|
+
})
|
|
1127
|
+
|
|
1128
|
+
expect(result.sent).toBe(true)
|
|
1129
|
+
expect(result.recipients).toBeDefined()
|
|
1130
|
+
expect(result.recipients?.length).toBe(3)
|
|
1131
|
+
})
|
|
1132
|
+
|
|
1133
|
+
it('includes sentAt timestamp in result', async () => {
|
|
1134
|
+
const result = await core.notify({
|
|
1135
|
+
target: 'test-user',
|
|
1136
|
+
message: 'Timestamped notification',
|
|
1137
|
+
})
|
|
1138
|
+
|
|
1139
|
+
expect(result.sentAt).toBeInstanceOf(Date)
|
|
1140
|
+
})
|
|
1141
|
+
|
|
1142
|
+
it('returns sent:false when target is unreachable', async () => {
|
|
1143
|
+
const result = await core.notify({
|
|
1144
|
+
target: { id: 'nonexistent', contacts: {} },
|
|
1145
|
+
message: 'Should fail gracefully',
|
|
1146
|
+
via: 'slack',
|
|
1147
|
+
})
|
|
1148
|
+
|
|
1149
|
+
expect(result.sent).toBe(false)
|
|
1150
|
+
expect(result.via).toHaveLength(0)
|
|
1151
|
+
})
|
|
1152
|
+
|
|
1153
|
+
it('supports metadata in notification', async () => {
|
|
1154
|
+
const result = await core.notify({
|
|
1155
|
+
target: 'test-user',
|
|
1156
|
+
message: 'Deployment complete',
|
|
1157
|
+
metadata: { version: '2.1.0', environment: 'production' },
|
|
1158
|
+
})
|
|
1159
|
+
|
|
1160
|
+
expect(result.sent).toBe(true)
|
|
1161
|
+
})
|
|
1162
|
+
})
|
|
1163
|
+
|
|
1164
|
+
describe('Stateless Actions - decide()', () => {
|
|
1165
|
+
let core: InstanceType<typeof DigitalWorkersServiceCore>
|
|
1166
|
+
|
|
1167
|
+
beforeEach(() => {
|
|
1168
|
+
core = new DigitalWorkersServiceCore(env)
|
|
1169
|
+
})
|
|
1170
|
+
|
|
1171
|
+
it('exposes decide as a method on DigitalWorkersServiceCore', () => {
|
|
1172
|
+
expect(typeof core.decide).toBe('function')
|
|
1173
|
+
})
|
|
1174
|
+
|
|
1175
|
+
it('makes a decision between options using real AI Gateway', async () => {
|
|
1176
|
+
const decision = await core.decide({
|
|
1177
|
+
options: ['Option A', 'Option B'],
|
|
1178
|
+
context: 'Choose the better option for testing',
|
|
1179
|
+
})
|
|
1180
|
+
|
|
1181
|
+
expect(decision).toBeDefined()
|
|
1182
|
+
expect(decision.choice).toBeDefined()
|
|
1183
|
+
expect(['Option A', 'Option B']).toContain(decision.choice)
|
|
1184
|
+
})
|
|
1185
|
+
|
|
1186
|
+
it('returns reasoning with the decision', async () => {
|
|
1187
|
+
const decision = await core.decide({
|
|
1188
|
+
options: ['Deploy now', 'Wait until Monday'],
|
|
1189
|
+
context: 'Production deploy on a Friday afternoon',
|
|
1190
|
+
})
|
|
1191
|
+
|
|
1192
|
+
expect(decision.reasoning).toBeDefined()
|
|
1193
|
+
expect(typeof decision.reasoning).toBe('string')
|
|
1194
|
+
expect(decision.reasoning.length).toBeGreaterThan(0)
|
|
1195
|
+
})
|
|
1196
|
+
|
|
1197
|
+
it('returns confidence score between 0 and 1', async () => {
|
|
1198
|
+
const decision = await core.decide({
|
|
1199
|
+
options: ['React', 'Vue', 'Svelte'],
|
|
1200
|
+
context: 'Choose a frontend framework for a new project',
|
|
1201
|
+
criteria: ['performance', 'ecosystem', 'developer experience'],
|
|
1202
|
+
})
|
|
1203
|
+
|
|
1204
|
+
expect(decision.confidence).toBeDefined()
|
|
1205
|
+
expect(typeof decision.confidence).toBe('number')
|
|
1206
|
+
expect(decision.confidence).toBeGreaterThanOrEqual(0)
|
|
1207
|
+
expect(decision.confidence).toBeLessThanOrEqual(1)
|
|
1208
|
+
})
|
|
1209
|
+
|
|
1210
|
+
it('supports criteria for multi-criteria evaluation', async () => {
|
|
1211
|
+
const decision = await core.decide({
|
|
1212
|
+
options: ['Approach A', 'Approach B', 'Approach C'],
|
|
1213
|
+
context: 'Technical architecture decision',
|
|
1214
|
+
criteria: ['scalability', 'cost', 'time-to-market'],
|
|
1215
|
+
})
|
|
1216
|
+
|
|
1217
|
+
expect(decision.choice).toBeDefined()
|
|
1218
|
+
// Should be one of the provided options
|
|
1219
|
+
expect(['Approach A', 'Approach B', 'Approach C']).toContain(decision.choice)
|
|
1220
|
+
})
|
|
1221
|
+
|
|
1222
|
+
it('returns alternatives with scores', async () => {
|
|
1223
|
+
const decision = await core.decide({
|
|
1224
|
+
options: ['A', 'B', 'C'],
|
|
1225
|
+
context: 'Simple ranking test',
|
|
1226
|
+
})
|
|
1227
|
+
|
|
1228
|
+
expect(decision.alternatives).toBeDefined()
|
|
1229
|
+
expect(Array.isArray(decision.alternatives)).toBe(true)
|
|
1230
|
+
// Each alternative should have an option and a score
|
|
1231
|
+
if (decision.alternatives && decision.alternatives.length > 0) {
|
|
1232
|
+
for (const alt of decision.alternatives) {
|
|
1233
|
+
expect(alt.option).toBeDefined()
|
|
1234
|
+
expect(typeof alt.score).toBe('number')
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
})
|
|
1238
|
+
|
|
1239
|
+
it('handles structured options (objects)', async () => {
|
|
1240
|
+
const decision = await core.decide({
|
|
1241
|
+
options: [
|
|
1242
|
+
{ id: 'migrate', label: 'Migrate to new platform' },
|
|
1243
|
+
{ id: 'refactor', label: 'Refactor existing system' },
|
|
1244
|
+
{ id: 'rebuild', label: 'Rebuild from scratch' },
|
|
1245
|
+
],
|
|
1246
|
+
context: 'Legacy system modernization decision',
|
|
1247
|
+
})
|
|
1248
|
+
|
|
1249
|
+
expect(decision.choice).toBeDefined()
|
|
1250
|
+
})
|
|
1251
|
+
|
|
1252
|
+
it('requires at least two options', async () => {
|
|
1253
|
+
await expect(
|
|
1254
|
+
core.decide({
|
|
1255
|
+
options: ['Only one option'],
|
|
1256
|
+
context: 'Not enough options',
|
|
1257
|
+
})
|
|
1258
|
+
).rejects.toThrow()
|
|
1259
|
+
})
|
|
1260
|
+
})
|
|
1261
|
+
|
|
1262
|
+
describe('Stateless Actions - askAI()', () => {
|
|
1263
|
+
let core: InstanceType<typeof DigitalWorkersServiceCore>
|
|
1264
|
+
|
|
1265
|
+
beforeEach(() => {
|
|
1266
|
+
core = new DigitalWorkersServiceCore(env)
|
|
1267
|
+
})
|
|
1268
|
+
|
|
1269
|
+
it('exposes askAI as a method on DigitalWorkersServiceCore', () => {
|
|
1270
|
+
expect(typeof core.askAI).toBe('function')
|
|
1271
|
+
})
|
|
1272
|
+
|
|
1273
|
+
it('answers a question using real AI Gateway', async () => {
|
|
1274
|
+
const answer = await core.askAI('What is 2 + 2?')
|
|
1275
|
+
|
|
1276
|
+
expect(answer).toBeDefined()
|
|
1277
|
+
expect(typeof answer).toBe('string')
|
|
1278
|
+
expect(answer.length).toBeGreaterThan(0)
|
|
1279
|
+
})
|
|
1280
|
+
|
|
1281
|
+
it('accepts context for informed answers', async () => {
|
|
1282
|
+
const answer = await core.askAI('What is our refund policy?', {
|
|
1283
|
+
context: {
|
|
1284
|
+
refundWindow: '30 days',
|
|
1285
|
+
conditions: 'Original packaging required',
|
|
1286
|
+
exceptions: 'Digital products non-refundable',
|
|
1287
|
+
},
|
|
1288
|
+
})
|
|
1289
|
+
|
|
1290
|
+
expect(answer).toBeDefined()
|
|
1291
|
+
expect(typeof answer).toBe('string')
|
|
1292
|
+
})
|
|
1293
|
+
|
|
1294
|
+
it('supports structured response with schema', async () => {
|
|
1295
|
+
const result = await core.askAI('List three primary colors', {
|
|
1296
|
+
schema: {
|
|
1297
|
+
colors: ['Color name as a string'],
|
|
1298
|
+
},
|
|
1299
|
+
})
|
|
1300
|
+
|
|
1301
|
+
expect(result).toBeDefined()
|
|
1302
|
+
// When schema is provided, result should match the schema shape
|
|
1303
|
+
expect(typeof result).toBe('object')
|
|
1304
|
+
const structured = result as { colors: string[] }
|
|
1305
|
+
expect(Array.isArray(structured.colors)).toBe(true)
|
|
1306
|
+
expect(structured.colors.length).toBe(3)
|
|
1307
|
+
})
|
|
1308
|
+
|
|
1309
|
+
it('returns plain string when no schema provided', async () => {
|
|
1310
|
+
const answer = await core.askAI('Say hello')
|
|
1311
|
+
|
|
1312
|
+
expect(typeof answer).toBe('string')
|
|
1313
|
+
})
|
|
1314
|
+
|
|
1315
|
+
it('handles complex questions with context', async () => {
|
|
1316
|
+
const answer = await core.askAI('Should we scale up the database?', {
|
|
1317
|
+
context: {
|
|
1318
|
+
currentCPU: '85%',
|
|
1319
|
+
currentMemory: '70%',
|
|
1320
|
+
queryLatency: '450ms',
|
|
1321
|
+
peakHours: '9am-5pm EST',
|
|
1322
|
+
},
|
|
1323
|
+
})
|
|
1324
|
+
|
|
1325
|
+
expect(answer).toBeDefined()
|
|
1326
|
+
expect(typeof answer).toBe('string')
|
|
1327
|
+
expect(answer.length).toBeGreaterThan(0)
|
|
1328
|
+
})
|
|
1329
|
+
|
|
1330
|
+
it('handles empty question gracefully', async () => {
|
|
1331
|
+
await expect(core.askAI('')).rejects.toThrow()
|
|
1332
|
+
})
|
|
1333
|
+
})
|
|
1334
|
+
|
|
1335
|
+
describe('Stateless Actions - Job ID Pattern', () => {
|
|
1336
|
+
let core: InstanceType<typeof DigitalWorkersServiceCore>
|
|
1337
|
+
|
|
1338
|
+
beforeEach(() => {
|
|
1339
|
+
core = new DigitalWorkersServiceCore(env)
|
|
1340
|
+
})
|
|
1341
|
+
|
|
1342
|
+
it('decide returns a job ID for tracking', async () => {
|
|
1343
|
+
const decision = await core.decide({
|
|
1344
|
+
options: ['A', 'B'],
|
|
1345
|
+
context: 'Test decision for job tracking',
|
|
1346
|
+
})
|
|
1347
|
+
|
|
1348
|
+
expect(decision.jobId).toBeDefined()
|
|
1349
|
+
expect(typeof decision.jobId).toBe('string')
|
|
1350
|
+
expect(decision.jobId.length).toBeGreaterThan(0)
|
|
1351
|
+
})
|
|
1352
|
+
|
|
1353
|
+
it('askAI returns a job ID when called with tracking option', async () => {
|
|
1354
|
+
const result = await core.askAI('Test question', { track: true })
|
|
1355
|
+
|
|
1356
|
+
// When tracking is enabled, result becomes an object with jobId
|
|
1357
|
+
expect(typeof result).toBe('object')
|
|
1358
|
+
const tracked = result as { answer: string; jobId: string }
|
|
1359
|
+
expect(tracked.jobId).toBeDefined()
|
|
1360
|
+
expect(typeof tracked.jobId).toBe('string')
|
|
1361
|
+
})
|
|
1362
|
+
|
|
1363
|
+
it('notify returns a job ID', async () => {
|
|
1364
|
+
const result = await core.notify({
|
|
1365
|
+
target: 'test-user',
|
|
1366
|
+
message: 'Job tracking test',
|
|
1367
|
+
})
|
|
1368
|
+
|
|
1369
|
+
expect(result.jobId).toBeDefined()
|
|
1370
|
+
expect(typeof result.jobId).toBe('string')
|
|
1371
|
+
})
|
|
1372
|
+
|
|
1373
|
+
it('job IDs are unique across calls', async () => {
|
|
1374
|
+
const result1 = await core.notify({ target: 'user-a', message: 'First' })
|
|
1375
|
+
const result2 = await core.notify({ target: 'user-b', message: 'Second' })
|
|
1376
|
+
|
|
1377
|
+
expect(result1.jobId).not.toBe(result2.jobId)
|
|
1378
|
+
})
|
|
1379
|
+
|
|
1380
|
+
it('job ID follows expected format pattern', async () => {
|
|
1381
|
+
const result = await core.notify({ target: 'user', message: 'Format test' })
|
|
1382
|
+
|
|
1383
|
+
// Job IDs should follow a predictable pattern (e.g., job_<uuid> or similar)
|
|
1384
|
+
expect(result.jobId).toMatch(/^job_/)
|
|
1385
|
+
})
|
|
1386
|
+
})
|
|
1387
|
+
|
|
1388
|
+
describe('Stateless Actions via connect() RPC', () => {
|
|
1389
|
+
it('connect() returns a service with stateless action methods', () => {
|
|
1390
|
+
const service = new DigitalWorkersService({ env } as any, {} as any)
|
|
1391
|
+
const core = service.connect()
|
|
1392
|
+
|
|
1393
|
+
// Verify stateless action methods exist alongside lifecycle methods
|
|
1394
|
+
expect(typeof core.notify).toBe('function')
|
|
1395
|
+
expect(typeof core.decide).toBe('function')
|
|
1396
|
+
expect(typeof core.askAI).toBe('function')
|
|
1397
|
+
|
|
1398
|
+
// Lifecycle methods should still exist
|
|
1399
|
+
expect(typeof core.spawn).toBe('function')
|
|
1400
|
+
expect(typeof core.terminate).toBe('function')
|
|
1401
|
+
expect(typeof core.send).toBe('function')
|
|
1402
|
+
})
|
|
1403
|
+
|
|
1404
|
+
it('stateless actions do not require spawning a worker first', async () => {
|
|
1405
|
+
const service = new DigitalWorkersService({ env } as any, {} as any)
|
|
1406
|
+
const core = service.connect()
|
|
1407
|
+
|
|
1408
|
+
// Should work without any prior spawn() calls
|
|
1409
|
+
const decision = await core.decide({
|
|
1410
|
+
options: ['Yes', 'No'],
|
|
1411
|
+
context: 'Simple yes/no test without worker lifecycle',
|
|
1412
|
+
})
|
|
1413
|
+
|
|
1414
|
+
expect(decision.choice).toBeDefined()
|
|
1415
|
+
expect(['Yes', 'No']).toContain(decision.choice)
|
|
1416
|
+
})
|
|
1417
|
+
|
|
1418
|
+
it('stateless actions are independent of worker state', async () => {
|
|
1419
|
+
const service = new DigitalWorkersService({ env } as any, {} as any)
|
|
1420
|
+
const core = service.connect()
|
|
1421
|
+
|
|
1422
|
+
// Notify should work independently
|
|
1423
|
+
const notifyResult = await core.notify({
|
|
1424
|
+
target: 'independent-user',
|
|
1425
|
+
message: 'No worker required',
|
|
1426
|
+
})
|
|
1427
|
+
expect(notifyResult.sent).toBe(true)
|
|
1428
|
+
|
|
1429
|
+
// askAI should work independently
|
|
1430
|
+
const answer = await core.askAI('Is this independent?')
|
|
1431
|
+
expect(answer).toBeDefined()
|
|
1432
|
+
})
|
|
1433
|
+
})
|