@pikku/core 0.12.29 → 0.12.31
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 +87 -0
- package/dist/data-classification.d.ts +3 -3
- package/dist/services/meta-service.js +15 -7
- package/dist/types/core.types.d.ts +9 -1
- package/dist/wirings/secret/secret.types.d.ts +7 -0
- package/dist/wirings/secret/validate-secret-definitions.js +2 -0
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +5 -1
- package/dist/wirings/workflow/graph/graph-runner.js +1 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +11 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +37 -23
- package/dist/wirings/workflow/workflow.types.d.ts +0 -3
- package/package.json +1 -1
- package/src/data-classification.ts +10 -3
- package/src/services/meta-service.ts +19 -7
- package/src/types/core.types.ts +9 -0
- package/src/wirings/secret/secret.types.ts +7 -0
- package/src/wirings/secret/validate-secret-definitions.ts +2 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +5 -1
- package/src/wirings/workflow/graph/graph-runner.test.ts +1 -2
- package/src/wirings/workflow/graph/graph-runner.ts +1 -2
- package/src/wirings/workflow/pikku-workflow-service.test.ts +168 -19
- package/src/wirings/workflow/pikku-workflow-service.ts +41 -25
- package/src/wirings/workflow/workflow.types.ts +0 -3
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -26,14 +26,13 @@ describe('pikku-workflow-service worker registration', () => {
|
|
|
26
26
|
})
|
|
27
27
|
})
|
|
28
28
|
|
|
29
|
-
describe('pikku-workflow-service inline
|
|
30
|
-
test('workflow
|
|
29
|
+
describe('pikku-workflow-service run-level inline', () => {
|
|
30
|
+
test('workflow runs inline (and queues nothing) when no queue service is configured', async () => {
|
|
31
31
|
const ws = new InMemoryWorkflowService()
|
|
32
|
-
const workflowName = '
|
|
33
|
-
const graphHash = 'inline-
|
|
32
|
+
const workflowName = 'testRunInlineNoQueue'
|
|
33
|
+
const graphHash = 'run-inline-no-queue-hash'
|
|
34
34
|
|
|
35
|
-
//
|
|
36
|
-
let queued = false
|
|
35
|
+
// No queueService configured at all → the run executes inline.
|
|
37
36
|
pikkuState(null, 'package', 'singletonServices', {
|
|
38
37
|
logger: {
|
|
39
38
|
error: () => {},
|
|
@@ -41,11 +40,6 @@ describe('pikku-workflow-service inline meta flag', () => {
|
|
|
41
40
|
warn: () => {},
|
|
42
41
|
debug: () => {},
|
|
43
42
|
},
|
|
44
|
-
queueService: {
|
|
45
|
-
add: async () => {
|
|
46
|
-
queued = true
|
|
47
|
-
},
|
|
48
|
-
},
|
|
49
43
|
} as any)
|
|
50
44
|
|
|
51
45
|
const metaState = pikkuState(null, 'workflows', 'meta')
|
|
@@ -54,7 +48,6 @@ describe('pikku-workflow-service inline meta flag', () => {
|
|
|
54
48
|
pikkuFuncId: workflowName,
|
|
55
49
|
source: 'dsl',
|
|
56
50
|
graphHash,
|
|
57
|
-
inline: true,
|
|
58
51
|
}
|
|
59
52
|
|
|
60
53
|
const functionMetaState = pikkuState(null, 'function', 'meta')
|
|
@@ -72,21 +65,16 @@ describe('pikku-workflow-service inline meta flag', () => {
|
|
|
72
65
|
|
|
73
66
|
const { runId } = await ws.startWorkflow(workflowName, {}, {}, {})
|
|
74
67
|
|
|
75
|
-
//
|
|
68
|
+
// With no queue service the run is created as inline.
|
|
76
69
|
const run = await ws.getRun(runId)
|
|
77
70
|
assert.equal(run?.inline, true, 'run should be marked as inline')
|
|
78
|
-
assert.equal(
|
|
79
|
-
queued,
|
|
80
|
-
false,
|
|
81
|
-
'nothing should have been queued to the queue service'
|
|
82
|
-
)
|
|
83
71
|
|
|
84
72
|
delete metaState[workflowName]
|
|
85
73
|
delete functionMetaState[workflowName]
|
|
86
74
|
pikkuState(null, 'workflows', 'registrations').delete(workflowName)
|
|
87
75
|
})
|
|
88
76
|
|
|
89
|
-
test('workflow
|
|
77
|
+
test('workflow is dispatched to the queue when a queueService exists', async () => {
|
|
90
78
|
const ws = new InMemoryWorkflowService()
|
|
91
79
|
const workflowName = 'testNonInlineMetaFlag'
|
|
92
80
|
const graphHash = 'non-inline-meta-hash'
|
|
@@ -135,6 +123,95 @@ describe('pikku-workflow-service inline meta flag', () => {
|
|
|
135
123
|
})
|
|
136
124
|
})
|
|
137
125
|
|
|
126
|
+
describe('pikku-workflow-service per-function step dispatch', () => {
|
|
127
|
+
// Expose the protected dispatchStep so we can assert the dispatch decision
|
|
128
|
+
// in isolation: a step queues only when its function opts out of inline
|
|
129
|
+
// execution (inline: false).
|
|
130
|
+
class TestWorkflowService extends InMemoryWorkflowService {
|
|
131
|
+
public callDispatchStep(rpcName: string) {
|
|
132
|
+
return this.dispatchStep('run-1', 'step-1', rpcName, {})
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const setupFunction = (rpcName: string, inline?: boolean) => {
|
|
137
|
+
pikkuState(null, 'rpc', 'meta')[rpcName] = rpcName as any
|
|
138
|
+
pikkuState(null, 'function', 'meta')[rpcName] = {
|
|
139
|
+
pikkuFuncId: rpcName,
|
|
140
|
+
sessionless: true,
|
|
141
|
+
...(inline === undefined ? {} : { inline }),
|
|
142
|
+
} as any
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const cleanup = (rpcName: string) => {
|
|
146
|
+
delete pikkuState(null, 'rpc', 'meta')[rpcName]
|
|
147
|
+
delete pikkuState(null, 'function', 'meta')[rpcName]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
test('default (inline) step is NOT dispatched to the queue', async () => {
|
|
151
|
+
const ws = new TestWorkflowService()
|
|
152
|
+
let queued = false
|
|
153
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
154
|
+
logger: { error() {}, info() {}, warn() {}, debug() {} },
|
|
155
|
+
queueService: {
|
|
156
|
+
add: async () => {
|
|
157
|
+
queued = true
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
} as any)
|
|
161
|
+
|
|
162
|
+
setupFunction('defaultInlineStep')
|
|
163
|
+
const dispatched = await ws.callDispatchStep('defaultInlineStep')
|
|
164
|
+
assert.equal(dispatched, false, 'default step should run inline')
|
|
165
|
+
assert.equal(queued, false, 'default step should not queue')
|
|
166
|
+
cleanup('defaultInlineStep')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('inline: false step IS dispatched to the queue when a queueService exists', async () => {
|
|
170
|
+
const ws = new TestWorkflowService()
|
|
171
|
+
let queued = false
|
|
172
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
173
|
+
logger: { error() {}, info() {}, warn() {}, debug() {} },
|
|
174
|
+
queueService: {
|
|
175
|
+
add: async () => {
|
|
176
|
+
queued = true
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
} as any)
|
|
180
|
+
|
|
181
|
+
setupFunction('queuedStep', false)
|
|
182
|
+
const dispatched = await ws.callDispatchStep('queuedStep')
|
|
183
|
+
assert.equal(dispatched, true, 'inline:false step should dispatch')
|
|
184
|
+
assert.equal(queued, true, 'inline:false step should queue')
|
|
185
|
+
cleanup('queuedStep')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('inline: false step warns and runs inline when no queueService is configured', async () => {
|
|
189
|
+
const ws = new TestWorkflowService()
|
|
190
|
+
let warned = false
|
|
191
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
192
|
+
logger: {
|
|
193
|
+
error() {},
|
|
194
|
+
info() {},
|
|
195
|
+
warn: () => {
|
|
196
|
+
warned = true
|
|
197
|
+
},
|
|
198
|
+
debug() {},
|
|
199
|
+
},
|
|
200
|
+
// no queueService
|
|
201
|
+
} as any)
|
|
202
|
+
|
|
203
|
+
setupFunction('queuedStepNoQueue', false)
|
|
204
|
+
const dispatched = await ws.callDispatchStep('queuedStepNoQueue')
|
|
205
|
+
assert.equal(
|
|
206
|
+
dispatched,
|
|
207
|
+
false,
|
|
208
|
+
'falls back to inline when no queue service'
|
|
209
|
+
)
|
|
210
|
+
assert.equal(warned, true, 'should warn about the misconfiguration')
|
|
211
|
+
cleanup('queuedStepNoQueue')
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
138
215
|
describe('pikku-workflow-service version mismatch fallback', () => {
|
|
139
216
|
test('should fall back to stored graph for dsl workflow version mismatch', async () => {
|
|
140
217
|
const ws = new InMemoryWorkflowService()
|
|
@@ -323,4 +400,76 @@ describe('pikku-workflow-service suspend', () => {
|
|
|
323
400
|
delete functionMetaState[workflowName]
|
|
324
401
|
pikkuState(null, 'workflows', 'registrations').delete(workflowName)
|
|
325
402
|
})
|
|
403
|
+
|
|
404
|
+
test('should support multiple independent suspend points in one workflow', async () => {
|
|
405
|
+
const ws = new InMemoryWorkflowService()
|
|
406
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
407
|
+
queueService: {
|
|
408
|
+
add: async () => {},
|
|
409
|
+
},
|
|
410
|
+
} as any)
|
|
411
|
+
|
|
412
|
+
const workflowName = 'testMultiSuspendWorkflow'
|
|
413
|
+
const graphHash = 'multi-suspend-hash'
|
|
414
|
+
const metaState = pikkuState(null, 'workflows', 'meta')
|
|
415
|
+
metaState[workflowName] = {
|
|
416
|
+
name: workflowName,
|
|
417
|
+
pikkuFuncId: workflowName,
|
|
418
|
+
source: 'dsl',
|
|
419
|
+
graphHash,
|
|
420
|
+
}
|
|
421
|
+
const functionMetaState = pikkuState(null, 'function', 'meta')
|
|
422
|
+
functionMetaState[workflowName] = {
|
|
423
|
+
name: workflowName,
|
|
424
|
+
sessionless: true,
|
|
425
|
+
permissions: [],
|
|
426
|
+
} as any
|
|
427
|
+
|
|
428
|
+
addWorkflow(workflowName, {
|
|
429
|
+
func: async (
|
|
430
|
+
_services: any,
|
|
431
|
+
_data: any,
|
|
432
|
+
{ workflow }: { workflow: PikkuWorkflowWire }
|
|
433
|
+
) => {
|
|
434
|
+
await workflow.suspend('Building')
|
|
435
|
+
await workflow.suspend('Awaiting approval')
|
|
436
|
+
return { ok: true }
|
|
437
|
+
},
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
const runId = await ws.createRun(workflowName, {}, false, graphHash, {
|
|
441
|
+
type: 'test',
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
// First suspend: 'await_build' pauses the run.
|
|
445
|
+
await assert.rejects(
|
|
446
|
+
ws.runWorkflowJob(runId, {}),
|
|
447
|
+
(error: unknown) => error instanceof WorkflowSuspendedException
|
|
448
|
+
)
|
|
449
|
+
let run = await ws.getRun(runId)
|
|
450
|
+
assert.equal(run?.status, 'suspended')
|
|
451
|
+
assert.equal(run?.error?.message, 'Building')
|
|
452
|
+
|
|
453
|
+
// Resuming must hit the SECOND suspend ('awaiting_approval'), not fall
|
|
454
|
+
// through it — this is what the shared-step-name bug broke.
|
|
455
|
+
await ws.resumeWorkflow(runId)
|
|
456
|
+
await assert.rejects(
|
|
457
|
+
ws.runWorkflowJob(runId, {}),
|
|
458
|
+
(error: unknown) => error instanceof WorkflowSuspendedException
|
|
459
|
+
)
|
|
460
|
+
run = await ws.getRun(runId)
|
|
461
|
+
assert.equal(run?.status, 'suspended')
|
|
462
|
+
assert.equal(run?.error?.message, 'Awaiting approval')
|
|
463
|
+
|
|
464
|
+
// Resuming again completes the workflow.
|
|
465
|
+
await ws.resumeWorkflow(runId)
|
|
466
|
+
await ws.runWorkflowJob(runId, {})
|
|
467
|
+
run = await ws.getRun(runId)
|
|
468
|
+
assert.equal(run?.status, 'completed')
|
|
469
|
+
assert.deepEqual(run?.output, { ok: true })
|
|
470
|
+
|
|
471
|
+
delete metaState[workflowName]
|
|
472
|
+
delete functionMetaState[workflowName]
|
|
473
|
+
pikkuState(null, 'workflows', 'registrations').delete(workflowName)
|
|
474
|
+
})
|
|
326
475
|
})
|
|
@@ -879,16 +879,28 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
879
879
|
data: unknown,
|
|
880
880
|
stepOptions?: WorkflowStepOptions
|
|
881
881
|
): Promise<boolean> {
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
//
|
|
886
|
-
//
|
|
882
|
+
// Step execution is decided purely by the function's `inline` flag (default
|
|
883
|
+
// true). Only a function explicitly marked `inline: false` dispatches via
|
|
884
|
+
// the queue. The run-level inline state is intentionally NOT consulted
|
|
885
|
+
// here: default steps run inline even inside a queue-dispatched run, so a
|
|
886
|
+
// normally-started workflow executes its steps in one orchestrator-worker
|
|
887
|
+
// pass instead of one queue round-trip per step.
|
|
887
888
|
const functionsMeta = pikkuState(null, 'function', 'meta')
|
|
888
889
|
const rpcFuncId = pikkuState(null, 'rpc', 'meta')[rpcName]
|
|
889
|
-
const rpcMeta =
|
|
890
|
+
const rpcMeta =
|
|
891
|
+
typeof rpcFuncId === 'string' ? functionsMeta[rpcFuncId] : undefined
|
|
890
892
|
const forceQueue = rpcMeta?.inline === false
|
|
891
|
-
if (!forceQueue
|
|
893
|
+
if (!forceQueue) {
|
|
894
|
+
return false
|
|
895
|
+
}
|
|
896
|
+
// The function opted out of inline execution (`inline: false`) but no queue
|
|
897
|
+
// service is configured to dispatch it. Fall back to inline so the workflow
|
|
898
|
+
// still progresses, but warn loudly — silently swallowing this hides a real
|
|
899
|
+
// misconfiguration (the step won't get its own worker/retry isolation).
|
|
900
|
+
if (!getSingletonServices()?.queueService) {
|
|
901
|
+
getSingletonServices()?.logger.warn(
|
|
902
|
+
`Workflow step '${stepName}' (function '${rpcName}') is marked 'inline: false' but no queue service is configured — running it inline instead of dispatching to a queue.`
|
|
903
|
+
)
|
|
892
904
|
return false
|
|
893
905
|
}
|
|
894
906
|
const retries = stepOptions?.retries ?? 0
|
|
@@ -977,9 +989,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
977
989
|
workflowMeta.source === 'dynamic-workflow'
|
|
978
990
|
) {
|
|
979
991
|
const shouldInline =
|
|
980
|
-
options?.inline ||
|
|
981
|
-
workflowMeta.inline ||
|
|
982
|
-
!getSingletonServices()?.queueService
|
|
992
|
+
options?.inline || !getSingletonServices()?.queueService
|
|
983
993
|
return runWorkflowGraph(
|
|
984
994
|
this,
|
|
985
995
|
name,
|
|
@@ -1005,9 +1015,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1005
1015
|
}
|
|
1006
1016
|
|
|
1007
1017
|
const shouldInline =
|
|
1008
|
-
options?.inline ||
|
|
1009
|
-
workflowMeta.inline ||
|
|
1010
|
-
!getSingletonServices()?.queueService
|
|
1018
|
+
options?.inline || !getSingletonServices()?.queueService
|
|
1011
1019
|
|
|
1012
1020
|
const runId = await this.createRun(
|
|
1013
1021
|
name,
|
|
@@ -1351,8 +1359,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1351
1359
|
parentRunId: runId,
|
|
1352
1360
|
parentStepId: stepState.stepId,
|
|
1353
1361
|
}
|
|
1354
|
-
const shouldInline =
|
|
1355
|
-
subWorkflowMeta.inline || !getSingletonServices()?.queueService
|
|
1362
|
+
const shouldInline = !getSingletonServices()?.queueService
|
|
1356
1363
|
const { runId: childRunId } = await this.startWorkflow(
|
|
1357
1364
|
rpcName,
|
|
1358
1365
|
data,
|
|
@@ -1762,23 +1769,31 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1762
1769
|
await this.setStepResult(stepState.stepId, null)
|
|
1763
1770
|
}
|
|
1764
1771
|
|
|
1772
|
+
/**
|
|
1773
|
+
* Derive the durable step name for a suspend point from its `reason`, so each
|
|
1774
|
+
* distinct reason is its own step row — letting one workflow have multiple
|
|
1775
|
+
* independent suspends (e.g. wait-for-build, then wait-for-approval), and
|
|
1776
|
+
* supporting dynamic reasons in loops (`suspend(`Wait for ${i}`)`) exactly
|
|
1777
|
+
* like dynamic `do()` step names. The reason is used raw (it's just a text
|
|
1778
|
+
* step name), only namespaced so it can't collide with a `do`/`sleep` step of
|
|
1779
|
+
* the same name. Like `do()` / `sleep()`, the reason is the step's stable
|
|
1780
|
+
* identity: it MUST be derived deterministically so it's the same every time
|
|
1781
|
+
* the workflow replays through that point.
|
|
1782
|
+
*/
|
|
1765
1783
|
private getSuspendStepName(reason: string): string {
|
|
1766
|
-
|
|
1767
|
-
return '__workflow_suspend'
|
|
1768
|
-
}
|
|
1769
|
-
return '__workflow_suspend'
|
|
1784
|
+
return `__workflow_suspend:${reason}`
|
|
1770
1785
|
}
|
|
1771
1786
|
|
|
1772
1787
|
private async suspendStep(runId: string, reason: string): Promise<void> {
|
|
1773
|
-
const
|
|
1774
|
-
await this.withStepLock(runId,
|
|
1788
|
+
const suspendStepName = this.getSuspendStepName(reason)
|
|
1789
|
+
await this.withStepLock(runId, suspendStepName, async () => {
|
|
1775
1790
|
let stepState: StepState
|
|
1776
1791
|
try {
|
|
1777
|
-
stepState = await this.getStepState(runId,
|
|
1792
|
+
stepState = await this.getStepState(runId, suspendStepName)
|
|
1778
1793
|
} catch {
|
|
1779
1794
|
stepState = await this.insertStepState(
|
|
1780
1795
|
runId,
|
|
1781
|
-
|
|
1796
|
+
suspendStepName,
|
|
1782
1797
|
'pikkuWorkflowSuspend',
|
|
1783
1798
|
{
|
|
1784
1799
|
reason,
|
|
@@ -1788,7 +1803,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1788
1803
|
if (!stepState.stepId) {
|
|
1789
1804
|
stepState = await this.insertStepState(
|
|
1790
1805
|
runId,
|
|
1791
|
-
|
|
1806
|
+
suspendStepName,
|
|
1792
1807
|
'pikkuWorkflowSuspend',
|
|
1793
1808
|
{
|
|
1794
1809
|
reason,
|
|
@@ -1865,7 +1880,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1865
1880
|
},
|
|
1866
1881
|
|
|
1867
1882
|
suspend: async (reason: string) => {
|
|
1868
|
-
|
|
1883
|
+
this.verifyStepName(reason)
|
|
1884
|
+
await this.suspendStep(runId, reason)
|
|
1869
1885
|
},
|
|
1870
1886
|
}
|
|
1871
1887
|
return workflowWire
|
|
@@ -315,7 +315,6 @@ export type WorkflowsMeta = Record<
|
|
|
315
315
|
steps: WorkflowStepMeta[]
|
|
316
316
|
context?: WorkflowContext
|
|
317
317
|
dsl?: boolean
|
|
318
|
-
inline?: boolean
|
|
319
318
|
expose?: boolean
|
|
320
319
|
}
|
|
321
320
|
>
|
|
@@ -336,8 +335,6 @@ export interface WorkflowRuntimeMeta {
|
|
|
336
335
|
description?: string
|
|
337
336
|
/** Tags for organization */
|
|
338
337
|
tags?: string[]
|
|
339
|
-
/** If true, workflow always executes inline without queues */
|
|
340
|
-
inline?: boolean
|
|
341
338
|
/** Serialized nodes */
|
|
342
339
|
nodes?: Record<string, any>
|
|
343
340
|
/** Entry node IDs for graph workflows (computed at build time) */
|