@pikku/core 0.12.94 → 0.12.96
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/CHANGELOG.md +179 -0
- package/dist/dev/hot-reload.js +24 -4
- package/dist/dev/module-runner.d.ts +20 -3
- package/dist/dev/module-runner.js +17 -4
- package/dist/services/email-template.d.ts +43 -0
- package/dist/services/email-template.js +139 -0
- package/dist/services/http-personas.d.ts +6 -1
- package/dist/services/http-personas.js +4 -1
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/wirings/agent/agent-prepare.d.ts +14 -0
- package/dist/wirings/agent/agent-prepare.js +24 -0
- package/dist/wirings/agent/index.d.ts +1 -1
- package/dist/wirings/agent/index.js +1 -1
- package/dist/wirings/scheduler/scheduler-runner.js +0 -1
- package/dist/wirings/virtual-user/index.d.ts +1 -0
- package/dist/wirings/virtual-user/index.js +1 -0
- package/dist/wirings/virtual-user/virtual-user-derive.js +9 -0
- package/dist/wirings/virtual-user/virtual-user-scaffold.d.ts +267 -0
- package/dist/wirings/virtual-user/virtual-user-scaffold.js +400 -0
- package/dist/wirings/workflow/index.d.ts +1 -0
- package/dist/wirings/workflow/index.js +1 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +3 -9
- package/dist/wirings/workflow/scenario-prose.d.ts +23 -1
- package/dist/wirings/workflow/scenario-prose.js +12 -3
- package/dist/wirings/workflow/scenario-run.types.d.ts +7 -0
- package/dist/wirings/workflow/workflow-queue-routing.d.ts +18 -0
- package/dist/wirings/workflow/workflow-queue-routing.js +35 -0
- package/dist/wirings/workflow/workflow-status-stream.d.ts +28 -0
- package/dist/wirings/workflow/workflow-status-stream.js +105 -0
- package/package.json +1 -1
- package/src/dev/hot-reload.test.ts +42 -0
- package/src/dev/hot-reload.ts +30 -4
- package/src/dev/module-runner.test.ts +56 -13
- package/src/dev/module-runner.ts +32 -10
- package/src/public-surface.json +17 -1
- package/src/services/email-template.test.ts +311 -0
- package/src/services/email-template.ts +254 -0
- package/src/services/http-personas.ts +10 -2
- package/src/services/index.ts +8 -0
- package/src/services/persona-sign-in.test.ts +22 -0
- package/src/wirings/agent/agent-helpers.test.ts +63 -0
- package/src/wirings/agent/agent-prepare.ts +25 -0
- package/src/wirings/agent/index.ts +1 -0
- package/src/wirings/scheduler/scheduler-runner.test.ts +178 -0
- package/src/wirings/scheduler/scheduler-runner.ts +0 -1
- package/src/wirings/virtual-user/index.ts +20 -0
- package/src/wirings/virtual-user/virtual-user-derive.test.ts +33 -5
- package/src/wirings/virtual-user/virtual-user-derive.ts +9 -0
- package/src/wirings/virtual-user/virtual-user-scaffold.test.ts +795 -0
- package/src/wirings/virtual-user/virtual-user-scaffold.ts +634 -0
- package/src/wirings/workflow/index.ts +4 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +71 -2
- package/src/wirings/workflow/pikku-workflow-service.ts +5 -11
- package/src/wirings/workflow/scenario-prose.test.ts +134 -9
- package/src/wirings/workflow/scenario-prose.ts +37 -2
- package/src/wirings/workflow/scenario-run.types.ts +7 -0
- package/src/wirings/workflow/workflow-child-run-session.test.ts +79 -0
- package/src/wirings/workflow/workflow-queue-routing.ts +44 -0
- package/src/wirings/workflow/workflow-status-stream.test.ts +354 -0
- package/src/wirings/workflow/workflow-status-stream.ts +144 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { test, describe } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { streamWorkflowRunStatus } from './workflow-status-stream.js'
|
|
4
|
+
import type { WorkflowRunService } from './workflow.types.js'
|
|
5
|
+
|
|
6
|
+
const run = (over: Record<string, unknown> = {}) =>
|
|
7
|
+
({
|
|
8
|
+
id: 'run-1',
|
|
9
|
+
workflow: 'checkout',
|
|
10
|
+
status: 'running',
|
|
11
|
+
input: {},
|
|
12
|
+
wire: {},
|
|
13
|
+
createdAt: new Date(),
|
|
14
|
+
updatedAt: new Date(),
|
|
15
|
+
...over,
|
|
16
|
+
}) as any
|
|
17
|
+
|
|
18
|
+
const step = (stepName: string, status: string, over: any = {}) =>
|
|
19
|
+
({ stepId: stepName, stepName, status, attemptCount: 1, ...over }) as any
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Each poll is driven by a scripted sequence rather than a clock, so a test
|
|
23
|
+
* asserts what the stream sends without waiting for one.
|
|
24
|
+
*/
|
|
25
|
+
const harness = (
|
|
26
|
+
polls: Array<{ run: any; steps: any[] }>,
|
|
27
|
+
{ detailed = false, session = undefined as any } = {}
|
|
28
|
+
) => {
|
|
29
|
+
const sent: any[] = []
|
|
30
|
+
let closed = false
|
|
31
|
+
let index = 0
|
|
32
|
+
const workflowRunService = {
|
|
33
|
+
getRun: async () => {
|
|
34
|
+
const frame = polls[Math.min(index, polls.length - 1)]
|
|
35
|
+
return frame!.run
|
|
36
|
+
},
|
|
37
|
+
getRunSteps: async () => {
|
|
38
|
+
const frame = polls[Math.min(index, polls.length - 1)]
|
|
39
|
+
index += 1
|
|
40
|
+
return frame!.steps
|
|
41
|
+
},
|
|
42
|
+
} as unknown as WorkflowRunService
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
sent,
|
|
46
|
+
closed: () => closed,
|
|
47
|
+
stream: () =>
|
|
48
|
+
streamWorkflowRunStatus({
|
|
49
|
+
workflowRunService,
|
|
50
|
+
runId: 'run-1',
|
|
51
|
+
channel: {
|
|
52
|
+
send: async (data: any) => {
|
|
53
|
+
sent.push(data)
|
|
54
|
+
},
|
|
55
|
+
close: async () => {
|
|
56
|
+
closed = true
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
session,
|
|
60
|
+
detailed,
|
|
61
|
+
pollIntervalMs: 1,
|
|
62
|
+
}),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('streamWorkflowRunStatus', () => {
|
|
67
|
+
test('a run that is already finished never starts a timer', async () => {
|
|
68
|
+
const h = harness([
|
|
69
|
+
{ run: run({ status: 'completed' }), steps: [step('a', 'succeeded')] },
|
|
70
|
+
])
|
|
71
|
+
await h.stream()
|
|
72
|
+
assert.deepEqual(
|
|
73
|
+
h.sent.map((frame) => frame.type),
|
|
74
|
+
['update', 'done']
|
|
75
|
+
)
|
|
76
|
+
assert.equal(h.closed(), true)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('a run nobody can find closes the stream rather than hanging', async () => {
|
|
80
|
+
const h = harness([{ run: null, steps: [] }])
|
|
81
|
+
await h.stream()
|
|
82
|
+
assert.equal(h.sent.length, 0)
|
|
83
|
+
assert.equal(h.closed(), true)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// Checked on every poll, not just the first: a stream that outlives a session
|
|
87
|
+
// should stop rather than keep reporting.
|
|
88
|
+
test('a run belonging to someone else is refused', async () => {
|
|
89
|
+
const h = harness(
|
|
90
|
+
[{ run: run({ wire: { pikkuUserId: 'someone-else' } }), steps: [] }],
|
|
91
|
+
{ session: { userId: 'me' } }
|
|
92
|
+
)
|
|
93
|
+
await assert.rejects(
|
|
94
|
+
h.stream(),
|
|
95
|
+
/Not authorized to access this workflow run/
|
|
96
|
+
)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// A deterministic run knows its whole shape up front, so the client can draw
|
|
100
|
+
// every step — including the ones not started — before anything runs.
|
|
101
|
+
test('a deterministic run sends its planned shape first', async () => {
|
|
102
|
+
const h = harness([
|
|
103
|
+
{
|
|
104
|
+
run: run({
|
|
105
|
+
status: 'completed',
|
|
106
|
+
deterministic: true,
|
|
107
|
+
plannedSteps: [{ stepName: 'a' }, { stepName: 'b' }],
|
|
108
|
+
}),
|
|
109
|
+
steps: [step('a', 'succeeded')],
|
|
110
|
+
},
|
|
111
|
+
])
|
|
112
|
+
await h.stream()
|
|
113
|
+
const init = h.sent[0]
|
|
114
|
+
assert.equal(init.type, 'init')
|
|
115
|
+
assert.equal(init.deterministic, true)
|
|
116
|
+
assert.deepEqual(init.steps, [
|
|
117
|
+
{ stepName: 'a', status: 'succeeded' },
|
|
118
|
+
// Planned but not started, which is the whole reason to send this frame.
|
|
119
|
+
{ stepName: 'b', status: 'pending' },
|
|
120
|
+
])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('a dynamic run has no shape to announce, so it announces none', async () => {
|
|
124
|
+
const h = harness([
|
|
125
|
+
{ run: run({ status: 'completed' }), steps: [step('a', 'succeeded')] },
|
|
126
|
+
])
|
|
127
|
+
await h.stream()
|
|
128
|
+
assert.equal(
|
|
129
|
+
h.sent.some((frame) => frame.type === 'init'),
|
|
130
|
+
false
|
|
131
|
+
)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// A run sitting on a slow step should cost one message, not one per poll.
|
|
135
|
+
test('nothing is sent while nothing has changed', async () => {
|
|
136
|
+
const h = harness([
|
|
137
|
+
{ run: run(), steps: [step('a', 'running')] },
|
|
138
|
+
{ run: run(), steps: [step('a', 'running')] },
|
|
139
|
+
{ run: run(), steps: [step('a', 'running')] },
|
|
140
|
+
{
|
|
141
|
+
run: run({ status: 'completed' }),
|
|
142
|
+
steps: [step('a', 'succeeded')],
|
|
143
|
+
},
|
|
144
|
+
])
|
|
145
|
+
await h.stream()
|
|
146
|
+
assert.deepEqual(
|
|
147
|
+
h.sent.map((frame) => frame.type),
|
|
148
|
+
['update', 'update', 'done']
|
|
149
|
+
)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('every terminal status ends the stream', async () => {
|
|
153
|
+
for (const status of ['completed', 'failed', 'cancelled']) {
|
|
154
|
+
const h = harness([{ run: run({ status }), steps: [] }])
|
|
155
|
+
await h.stream()
|
|
156
|
+
assert.equal(h.sent.at(-1)!.type, 'done', status)
|
|
157
|
+
assert.equal(h.closed(), true, status)
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// The whole difference between the two scaffolded routes. A workflow's output
|
|
162
|
+
// and its error messages are internal detail, and a step that spawned a child
|
|
163
|
+
// run says so only to tooling that can follow it.
|
|
164
|
+
test('the user-facing stream reports progress and nothing else', async () => {
|
|
165
|
+
const h = harness([
|
|
166
|
+
{
|
|
167
|
+
run: run({
|
|
168
|
+
status: 'failed',
|
|
169
|
+
output: { card: '4242' },
|
|
170
|
+
error: { message: 'declined at acquirer', name: 'Error' },
|
|
171
|
+
}),
|
|
172
|
+
steps: [step('a', 'failed', { childRunId: 'child-1' })],
|
|
173
|
+
},
|
|
174
|
+
])
|
|
175
|
+
await h.stream()
|
|
176
|
+
const update = h.sent[0]
|
|
177
|
+
assert.equal(update.status, 'failed')
|
|
178
|
+
assert.equal('output' in update, false)
|
|
179
|
+
assert.equal('error' in update, false)
|
|
180
|
+
assert.deepEqual(update.steps, [{ stepName: 'a', status: 'failed' }])
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('the detailed stream carries what the run produced', async () => {
|
|
184
|
+
const h = harness(
|
|
185
|
+
[
|
|
186
|
+
{
|
|
187
|
+
run: run({
|
|
188
|
+
status: 'failed',
|
|
189
|
+
output: { card: '4242' },
|
|
190
|
+
error: { message: 'declined at acquirer', name: 'Error' },
|
|
191
|
+
}),
|
|
192
|
+
steps: [step('a', 'failed', { childRunId: 'child-1' })],
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
{ detailed: true }
|
|
196
|
+
)
|
|
197
|
+
await h.stream()
|
|
198
|
+
const update = h.sent[0]
|
|
199
|
+
assert.deepEqual(update.output, { card: '4242' })
|
|
200
|
+
assert.equal(update.error.message, 'declined at acquirer')
|
|
201
|
+
assert.deepEqual(update.steps, [
|
|
202
|
+
{ stepName: 'a', status: 'failed', childRunId: 'child-1' },
|
|
203
|
+
])
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
// The detailed stream compares output too, so a run whose steps are unchanged
|
|
207
|
+
// but whose output has moved on still reports it.
|
|
208
|
+
test('a change only the detailed stream can see still reaches it', async () => {
|
|
209
|
+
const frames = [
|
|
210
|
+
{ run: run({ output: { progress: 1 } }), steps: [step('a', 'running')] },
|
|
211
|
+
{ run: run({ output: { progress: 2 } }), steps: [step('a', 'running')] },
|
|
212
|
+
{
|
|
213
|
+
run: run({ status: 'completed', output: { progress: 2 } }),
|
|
214
|
+
steps: [step('a', 'succeeded')],
|
|
215
|
+
},
|
|
216
|
+
]
|
|
217
|
+
const quiet = harness(frames)
|
|
218
|
+
await quiet.stream()
|
|
219
|
+
assert.equal(
|
|
220
|
+
quiet.sent.filter((frame) => frame.type === 'update').length,
|
|
221
|
+
2
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
const loud = harness(frames, { detailed: true })
|
|
225
|
+
await loud.stream()
|
|
226
|
+
assert.equal(loud.sent.filter((frame) => frame.type === 'update').length, 3)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
// Without this the throw is an unhandled rejection from a timer callback,
|
|
230
|
+
// which takes the process with it rather than failing the request.
|
|
231
|
+
test('a poll that throws after the first stops the stream, loudly', async () => {
|
|
232
|
+
let calls = 0
|
|
233
|
+
await assert.rejects(
|
|
234
|
+
streamWorkflowRunStatus({
|
|
235
|
+
workflowRunService: {
|
|
236
|
+
getRun: async () => {
|
|
237
|
+
calls += 1
|
|
238
|
+
if (calls > 1) {
|
|
239
|
+
throw new Error('the database went away')
|
|
240
|
+
}
|
|
241
|
+
return run()
|
|
242
|
+
},
|
|
243
|
+
getRunSteps: async () => [],
|
|
244
|
+
} as unknown as WorkflowRunService,
|
|
245
|
+
runId: 'run-1',
|
|
246
|
+
channel: { send: async () => {}, close: async () => {} },
|
|
247
|
+
session: undefined,
|
|
248
|
+
pollIntervalMs: 1,
|
|
249
|
+
}),
|
|
250
|
+
/the database went away/
|
|
251
|
+
)
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* A stream that throws still has a channel open on the other end. The owner
|
|
257
|
+
* check runs on every poll precisely so a session that loses access stops the
|
|
258
|
+
* stream — which is only true if stopping also closes it.
|
|
259
|
+
*/
|
|
260
|
+
describe('streamWorkflowRunStatus closes the channel when a poll throws', () => {
|
|
261
|
+
const failingStream = (failOn: number) => {
|
|
262
|
+
let closed = false
|
|
263
|
+
let polls = 0
|
|
264
|
+
const workflowRunService = {
|
|
265
|
+
getRun: async () => {
|
|
266
|
+
polls += 1
|
|
267
|
+
if (polls >= failOn) {
|
|
268
|
+
throw new Error('run store unavailable')
|
|
269
|
+
}
|
|
270
|
+
return run()
|
|
271
|
+
},
|
|
272
|
+
getRunSteps: async () => [step('charge', 'running')],
|
|
273
|
+
} as unknown as WorkflowRunService
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
closed: () => closed,
|
|
277
|
+
stream: () =>
|
|
278
|
+
streamWorkflowRunStatus({
|
|
279
|
+
workflowRunService,
|
|
280
|
+
runId: 'run-1',
|
|
281
|
+
channel: {
|
|
282
|
+
send: async () => {},
|
|
283
|
+
close: async () => {
|
|
284
|
+
closed = true
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
session: undefined as any,
|
|
288
|
+
pollIntervalMs: 1,
|
|
289
|
+
}),
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
test('the first poll throwing closes the channel and rethrows', async () => {
|
|
294
|
+
const h = failingStream(1)
|
|
295
|
+
await assert.rejects(h.stream(), /run store unavailable/)
|
|
296
|
+
assert.equal(h.closed(), true, 'a throw must not leave the channel open')
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
test('a later poll throwing closes the channel and rethrows', async () => {
|
|
300
|
+
const h = failingStream(3)
|
|
301
|
+
await assert.rejects(h.stream(), /run store unavailable/)
|
|
302
|
+
assert.equal(h.closed(), true, 'a throw must not leave the channel open')
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The poll used to be on a fixed interval, which fires whether or not the
|
|
308
|
+
* previous one has come back. Two in flight at once both see `initSent` unset
|
|
309
|
+
* and send the init frame twice.
|
|
310
|
+
*/
|
|
311
|
+
describe('streamWorkflowRunStatus never runs two polls at once', () => {
|
|
312
|
+
test('a poll slower than the interval does not overlap the next', async () => {
|
|
313
|
+
const sent: any[] = []
|
|
314
|
+
let inFlight = 0
|
|
315
|
+
let overlapped = false
|
|
316
|
+
let polls = 0
|
|
317
|
+
|
|
318
|
+
const workflowRunService = {
|
|
319
|
+
getRun: async () => {
|
|
320
|
+
inFlight += 1
|
|
321
|
+
if (inFlight > 1) overlapped = true
|
|
322
|
+
await new Promise((r) => setTimeout(r, 15))
|
|
323
|
+
polls += 1
|
|
324
|
+
inFlight -= 1
|
|
325
|
+
return run({
|
|
326
|
+
status: polls >= 3 ? 'completed' : 'running',
|
|
327
|
+
deterministic: true,
|
|
328
|
+
plannedSteps: [{ stepName: 'charge' }],
|
|
329
|
+
})
|
|
330
|
+
},
|
|
331
|
+
getRunSteps: async () => [step('charge', 'running')],
|
|
332
|
+
} as unknown as WorkflowRunService
|
|
333
|
+
|
|
334
|
+
await streamWorkflowRunStatus({
|
|
335
|
+
workflowRunService,
|
|
336
|
+
runId: 'run-1',
|
|
337
|
+
channel: {
|
|
338
|
+
send: async (d: any) => {
|
|
339
|
+
sent.push(d)
|
|
340
|
+
},
|
|
341
|
+
close: async () => {},
|
|
342
|
+
},
|
|
343
|
+
session: undefined as any,
|
|
344
|
+
pollIntervalMs: 1,
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
assert.equal(overlapped, false, 'two polls must never be in flight together')
|
|
348
|
+
assert.equal(
|
|
349
|
+
sent.filter((f) => f.type === 'init').length,
|
|
350
|
+
1,
|
|
351
|
+
'the init frame is sent exactly once'
|
|
352
|
+
)
|
|
353
|
+
})
|
|
354
|
+
})
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { CoreUserSession } from '../../types/core.types.js'
|
|
2
|
+
import type { PikkuChannel } from '../channel/channel.types.js'
|
|
3
|
+
import { assertWorkflowRunOwner } from './workflow-run-ownership.js'
|
|
4
|
+
import type { WorkflowRunService, WorkflowStatus } from './workflow.types.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The status stream behind the scaffolded workflow SSE routes.
|
|
8
|
+
*
|
|
9
|
+
* Two routes share it, and they differ by one thing: whether the caller is
|
|
10
|
+
* trusted with what the run produced. A user-facing frontend gets step names
|
|
11
|
+
* and statuses; an admin console also gets the output, the error and the child
|
|
12
|
+
* run ids. That is a parameter, not a second copy of the loop.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const TERMINAL: ReadonlySet<string> = new Set<WorkflowStatus>([
|
|
16
|
+
'completed',
|
|
17
|
+
'failed',
|
|
18
|
+
'cancelled',
|
|
19
|
+
])
|
|
20
|
+
|
|
21
|
+
const DEFAULT_POLL_INTERVAL_MS = 500
|
|
22
|
+
|
|
23
|
+
export interface WorkflowStatusStreamParams {
|
|
24
|
+
workflowRunService: WorkflowRunService
|
|
25
|
+
runId: string
|
|
26
|
+
channel: Pick<PikkuChannel<unknown, any>, 'send' | 'close'>
|
|
27
|
+
session: CoreUserSession | undefined
|
|
28
|
+
/**
|
|
29
|
+
* Whether to include what the run produced. Off for the user-facing route:
|
|
30
|
+
* a workflow's output and its error messages are internal detail, and a step
|
|
31
|
+
* that spawned a child run says so only to tooling that can follow it.
|
|
32
|
+
*/
|
|
33
|
+
detailed?: boolean
|
|
34
|
+
pollIntervalMs?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Streams one run's progress until it reaches a terminal state.
|
|
39
|
+
*
|
|
40
|
+
* Polled rather than subscribed because a run's steps are written by whichever
|
|
41
|
+
* worker picked them up, in whichever process — there is no in-memory event to
|
|
42
|
+
* listen for that every deployment shape would deliver.
|
|
43
|
+
*
|
|
44
|
+
* Each poll sends only when something changed, compared by a hash of exactly
|
|
45
|
+
* what this stream reports. A run that sits on a slow step for a minute costs
|
|
46
|
+
* one message, not a hundred and twenty.
|
|
47
|
+
*/
|
|
48
|
+
export const streamWorkflowRunStatus = async ({
|
|
49
|
+
workflowRunService,
|
|
50
|
+
runId,
|
|
51
|
+
channel,
|
|
52
|
+
session,
|
|
53
|
+
detailed = false,
|
|
54
|
+
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
|
|
55
|
+
}: WorkflowStatusStreamParams): Promise<void> => {
|
|
56
|
+
let lastHash = ''
|
|
57
|
+
let initSent = false
|
|
58
|
+
|
|
59
|
+
const poll = async (): Promise<boolean> => {
|
|
60
|
+
const run = await workflowRunService.getRun(runId)
|
|
61
|
+
if (!run) {
|
|
62
|
+
await channel.close()
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
// Checked on every poll, not just the first: ownership is read from the run
|
|
66
|
+
// itself, and a stream that outlives a session should stop rather than keep
|
|
67
|
+
// reporting.
|
|
68
|
+
assertWorkflowRunOwner(run.wire, session)
|
|
69
|
+
|
|
70
|
+
const steps = await workflowRunService.getRunSteps(runId)
|
|
71
|
+
|
|
72
|
+
// A deterministic run knows its whole shape up front, so the client can
|
|
73
|
+
// draw every step — including the ones not started — before anything runs.
|
|
74
|
+
// A dynamic run has nothing to send here, and gets no init frame.
|
|
75
|
+
if (!initSent && run.deterministic) {
|
|
76
|
+
const statusByStep = new Map(
|
|
77
|
+
steps.map((step) => [step.stepName, step.status])
|
|
78
|
+
)
|
|
79
|
+
await channel.send({
|
|
80
|
+
type: 'init',
|
|
81
|
+
deterministic: true,
|
|
82
|
+
steps: (run.plannedSteps ?? []).map((step) => ({
|
|
83
|
+
stepName: step.stepName,
|
|
84
|
+
status: statusByStep.get(step.stepName) ?? 'pending',
|
|
85
|
+
})),
|
|
86
|
+
})
|
|
87
|
+
initSent = true
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const hash = JSON.stringify({
|
|
91
|
+
s: run.status,
|
|
92
|
+
...(detailed ? { o: run.output } : {}),
|
|
93
|
+
steps: steps.map((step) => [step.stepName, step.status]),
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
if (hash !== lastHash) {
|
|
97
|
+
lastHash = hash
|
|
98
|
+
await channel.send({
|
|
99
|
+
type: 'update',
|
|
100
|
+
status: run.status,
|
|
101
|
+
...(detailed ? { output: run.output, error: run.error } : {}),
|
|
102
|
+
steps: steps.map((step) => ({
|
|
103
|
+
stepName: step.stepName,
|
|
104
|
+
status: step.status,
|
|
105
|
+
...(detailed && step.childRunId
|
|
106
|
+
? { childRunId: step.childRunId }
|
|
107
|
+
: {}),
|
|
108
|
+
})),
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (TERMINAL.has(run.status)) {
|
|
113
|
+
await channel.send({ type: 'done' })
|
|
114
|
+
await channel.close()
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
return true
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Every exit from here closes the channel, including the ones a throw takes:
|
|
121
|
+
// `assertWorkflowRunOwner` rejecting a session that lost access is exactly
|
|
122
|
+
// the case where the stream should end rather than be left hanging open.
|
|
123
|
+
try {
|
|
124
|
+
// A run that is already finished is answered without ever starting a timer.
|
|
125
|
+
if (!(await poll())) {
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The next poll is scheduled when the previous one resolves rather than on
|
|
130
|
+
// a fixed interval. A timer that fires regardless would let two polls
|
|
131
|
+
// overlap on a slow store — both seeing `initSent` unset and sending the
|
|
132
|
+
// init frame twice, and racing `lastHash` into out-of-order updates.
|
|
133
|
+
while (
|
|
134
|
+
await new Promise<boolean>((resolve, reject) => {
|
|
135
|
+
setTimeout(() => void poll().then(resolve, reject), pollIntervalMs)
|
|
136
|
+
})
|
|
137
|
+
) {
|
|
138
|
+
// The condition is the whole loop: poll until it says to stop.
|
|
139
|
+
}
|
|
140
|
+
} catch (error) {
|
|
141
|
+
await channel.close()
|
|
142
|
+
throw error
|
|
143
|
+
}
|
|
144
|
+
}
|