@pikku/core 0.12.94 → 0.12.95
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 +136 -0
- 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/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/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 +28 -0
- 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/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
|
@@ -192,8 +192,11 @@ describe('pikku-workflow-service run-level inline', () => {
|
|
|
192
192
|
|
|
193
193
|
describe('pikku-workflow-service per-function step dispatch', () => {
|
|
194
194
|
class TestWorkflowService extends InMemoryWorkflowService {
|
|
195
|
-
public callDispatchStep(rpcName: string) {
|
|
196
|
-
return this.dispatchStep(
|
|
195
|
+
public callDispatchStep(rpcName: string, runId = 'run-1') {
|
|
196
|
+
return this.dispatchStep(runId, 'step-1', rpcName, {})
|
|
197
|
+
}
|
|
198
|
+
public markInline(runId: string) {
|
|
199
|
+
this.registerInlineRun(runId)
|
|
197
200
|
}
|
|
198
201
|
}
|
|
199
202
|
|
|
@@ -249,6 +252,72 @@ describe('pikku-workflow-service per-function step dispatch', () => {
|
|
|
249
252
|
cleanup('queuedStep')
|
|
250
253
|
})
|
|
251
254
|
|
|
255
|
+
const setupWorkflow = (name: string) => {
|
|
256
|
+
pikkuState(null, 'workflows', 'meta')[name] = {
|
|
257
|
+
name,
|
|
258
|
+
pikkuFuncId: name,
|
|
259
|
+
graphHash: 'hash',
|
|
260
|
+
} as any
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const cleanupWorkflow = (name: string) => {
|
|
264
|
+
delete pikkuState(null, 'workflows', 'meta')[name]
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
test('a step naming a workflow queues even though it is unmarked', async () => {
|
|
268
|
+
const ws = new TestWorkflowService()
|
|
269
|
+
let queued = false
|
|
270
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
271
|
+
logger: { error() {}, info() {}, warn() {}, debug() {} },
|
|
272
|
+
queueService: {
|
|
273
|
+
add: async () => {
|
|
274
|
+
queued = true
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
} as any)
|
|
278
|
+
|
|
279
|
+
setupWorkflow('childWorkflowStep')
|
|
280
|
+
const dispatched = await ws.callDispatchStep('childWorkflowStep')
|
|
281
|
+
assert.equal(dispatched, true, 'a child workflow should dispatch')
|
|
282
|
+
assert.equal(queued, true, 'a child workflow should queue')
|
|
283
|
+
cleanupWorkflow('childWorkflowStep')
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test('a step naming a workflow stays inline when the parent run is inline', async () => {
|
|
287
|
+
const ws = new TestWorkflowService()
|
|
288
|
+
let queued = false
|
|
289
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
290
|
+
logger: { error() {}, info() {}, warn() {}, debug() {} },
|
|
291
|
+
queueService: {
|
|
292
|
+
add: async () => {
|
|
293
|
+
queued = true
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
} as any)
|
|
297
|
+
|
|
298
|
+
setupWorkflow('childOfInlineParent')
|
|
299
|
+
ws.markInline('inline-parent')
|
|
300
|
+
const dispatched = await ws.callDispatchStep(
|
|
301
|
+
'childOfInlineParent',
|
|
302
|
+
'inline-parent'
|
|
303
|
+
)
|
|
304
|
+
assert.equal(dispatched, false, 'an inline parent keeps its child inline')
|
|
305
|
+
assert.equal(queued, false, 'an inline parent queues nothing')
|
|
306
|
+
cleanupWorkflow('childOfInlineParent')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
test('a step naming a workflow stays inline when there is no queueService', async () => {
|
|
310
|
+
const ws = new TestWorkflowService()
|
|
311
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
312
|
+
logger: { error() {}, info() {}, warn() {}, debug() {} },
|
|
313
|
+
} as any)
|
|
314
|
+
|
|
315
|
+
setupWorkflow('childWorkflowNoQueue')
|
|
316
|
+
const dispatched = await ws.callDispatchStep('childWorkflowNoQueue')
|
|
317
|
+
assert.equal(dispatched, false, 'no queue leaves the child inline')
|
|
318
|
+
cleanupWorkflow('childWorkflowNoQueue')
|
|
319
|
+
})
|
|
320
|
+
|
|
252
321
|
test('workflowQueued: true step throws when no queueService is configured', async () => {
|
|
253
322
|
const ws = new TestWorkflowService()
|
|
254
323
|
pikkuState(null, 'package', 'singletonServices', {
|
|
@@ -83,6 +83,7 @@ import {
|
|
|
83
83
|
jobGroupFor,
|
|
84
84
|
orchestratorQueueName,
|
|
85
85
|
resolveWorkflowConfig,
|
|
86
|
+
stepDispatchTarget,
|
|
86
87
|
stepJobOptions,
|
|
87
88
|
stepWorkerQueueName,
|
|
88
89
|
} from './workflow-queue-routing.js'
|
|
@@ -760,19 +761,12 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
760
761
|
stepOptions?: WorkflowStepOptions,
|
|
761
762
|
fromStepName?: string
|
|
762
763
|
): Promise<boolean> {
|
|
763
|
-
const
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
const forceQueue = rpcMeta?.workflowQueued === true
|
|
768
|
-
if (!forceQueue) {
|
|
764
|
+
const target = await stepDispatchTarget(rpcName, stepName, () =>
|
|
765
|
+
this.isInline(runId)
|
|
766
|
+
)
|
|
767
|
+
if (target === 'inline') {
|
|
769
768
|
return false
|
|
770
769
|
}
|
|
771
|
-
if (!getSingletonServices()?.queueService) {
|
|
772
|
-
throw new Error(
|
|
773
|
-
`Workflow step '${stepName}' (function '${rpcName}') is marked 'workflowQueued: true' but no queue service is configured.`
|
|
774
|
-
)
|
|
775
|
-
}
|
|
776
770
|
try {
|
|
777
771
|
await getSingletonServices()!.queueService!.add(
|
|
778
772
|
this.getStepWorkerQueueName(rpcName),
|
|
@@ -87,3 +87,82 @@ describe('a child workflow inherits the parent run wire pikkuUserId', () => {
|
|
|
87
87
|
assert.ok(wire.parentRunId, 'the child must name the run that started it')
|
|
88
88
|
})
|
|
89
89
|
})
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Queuing a child workflow is only worth anything if the parent comes back with
|
|
93
|
+
* the child's output. The routing tests assert which way a step goes; this
|
|
94
|
+
* asserts what the parent is left holding once the child it queued has ended.
|
|
95
|
+
*/
|
|
96
|
+
describe('a queued child workflow hands its output back to the parent step', () => {
|
|
97
|
+
const CHILD_OUTPUT = { invoiceId: 'inv-42' }
|
|
98
|
+
|
|
99
|
+
const setup = async () => {
|
|
100
|
+
resetPikkuState()
|
|
101
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
102
|
+
logger: silentLogger,
|
|
103
|
+
queueService: { add: async () => {} },
|
|
104
|
+
} as any)
|
|
105
|
+
registerChildWorkflow('childFlow')
|
|
106
|
+
pikkuState(null, 'function', 'functions').set('childFlow', {
|
|
107
|
+
func: async () => CHILD_OUTPUT,
|
|
108
|
+
} as any)
|
|
109
|
+
|
|
110
|
+
const ws = new InMemoryWorkflowService()
|
|
111
|
+
const parentRunId = await ws.createRun('parentFlow', {}, false, '', {
|
|
112
|
+
type: 'test',
|
|
113
|
+
})
|
|
114
|
+
const step = await ws.insertStepState(
|
|
115
|
+
parentRunId,
|
|
116
|
+
'callChild',
|
|
117
|
+
'childFlow',
|
|
118
|
+
{}
|
|
119
|
+
)
|
|
120
|
+
const stepId = (step as any).stepId ?? (step as any).id
|
|
121
|
+
assert.ok(stepId, 'the parent step must have an id to hand back to')
|
|
122
|
+
return { ws, parentRunId, stepId }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const childRunIdOf = async (ws: InMemoryWorkflowService) => {
|
|
126
|
+
for (const id of (ws as any).runs?.keys?.() ?? []) {
|
|
127
|
+
const run = await ws.getRun(id)
|
|
128
|
+
if (run?.workflow === 'childFlow') return run.id
|
|
129
|
+
}
|
|
130
|
+
throw new Error('no child run was started')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
test('the parent step carries the child output once the child completes', async () => {
|
|
134
|
+
const { ws, parentRunId, stepId } = await setup()
|
|
135
|
+
|
|
136
|
+
// The step worker that picked the queued job off runs this; it starts the
|
|
137
|
+
// child and unwinds the parent rather than waiting on it.
|
|
138
|
+
await (ws as any).executeWorkflowStepInner(
|
|
139
|
+
parentRunId,
|
|
140
|
+
'callChild',
|
|
141
|
+
'childFlow',
|
|
142
|
+
{},
|
|
143
|
+
{}
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
const childRunId = await childRunIdOf(ws)
|
|
147
|
+
const childRun = await ws.getRun(childRunId)
|
|
148
|
+
assert.equal(
|
|
149
|
+
childRun?.wire?.parentStepId,
|
|
150
|
+
stepId,
|
|
151
|
+
'the child must name the step it has to hand its output back to'
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
await ws.updateRunStatus(childRunId, 'completed', CHILD_OUTPUT)
|
|
155
|
+
await (ws as any).onChildWorkflowCompleted(
|
|
156
|
+
await ws.getRun(childRunId),
|
|
157
|
+
CHILD_OUTPUT
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
const steps = await ws.getRunSteps(parentRunId)
|
|
161
|
+
const callChild = steps.find((s: any) => s.stepName === 'callChild')
|
|
162
|
+
assert.deepEqual(
|
|
163
|
+
callChild?.result,
|
|
164
|
+
CHILD_OUTPUT,
|
|
165
|
+
'the parent step must end up holding what the child returned'
|
|
166
|
+
)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
@@ -64,6 +64,50 @@ export const stepWorkerQueueName = (
|
|
|
64
64
|
resolveWorkflowConfig().stepWorkerQueueName
|
|
65
65
|
)
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* How a step reaches its worker: on the queue, or here in the orchestrator.
|
|
69
|
+
*
|
|
70
|
+
* A step naming a workflow queues whenever a queue exists, even unmarked. Run
|
|
71
|
+
* here, it holds the parent's run lock — and its lock connection — until the
|
|
72
|
+
* child ends, and marks the child inline so the child's own `sleep` degrades
|
|
73
|
+
* from a suspension into a real in-process wait. Workflows cannot opt in
|
|
74
|
+
* through `workflowQueued`: that flag is read off `rpc` meta, and `addWorkflow`
|
|
75
|
+
* never registers there.
|
|
76
|
+
*
|
|
77
|
+
* Throws only for a step that asked for the queue by name and has none, which
|
|
78
|
+
* is a deployment missing a service rather than a routing choice.
|
|
79
|
+
*
|
|
80
|
+
* `parentIsInline` is a thunk because resolving it can read the run store, and
|
|
81
|
+
* every step dispatch would pay for that — only a step that names a workflow
|
|
82
|
+
* and has a queue to reach ever asks.
|
|
83
|
+
*/
|
|
84
|
+
export const stepDispatchTarget = async (
|
|
85
|
+
rpcName: string,
|
|
86
|
+
stepName: string,
|
|
87
|
+
parentIsInline: () => Promise<boolean>
|
|
88
|
+
): Promise<'queue' | 'inline'> => {
|
|
89
|
+
const rpcFuncId = pikkuState(null, 'rpc', 'meta')[rpcName]
|
|
90
|
+
const rpcMeta =
|
|
91
|
+
typeof rpcFuncId === 'string'
|
|
92
|
+
? pikkuState(null, 'function', 'meta')[rpcFuncId]
|
|
93
|
+
: undefined
|
|
94
|
+
const hasQueue = getSingletonServices()?.queueService !== undefined
|
|
95
|
+
if (rpcMeta?.workflowQueued === true) {
|
|
96
|
+
if (!hasQueue) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Workflow step '${stepName}' (function '${rpcName}') is marked 'workflowQueued: true' but no queue service is configured.`
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
return 'queue'
|
|
102
|
+
}
|
|
103
|
+
const isWorkflow =
|
|
104
|
+
pikkuState(null, 'workflows', 'meta')[rpcName] !== undefined
|
|
105
|
+
if (!isWorkflow || !hasQueue) {
|
|
106
|
+
return 'inline'
|
|
107
|
+
}
|
|
108
|
+
return (await parentIsInline()) ? 'inline' : 'queue'
|
|
109
|
+
}
|
|
110
|
+
|
|
67
111
|
export const jobGroupFor = (
|
|
68
112
|
strategy: WorkflowQueueStrategy,
|
|
69
113
|
id?: string
|
|
@@ -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
|
+
})
|