@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
|
@@ -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),
|
|
@@ -63,7 +63,7 @@ describe('composeStepProse', () => {
|
|
|
63
63
|
input: { packageName: '@pikku/addon-todos' },
|
|
64
64
|
actor: 'admin',
|
|
65
65
|
}),
|
|
66
|
-
'Then
|
|
66
|
+
'Then admin sees @pikku/addon-todos'
|
|
67
67
|
)
|
|
68
68
|
})
|
|
69
69
|
|
|
@@ -75,7 +75,121 @@ describe('composeStepProse', () => {
|
|
|
75
75
|
input: { packageName: '@pikku/addon-todos' },
|
|
76
76
|
actor: 'admin',
|
|
77
77
|
}),
|
|
78
|
-
'Then
|
|
78
|
+
'Then admin sees an addon in the gallery'
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('composeStepProse with an actor role', () => {
|
|
84
|
+
test('the role renders as an apposition after the actor', () => {
|
|
85
|
+
assert.equal(
|
|
86
|
+
composeStepProse({
|
|
87
|
+
phase: 'given',
|
|
88
|
+
description: 'signs in',
|
|
89
|
+
actor: 'yasser',
|
|
90
|
+
actorRole: 'founder',
|
|
91
|
+
}),
|
|
92
|
+
'Given yasser (the founder) signs in'
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('no role renders exactly as it did before', () => {
|
|
97
|
+
assert.equal(
|
|
98
|
+
composeStepProse({
|
|
99
|
+
phase: 'given',
|
|
100
|
+
description: 'signs in',
|
|
101
|
+
actor: 'yasser',
|
|
102
|
+
}),
|
|
103
|
+
'Given yasser signs in'
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('a role without an actor has nothing to qualify, so it is dropped', () => {
|
|
108
|
+
assert.equal(
|
|
109
|
+
composeStepProse({
|
|
110
|
+
phase: 'then',
|
|
111
|
+
description: 'the receipt totals 4.50',
|
|
112
|
+
actorRole: 'founder',
|
|
113
|
+
}),
|
|
114
|
+
'Then the receipt totals 4.50'
|
|
115
|
+
)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('the role sits inside the padded sentence, not the keyword column', () => {
|
|
119
|
+
assert.equal(
|
|
120
|
+
composeStepProse({
|
|
121
|
+
phase: 'when',
|
|
122
|
+
description: 'opens /app',
|
|
123
|
+
actor: 'nadia',
|
|
124
|
+
actorRole: 'head of ops',
|
|
125
|
+
keywordWidth: 5,
|
|
126
|
+
}),
|
|
127
|
+
'When nadia (the head of ops) opens /app'
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('composeStepProse continuations', () => {
|
|
133
|
+
test('a repeated phase reads as And', () => {
|
|
134
|
+
assert.equal(
|
|
135
|
+
composeStepProse({
|
|
136
|
+
phase: 'given',
|
|
137
|
+
description: 'is invited',
|
|
138
|
+
actor: 'nadia',
|
|
139
|
+
continuesPhase: true,
|
|
140
|
+
}),
|
|
141
|
+
'And nadia is invited'
|
|
142
|
+
)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('the same actor continuing drops the repeated subject', () => {
|
|
146
|
+
assert.equal(
|
|
147
|
+
composeStepProse({
|
|
148
|
+
phase: 'when',
|
|
149
|
+
description: 'sees the audit log',
|
|
150
|
+
actor: 'yasser',
|
|
151
|
+
continuesPhase: true,
|
|
152
|
+
continuesActor: true,
|
|
153
|
+
}),
|
|
154
|
+
'And sees the audit log'
|
|
155
|
+
)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('a new actor keeps their name even under And', () => {
|
|
159
|
+
assert.equal(
|
|
160
|
+
composeStepProse({
|
|
161
|
+
phase: 'given',
|
|
162
|
+
description: 'is invited',
|
|
163
|
+
actor: 'nadia',
|
|
164
|
+
continuesPhase: true,
|
|
165
|
+
continuesActor: false,
|
|
166
|
+
}),
|
|
167
|
+
'And nadia is invited'
|
|
168
|
+
)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('the same actor across a phase change keeps the subject', () => {
|
|
172
|
+
assert.equal(
|
|
173
|
+
composeStepProse({
|
|
174
|
+
phase: 'when',
|
|
175
|
+
description: 'opens the dashboard',
|
|
176
|
+
actor: 'yasser',
|
|
177
|
+
continuesActor: true,
|
|
178
|
+
}),
|
|
179
|
+
'When yasser opens the dashboard'
|
|
180
|
+
)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('an introduction is never dropped by a continuation', () => {
|
|
184
|
+
assert.equal(
|
|
185
|
+
composeStepProse({
|
|
186
|
+
phase: 'given',
|
|
187
|
+
description: 'is invited',
|
|
188
|
+
actor: 'nadia',
|
|
189
|
+
actorRole: 'reviewer',
|
|
190
|
+
continuesPhase: true,
|
|
191
|
+
}),
|
|
192
|
+
'And nadia (the reviewer) is invited'
|
|
79
193
|
)
|
|
80
194
|
})
|
|
81
195
|
})
|
|
@@ -88,7 +202,7 @@ describe('composeStepProse basics', () => {
|
|
|
88
202
|
description: 'buys an apple',
|
|
89
203
|
actor: 'shopper',
|
|
90
204
|
}),
|
|
91
|
-
'Given
|
|
205
|
+
'Given shopper buys an apple'
|
|
92
206
|
)
|
|
93
207
|
assert.equal(
|
|
94
208
|
composeStepProse({
|
|
@@ -96,7 +210,18 @@ describe('composeStepProse basics', () => {
|
|
|
96
210
|
description: 'sees a receipt',
|
|
97
211
|
actor: 'shopper',
|
|
98
212
|
}),
|
|
99
|
-
'Then
|
|
213
|
+
'Then shopper sees a receipt'
|
|
214
|
+
)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test('a persona named after a person reads as that person', () => {
|
|
218
|
+
assert.equal(
|
|
219
|
+
composeStepProse({
|
|
220
|
+
phase: 'when',
|
|
221
|
+
description: 'opens /app',
|
|
222
|
+
actor: 'nadia',
|
|
223
|
+
}),
|
|
224
|
+
'When nadia opens /app'
|
|
100
225
|
)
|
|
101
226
|
})
|
|
102
227
|
|
|
@@ -117,7 +242,7 @@ describe('composeStepProse basics', () => {
|
|
|
117
242
|
description: 'refreshes the dashboard',
|
|
118
243
|
actor: 'admin',
|
|
119
244
|
}),
|
|
120
|
-
'When
|
|
245
|
+
'When admin refreshes the dashboard'
|
|
121
246
|
)
|
|
122
247
|
})
|
|
123
248
|
|
|
@@ -133,11 +258,11 @@ describe('composeStepProse basics', () => {
|
|
|
133
258
|
)
|
|
134
259
|
|
|
135
260
|
assert.deepEqual(rendered, [
|
|
136
|
-
'Given
|
|
137
|
-
'When
|
|
138
|
-
'Then
|
|
261
|
+
'Given shopper buys an apple',
|
|
262
|
+
'When shopper checks out',
|
|
263
|
+
'Then shopper sees a receipt',
|
|
139
264
|
])
|
|
140
|
-
const columns = new Set(rendered.map((line) => line.indexOf('
|
|
265
|
+
const columns = new Set(rendered.map((line) => line.indexOf('shopper')))
|
|
141
266
|
assert.equal(columns.size, 1, 'every sentence starts in the same column')
|
|
142
267
|
})
|
|
143
268
|
|
|
@@ -28,6 +28,9 @@ export const composeStepProse = ({
|
|
|
28
28
|
template,
|
|
29
29
|
input,
|
|
30
30
|
actor,
|
|
31
|
+
actorRole,
|
|
32
|
+
continuesPhase,
|
|
33
|
+
continuesActor,
|
|
31
34
|
keywordWidth,
|
|
32
35
|
}: {
|
|
33
36
|
phase: ScenarioStepPhase
|
|
@@ -35,10 +38,37 @@ export const composeStepProse = ({
|
|
|
35
38
|
template?: string
|
|
36
39
|
input?: unknown
|
|
37
40
|
actor?: string
|
|
41
|
+
/**
|
|
42
|
+
* What this actor is, rendered as an apposition after their key — "yasser
|
|
43
|
+
* (the founder)". Only pass it where the actor has not been named yet: an
|
|
44
|
+
* ordinary run repeats one actor for a dozen steps, and repeating the role
|
|
45
|
+
* with them turns the one piece of context into the noise around it.
|
|
46
|
+
*/
|
|
47
|
+
actorRole?: string
|
|
48
|
+
/**
|
|
49
|
+
* This step repeats the phase of the one before it, so it reads as `And`
|
|
50
|
+
* rather than saying `Given` three times — the same thing Gherkin does.
|
|
51
|
+
*/
|
|
52
|
+
continuesPhase?: boolean
|
|
53
|
+
/**
|
|
54
|
+
* The step before this one had the same actor. Combined with `continuesPhase`
|
|
55
|
+
* the subject is dropped, because English drops a repeated subject in a
|
|
56
|
+
* compound predicate: "yasser opens the dashboard / and sees the audit log".
|
|
57
|
+
*
|
|
58
|
+
* It takes both. Dropping the subject across a phase change gives "When opens
|
|
59
|
+
* the dashboard", and a pronoun instead of a name would give "they sees",
|
|
60
|
+
* since step templates are authored in the third person singular.
|
|
61
|
+
*/
|
|
62
|
+
continuesActor?: boolean
|
|
38
63
|
keywordWidth?: number
|
|
39
64
|
}): string => {
|
|
40
|
-
const keyword = capitalise(phase)
|
|
41
|
-
|
|
65
|
+
const keyword = capitalise(continuesPhase ? 'and' : phase)
|
|
66
|
+
// The actor key is the subject verbatim, with no article in front of it.
|
|
67
|
+
// "the ${actor}" only reads as English when the key happens to be a role
|
|
68
|
+
// noun — it turns a persona named after a person into "the nadia", which
|
|
69
|
+
// is the reporter quietly imposing a naming convention on the author.
|
|
70
|
+
const subject =
|
|
71
|
+
continuesPhase && continuesActor ? '' : composeSubject(actor, actorRole)
|
|
42
72
|
const rendered = template ? renderStepTemplate(template, input) : description
|
|
43
73
|
const sentence = [subject, rendered].filter(Boolean).join(' ')
|
|
44
74
|
if (keywordWidth === undefined) {
|
|
@@ -47,5 +77,10 @@ export const composeStepProse = ({
|
|
|
47
77
|
return `${keyword.padEnd(keywordWidth)} ${sentence}`
|
|
48
78
|
}
|
|
49
79
|
|
|
80
|
+
const composeSubject = (actor?: string, actorRole?: string): string => {
|
|
81
|
+
if (!actor) return ''
|
|
82
|
+
return actorRole ? `${actor} (the ${actorRole})` : actor
|
|
83
|
+
}
|
|
84
|
+
|
|
50
85
|
const capitalise = (value: string) =>
|
|
51
86
|
value.charAt(0).toUpperCase() + value.slice(1)
|
|
@@ -38,6 +38,13 @@ export interface ScenarioArtifact {
|
|
|
38
38
|
/** One step of a run, already joined to the prose that declared it. */
|
|
39
39
|
export interface ScenarioStepRow {
|
|
40
40
|
sentence: string
|
|
41
|
+
/**
|
|
42
|
+
* The same sentence with the actor's role in it — "yasser (the founder)
|
|
43
|
+
* signs in". Set only on the step that first names each actor, and only
|
|
44
|
+
* when a persona declares a job title or a role, so a reader who wants the
|
|
45
|
+
* context picks this and one who wants the bare run picks `sentence`.
|
|
46
|
+
*/
|
|
47
|
+
sentenceWithRole?: string
|
|
41
48
|
status: string
|
|
42
49
|
durationMs?: number
|
|
43
50
|
error?: string
|
|
@@ -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
|