@pikku/core 0.12.38 → 0.12.39
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 +53 -0
- package/dist/errors/error-handler.d.ts +8 -0
- package/dist/errors/error-handler.js +8 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/services/in-memory-queue-service.d.ts +9 -0
- package/dist/services/in-memory-queue-service.js +42 -11
- package/dist/services/in-memory-workflow-service.js +2 -0
- package/dist/services/workflow-service.d.ts +3 -0
- package/dist/testing/service-tests.js +35 -0
- package/dist/types/core.types.d.ts +1 -0
- package/dist/wirings/cli/cli-runner.js +12 -3
- package/dist/wirings/workflow/graph/graph-runner.js +51 -61
- package/dist/wirings/workflow/index.d.ts +2 -0
- package/dist/wirings/workflow/index.js +2 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +32 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +136 -119
- package/dist/wirings/workflow/run-timeline.d.ts +85 -0
- package/dist/wirings/workflow/run-timeline.js +153 -0
- package/dist/wirings/workflow/workflow.types.d.ts +2 -0
- package/package.json +1 -1
- package/src/errors/error-handler.ts +10 -0
- package/src/index.ts +1 -0
- package/src/services/in-memory-queue-service.test.ts +97 -0
- package/src/services/in-memory-queue-service.ts +51 -16
- package/src/services/in-memory-workflow-service.ts +2 -0
- package/src/services/workflow-service.ts +9 -0
- package/src/testing/service-tests.ts +65 -0
- package/src/types/core.types.ts +4 -0
- package/src/wirings/cli/cli-runner.ts +11 -3
- package/src/wirings/workflow/graph/graph-runner.test.ts +72 -0
- package/src/wirings/workflow/graph/graph-runner.ts +95 -86
- package/src/wirings/workflow/index.ts +14 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +193 -137
- package/src/wirings/workflow/run-timeline.test.ts +211 -0
- package/src/wirings/workflow/run-timeline.ts +241 -0
- package/src/wirings/workflow/workflow.types.ts +2 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, test, beforeEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { InMemoryQueueService } from './in-memory-queue-service.js'
|
|
5
|
+
import { wireQueueWorker } from '../wirings/queue/queue-runner.js'
|
|
6
|
+
import { resetPikkuState, pikkuState } from '../pikku-state.js'
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
resetPikkuState()
|
|
10
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
11
|
+
logger: { error() {}, info() {}, warn() {}, debug() {} },
|
|
12
|
+
} as any)
|
|
13
|
+
pikkuState(null, 'package', 'factories', {
|
|
14
|
+
createWireServices: async () => ({}),
|
|
15
|
+
} as any)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
// Register a queue worker whose handler runs `behavior` (which may throw) and
|
|
19
|
+
// counts invocations. Returns the live call counter.
|
|
20
|
+
const registerWorker = (
|
|
21
|
+
queueName: string,
|
|
22
|
+
behavior: (count: number) => void
|
|
23
|
+
) => {
|
|
24
|
+
const calls = { count: 0 }
|
|
25
|
+
const funcId = `queue_${queueName}`
|
|
26
|
+
pikkuState(null, 'queue', 'meta')[queueName] = { pikkuFuncId: funcId, name: queueName }
|
|
27
|
+
pikkuState(null, 'function', 'meta')[funcId] = {
|
|
28
|
+
pikkuFuncId: funcId,
|
|
29
|
+
inputSchemaName: null,
|
|
30
|
+
outputSchemaName: null,
|
|
31
|
+
sessionless: true,
|
|
32
|
+
} as any
|
|
33
|
+
wireQueueWorker({
|
|
34
|
+
name: queueName,
|
|
35
|
+
func: {
|
|
36
|
+
auth: false,
|
|
37
|
+
func: async () => {
|
|
38
|
+
calls.count++
|
|
39
|
+
behavior(calls.count)
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
} as any)
|
|
43
|
+
return calls
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const waitUntil = async (
|
|
47
|
+
predicate: () => boolean,
|
|
48
|
+
timeoutMs = 2000
|
|
49
|
+
): Promise<boolean> => {
|
|
50
|
+
const deadline = Date.now() + timeoutMs
|
|
51
|
+
while (Date.now() < deadline) {
|
|
52
|
+
if (predicate()) return true
|
|
53
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
54
|
+
}
|
|
55
|
+
return predicate()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('InMemoryQueueService retry', () => {
|
|
59
|
+
test('redelivers a transiently-failing job until it succeeds', async () => {
|
|
60
|
+
const calls = registerWorker('flaky', (count) => {
|
|
61
|
+
if (count <= 2) throw new Error(`transient ${count}`)
|
|
62
|
+
})
|
|
63
|
+
const queue = new InMemoryQueueService()
|
|
64
|
+
|
|
65
|
+
await queue.add('flaky', {}, { attempts: 5, delay: 0 })
|
|
66
|
+
|
|
67
|
+
// Succeeds on the 3rd attempt; must not exceed the configured attempts.
|
|
68
|
+
assert.equal(await waitUntil(() => calls.count >= 3), true)
|
|
69
|
+
await new Promise((r) => setTimeout(r, 150))
|
|
70
|
+
assert.equal(calls.count, 3, 'should stop retrying once the job succeeds')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('stops after `attempts` when a job always fails', async () => {
|
|
74
|
+
const calls = registerWorker('always-fails', () => {
|
|
75
|
+
throw new Error('boom')
|
|
76
|
+
})
|
|
77
|
+
const queue = new InMemoryQueueService()
|
|
78
|
+
|
|
79
|
+
await queue.add('always-fails', {}, { attempts: 3, delay: 0 })
|
|
80
|
+
|
|
81
|
+
assert.equal(await waitUntil(() => calls.count >= 3), true)
|
|
82
|
+
await new Promise((r) => setTimeout(r, 150))
|
|
83
|
+
assert.equal(calls.count, 3, 'must not redeliver beyond `attempts`')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('runs once and does not retry when no attempts are configured', async () => {
|
|
87
|
+
const calls = registerWorker('no-retry', () => {
|
|
88
|
+
throw new Error('boom')
|
|
89
|
+
})
|
|
90
|
+
const queue = new InMemoryQueueService()
|
|
91
|
+
|
|
92
|
+
await queue.add('no-retry', {}, { delay: 0 })
|
|
93
|
+
|
|
94
|
+
await new Promise((r) => setTimeout(r, 150))
|
|
95
|
+
assert.equal(calls.count, 1, 'a job without attempts runs exactly once')
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -5,6 +5,13 @@ import type {
|
|
|
5
5
|
} from '../wirings/queue/queue.types.js'
|
|
6
6
|
import { runQueueJob } from '../wirings/queue/queue-runner.js'
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* In-process queue for local/dev runs. Schedules jobs on the macrotask queue
|
|
10
|
+
* (setTimeout) so dispatch is genuinely asynchronous — the same timing shape as
|
|
11
|
+
* a real queue — and redelivers a failed job up to `options.attempts` times with
|
|
12
|
+
* backoff, so a transiently-failing workflow step recovers exactly as it would
|
|
13
|
+
* on pg-boss/bullmq instead of being silently dropped on its first error.
|
|
14
|
+
*/
|
|
8
15
|
export class InMemoryQueueService implements QueueService {
|
|
9
16
|
readonly supportsResults = false
|
|
10
17
|
private jobCounter = 0
|
|
@@ -15,31 +22,59 @@ export class InMemoryQueueService implements QueueService {
|
|
|
15
22
|
options?: JobOptions
|
|
16
23
|
): Promise<string> {
|
|
17
24
|
const jobId = `inmem-${++this.jobCounter}`
|
|
25
|
+
const maxAttempts = Math.max(1, options?.attempts ?? 1)
|
|
26
|
+
let attemptsMade = 0
|
|
27
|
+
const createdAt = new Date()
|
|
18
28
|
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
setTimeout(async () => {
|
|
29
|
+
const runAttempt = async () => {
|
|
30
|
+
attemptsMade++
|
|
31
|
+
const job: QueueJob<T> = {
|
|
32
|
+
id: jobId,
|
|
33
|
+
queueName,
|
|
34
|
+
data,
|
|
35
|
+
status: () => 'active',
|
|
36
|
+
metadata: () => ({ attemptsMade, maxAttempts, createdAt }),
|
|
37
|
+
pikkuUserId: options?.pikkuUserId,
|
|
38
|
+
}
|
|
30
39
|
try {
|
|
31
40
|
await runQueueJob({ job })
|
|
32
41
|
} catch (e: any) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
42
|
+
if (attemptsMade < maxAttempts) {
|
|
43
|
+
// Transient failure — redeliver with backoff, mirroring a real queue.
|
|
44
|
+
setTimeout(runAttempt, this.backoffDelay(options?.backoff, attemptsMade))
|
|
45
|
+
} else {
|
|
46
|
+
console.error(
|
|
47
|
+
`[InMemoryQueue] Job ${jobId} on ${queueName} failed after ${attemptsMade} attempt(s):`,
|
|
48
|
+
e.message
|
|
49
|
+
)
|
|
50
|
+
}
|
|
37
51
|
}
|
|
38
|
-
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const delay = options?.delay ?? 100 + Math.floor(Math.random() * 201)
|
|
55
|
+
setTimeout(runAttempt, delay)
|
|
39
56
|
|
|
40
57
|
return jobId
|
|
41
58
|
}
|
|
42
59
|
|
|
60
|
+
/** Delay before the next redelivery, honoring the job's backoff policy. */
|
|
61
|
+
private backoffDelay(
|
|
62
|
+
backoff: JobOptions['backoff'],
|
|
63
|
+
attemptsMade: number
|
|
64
|
+
): number {
|
|
65
|
+
const baseDelay = typeof backoff === 'object' ? (backoff.delay ?? 50) : 50
|
|
66
|
+
if (
|
|
67
|
+
backoff === 'exponential' ||
|
|
68
|
+
(typeof backoff === 'object' && backoff?.type === 'exponential')
|
|
69
|
+
) {
|
|
70
|
+
return Math.min(2 ** (attemptsMade - 1) * baseDelay, 2000)
|
|
71
|
+
}
|
|
72
|
+
if (typeof backoff === 'object' && backoff?.type === 'fixed') {
|
|
73
|
+
return backoff.delay ?? 50
|
|
74
|
+
}
|
|
75
|
+
return 50
|
|
76
|
+
}
|
|
77
|
+
|
|
43
78
|
async getJob(): Promise<null> {
|
|
44
79
|
return null
|
|
45
80
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'crypto'
|
|
2
2
|
import { PikkuWorkflowService } from '../wirings/workflow/pikku-workflow-service.js'
|
|
3
|
+
import { isExpectedError } from '../errors/error-handler.js'
|
|
3
4
|
import type { SerializedError } from '../types/core.types.js'
|
|
4
5
|
import type {
|
|
5
6
|
WorkflowPlannedStep,
|
|
@@ -230,6 +231,7 @@ export class InMemoryWorkflowService
|
|
|
230
231
|
name: error.name,
|
|
231
232
|
message: error.message,
|
|
232
233
|
stack: error.stack,
|
|
234
|
+
expected: isExpectedError(error),
|
|
233
235
|
}
|
|
234
236
|
step.failedAt = new Date()
|
|
235
237
|
step.updatedAt = new Date()
|
|
@@ -8,6 +8,10 @@ import type {
|
|
|
8
8
|
WorkflowStatus,
|
|
9
9
|
WorkflowVersionStatus,
|
|
10
10
|
} from '../wirings/workflow/workflow.types.js'
|
|
11
|
+
import type {
|
|
12
|
+
RunTimeline,
|
|
13
|
+
ReconstructedRunState,
|
|
14
|
+
} from '../wirings/workflow/run-timeline.js'
|
|
11
15
|
|
|
12
16
|
/**
|
|
13
17
|
* Interface for workflow orchestration
|
|
@@ -29,6 +33,11 @@ export interface WorkflowService {
|
|
|
29
33
|
getRun(id: string): Promise<WorkflowRun | null>
|
|
30
34
|
getRunStatus(id: string): Promise<WorkflowRunStatus | null>
|
|
31
35
|
getRunHistory(runId: string): Promise<Array<StepState & { stepName: string }>>
|
|
36
|
+
getRunTimeline(id: string): Promise<RunTimeline | null>
|
|
37
|
+
reconstructRunStateAt(
|
|
38
|
+
id: string,
|
|
39
|
+
at?: number | Date
|
|
40
|
+
): Promise<ReconstructedRunState | null>
|
|
32
41
|
updateRunStatus(
|
|
33
42
|
id: string,
|
|
34
43
|
status: WorkflowStatus,
|
|
@@ -320,6 +320,71 @@ export function defineServiceTests(config: ServiceTestConfig): void {
|
|
|
320
320
|
assert.equal(retried.result, undefined)
|
|
321
321
|
})
|
|
322
322
|
|
|
323
|
+
test('getRunStatus reports per-step duration and attempts', async () => {
|
|
324
|
+
const runId = await service.createRun(
|
|
325
|
+
'status-timing-workflow',
|
|
326
|
+
{},
|
|
327
|
+
false,
|
|
328
|
+
'hash-timing',
|
|
329
|
+
wire
|
|
330
|
+
)
|
|
331
|
+
const step = await service.insertStepState(
|
|
332
|
+
runId,
|
|
333
|
+
'timed-step',
|
|
334
|
+
'myRpc',
|
|
335
|
+
{}
|
|
336
|
+
)
|
|
337
|
+
await service.setStepRunning(step.stepId)
|
|
338
|
+
await service.setStepResult(step.stepId, { ok: true })
|
|
339
|
+
|
|
340
|
+
const status = await service.getRunStatus(runId)
|
|
341
|
+
assert.ok(status)
|
|
342
|
+
const timed = status.steps.find((s) => s.name === 'timed-step')
|
|
343
|
+
assert.ok(timed, 'step appears in run status')
|
|
344
|
+
// running→succeeded must stamp runningAt/succeededAt so a duration is
|
|
345
|
+
// computable. Regression guard: the kysely store previously updated
|
|
346
|
+
// only the status on transitions, leaving the timestamps null and
|
|
347
|
+
// duration permanently undefined.
|
|
348
|
+
assert.ok(
|
|
349
|
+
timed.duration !== undefined,
|
|
350
|
+
'duration is computed from stamped transition timestamps'
|
|
351
|
+
)
|
|
352
|
+
assert.ok(timed.duration >= 0, 'duration is non-negative')
|
|
353
|
+
assert.equal(timed.attempts, 1, 'single attempt')
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
test('getRunStatus surfaces retried attempt count', async () => {
|
|
357
|
+
const runId = await service.createRun(
|
|
358
|
+
'status-retry-workflow',
|
|
359
|
+
{},
|
|
360
|
+
false,
|
|
361
|
+
'hash-retry-status',
|
|
362
|
+
wire
|
|
363
|
+
)
|
|
364
|
+
const step = await service.insertStepState(
|
|
365
|
+
runId,
|
|
366
|
+
'flaky-step',
|
|
367
|
+
'myRpc',
|
|
368
|
+
{},
|
|
369
|
+
{ retries: 2 }
|
|
370
|
+
)
|
|
371
|
+
await service.setStepRunning(step.stepId)
|
|
372
|
+
await service.setStepError(step.stepId, new Error('first try'))
|
|
373
|
+
// Guarantee the retry's history row sorts after the failed attempt so
|
|
374
|
+
// getRunStatus picks the latest attempt (it tie-breaks on timestamps).
|
|
375
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
376
|
+
const retry = await service.createRetryAttempt(step.stepId, 'pending')
|
|
377
|
+
await service.setStepRunning(retry.stepId)
|
|
378
|
+
await service.setStepResult(retry.stepId, { ok: true })
|
|
379
|
+
|
|
380
|
+
const status = await service.getRunStatus(runId)
|
|
381
|
+
assert.ok(status)
|
|
382
|
+
const flaky = status.steps.find((s) => s.name === 'flaky-step')
|
|
383
|
+
assert.ok(flaky, 'retried step collapses to a single status entry')
|
|
384
|
+
assert.equal(flaky.status, 'succeeded', 'latest attempt wins')
|
|
385
|
+
assert.equal(flaky.attempts, 2, 'attempt count surfaces in status')
|
|
386
|
+
})
|
|
387
|
+
|
|
323
388
|
test('getNodesWithoutSteps', async () => {
|
|
324
389
|
const runId = await service.createRun(
|
|
325
390
|
'graph-workflow',
|
package/src/types/core.types.ts
CHANGED
|
@@ -558,5 +558,9 @@ export interface SerializedError {
|
|
|
558
558
|
message: string
|
|
559
559
|
stack?: string
|
|
560
560
|
code?: string
|
|
561
|
+
// True when the original error was a deliberate, expected failure (a
|
|
562
|
+
// PikkuError). Survives the step-boundary rehydration so the workflow runner
|
|
563
|
+
// can log the message alone instead of a stack trace. See isExpectedError.
|
|
564
|
+
expected?: boolean
|
|
561
565
|
[key: string]: any
|
|
562
566
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { NotFoundError } from '../../errors/errors.js'
|
|
2
|
+
import { isExpectedError } from '../../errors/error-handler.js'
|
|
2
3
|
import { addFunction, runPikkuFunc } from '../../function/function-runner.js'
|
|
3
4
|
import { pikkuState } from '../../pikku-state.js'
|
|
4
5
|
import type {
|
|
@@ -543,10 +544,17 @@ export async function executeCLI({
|
|
|
543
544
|
throw error
|
|
544
545
|
}
|
|
545
546
|
|
|
546
|
-
//
|
|
547
|
-
|
|
547
|
+
// An expected failure (a deliberate PikkuError, e.g. a build gate
|
|
548
|
+
// tripping) — its message says everything, so print that alone (no
|
|
549
|
+
// `Error:` prefix). Anything else is an uncaught error: log the object so
|
|
550
|
+
// its stack shows.
|
|
551
|
+
if (isExpectedError(error)) {
|
|
552
|
+
console.error(error.message)
|
|
553
|
+
} else {
|
|
554
|
+
console.error('Error:', error)
|
|
555
|
+
}
|
|
548
556
|
|
|
549
|
-
// Show stack trace in verbose mode
|
|
557
|
+
// Show stack trace in verbose mode (even for expected errors).
|
|
550
558
|
if (args.includes('--verbose') || args.includes('-v')) {
|
|
551
559
|
console.error('Stack trace:', error.stack)
|
|
552
560
|
}
|
|
@@ -416,6 +416,78 @@ describe('graph-runner bugs', () => {
|
|
|
416
416
|
delete metaState['testInlineFailure']
|
|
417
417
|
})
|
|
418
418
|
|
|
419
|
+
test('inline graph revisits a cyclic node and records the walked path via fromStepName', async () => {
|
|
420
|
+
const ws = new InMemoryWorkflowService()
|
|
421
|
+
|
|
422
|
+
// attempt loops back to itself via `again` until it converges to `done`.
|
|
423
|
+
const mockRpcService = {
|
|
424
|
+
rpcWithWire: async (rpcName: string, _data: any, wire: any) => {
|
|
425
|
+
if (rpcName === 'cyclicBegin') return { attempts: 3 }
|
|
426
|
+
if (rpcName === 'cyclicAttempt') {
|
|
427
|
+
const state = await wire.graph.getState()
|
|
428
|
+
const count = ((state.count as number) ?? 0) + 1
|
|
429
|
+
await wire.graph.setState('count', count)
|
|
430
|
+
wire.graph.branch(count >= 3 ? 'done' : 'again')
|
|
431
|
+
return { count }
|
|
432
|
+
}
|
|
433
|
+
if (rpcName === 'cyclicFinish') return { ok: true }
|
|
434
|
+
return {}
|
|
435
|
+
},
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const metaState = pikkuState(null, 'workflows', 'meta')
|
|
439
|
+
metaState['testInlineCyclic'] = {
|
|
440
|
+
name: 'testInlineCyclic',
|
|
441
|
+
pikkuFuncId: 'testInlineCyclic',
|
|
442
|
+
source: 'graph',
|
|
443
|
+
entryNodeIds: ['begin'],
|
|
444
|
+
graphHash: 'inline-cyclic-hash',
|
|
445
|
+
nodes: {
|
|
446
|
+
begin: { nodeId: 'begin', rpcName: 'cyclicBegin', next: 'attempt' },
|
|
447
|
+
attempt: {
|
|
448
|
+
nodeId: 'attempt',
|
|
449
|
+
rpcName: 'cyclicAttempt',
|
|
450
|
+
next: { again: 'attempt', done: 'finish' },
|
|
451
|
+
},
|
|
452
|
+
finish: { nodeId: 'finish', rpcName: 'cyclicFinish' },
|
|
453
|
+
},
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const { runId } = await runWorkflowGraph(
|
|
457
|
+
ws,
|
|
458
|
+
'testInlineCyclic',
|
|
459
|
+
{},
|
|
460
|
+
mockRpcService,
|
|
461
|
+
true
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
const run = await ws.getRun(runId)
|
|
465
|
+
assert.equal(run?.status, 'completed')
|
|
466
|
+
|
|
467
|
+
// The self-cycle produced three ordinal instances of `attempt`.
|
|
468
|
+
const instances = await ws.getStepInstances(runId)
|
|
469
|
+
const names = instances.map((i) => i.stepName).sort()
|
|
470
|
+
assert.ok(
|
|
471
|
+
names.includes('attempt') &&
|
|
472
|
+
names.includes('attempt#1') &&
|
|
473
|
+
names.includes('attempt#2'),
|
|
474
|
+
`expected attempt/attempt#1/attempt#2, got ${JSON.stringify(names)}`
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
// Walk the fromStepName chain back from the terminal node — inline now
|
|
478
|
+
// records provenance identical to the queued path.
|
|
479
|
+
const from = new Map(instances.map((i) => [i.stepName, i.fromStepName]))
|
|
480
|
+
const path: string[] = []
|
|
481
|
+
let cursor: string | undefined = 'finish'
|
|
482
|
+
while (cursor) {
|
|
483
|
+
path.unshift(cursor)
|
|
484
|
+
cursor = from.get(cursor) ?? undefined
|
|
485
|
+
}
|
|
486
|
+
assert.deepEqual(path, ['begin', 'attempt', 'attempt#1', 'attempt#2', 'finish'])
|
|
487
|
+
|
|
488
|
+
delete metaState['testInlineCyclic']
|
|
489
|
+
})
|
|
490
|
+
|
|
419
491
|
test('executeGraphStep should throw after queueing onError nodes', async () => {
|
|
420
492
|
const ws = new InMemoryWorkflowService()
|
|
421
493
|
|