diff --git a/CHANGELOG.md b/CHANGELOG.md index 0487130..38fbc24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## 0.6.0 + +This version introduces the [parallel activity](https://nocode-js.com/docs/sequential-workflow-machine/activities/parallel-activity). The parallel activity allows to execute in the same time many activities. + +**Breaking Changes** + +* The `getStatePath` method in the `WorkflowMachineSnapshot` class is deleted. Please use the `tryGetStatePath` method or the `getStatePaths` method instead. + ## 0.5.2 This version adds a possibility to stop a workflow machine. To stop a workflow machine, you should call the `tryStop()` method of the `WorkflowMachineInterpreter` class. diff --git a/machine/package.json b/machine/package.json index 6bce1b1..4d54f0d 100644 --- a/machine/package.json +++ b/machine/package.json @@ -1,7 +1,7 @@ { "name": "sequential-workflow-machine", "description": "Powerful sequential workflow machine for frontend and backend applications.", - "version": "0.5.2", + "version": "0.6.0", "type": "module", "main": "./lib/esm/index.js", "types": "./lib/index.d.ts", @@ -44,11 +44,11 @@ }, "peerDependencies": { "sequential-workflow-model": "^0.2.0", - "xstate": "^4.38.2" + "xstate": "^4.38.3" }, "dependencies": { "sequential-workflow-model": "^0.2.0", - "xstate": "^4.38.2" + "xstate": "^4.38.3" }, "devDependencies": { "@types/jest": "^29.4.0", @@ -72,4 +72,4 @@ "nocode", "lowcode" ] -} +} \ No newline at end of file diff --git a/machine/src/activities/fork-activity/fork-activity-node-builder.ts b/machine/src/activities/fork-activity/fork-activity-node-builder.ts index 6a42026..9c851ec 100644 --- a/machine/src/activities/fork-activity/fork-activity-node-builder.ts +++ b/machine/src/activities/fork-activity/fork-activity-node-builder.ts @@ -36,7 +36,7 @@ export class ForkActivityNodeBuilder> = {}; for (const branchName of branchNames) { - const branchNodeId = getBranchNodeId(branchName); + const branchNodeId = getBranchNodeId(step.id, branchName); states[branchNodeId] = this.sequenceNodeBuilder.build(buildingContext, step.branches[branchName], nextNodeTarget); } @@ -82,7 +82,7 @@ export class ForkActivityNodeBuilder { return { - target: getBranchNodeId(branchName), + target: getBranchNodeId(step.id, branchName), cond: (context: MachineContext) => { const activityState = activityStateProvider.get(context, nodeId); return activityState.targetBranchName === branchName; diff --git a/machine/src/activities/index.ts b/machine/src/activities/index.ts index ef197a1..e1113de 100644 --- a/machine/src/activities/index.ts +++ b/machine/src/activities/index.ts @@ -4,4 +4,5 @@ export * from './container-activity'; export * from './fork-activity'; export * from './interruption-activity'; export * from './loop-activity'; +export * from './parallel-activity'; export * from './results'; diff --git a/machine/src/activities/loop-activity/loop-activity.spec.ts b/machine/src/activities/loop-activity/loop-activity.spec.ts index 89a9f77..2c3f998 100644 --- a/machine/src/activities/loop-activity/loop-activity.spec.ts +++ b/machine/src/activities/loop-activity/loop-activity.spec.ts @@ -102,7 +102,7 @@ describe('LoopActivity', () => { interpreter.onChange(() => { const snapshot = interpreter.getSnapshot(); - expect(snapshot.getStatePath()).toMatchObject(expectedRun[index].path); + expect(snapshot.tryGetStatePath()).toMatchObject(expectedRun[index].path); expect(snapshot.tryGetCurrentStepId()).toBe(expectedRun[index].id); expect(snapshot.isFailed()).toBe(false); expect(snapshot.isFinished()).toBe(index === 8); diff --git a/machine/src/activities/parallel-activity/index.ts b/machine/src/activities/parallel-activity/index.ts new file mode 100644 index 0000000..d1e16d9 --- /dev/null +++ b/machine/src/activities/parallel-activity/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './parallel-activity'; diff --git a/machine/src/activities/parallel-activity/parallel-activity-node-builder.ts b/machine/src/activities/parallel-activity/parallel-activity-node-builder.ts new file mode 100644 index 0000000..e1fd4f1 --- /dev/null +++ b/machine/src/activities/parallel-activity/parallel-activity-node-builder.ts @@ -0,0 +1,128 @@ +import { + ActivityNodeBuilder, + BuildingContext, + MachineContext, + ActivityNodeConfig, + STATE_FAILED_TARGET, + STATE_INTERRUPTED_TARGET +} from '../../types'; +import { ActivityStateProvider } from '../../core/activity-context-provider'; +import { catchUnhandledError } from '../../core/catch-unhandled-error'; +import { SequenceNodeBuilder } from '../../core/sequence-node-builder'; +import { getBranchNodeId, getStepNodeId } from '../../core/safe-node-id'; +import { BranchedStep } from 'sequential-workflow-model'; +import { ParallelActivityConfig, ParallelActivityState } from './types'; +import { isInterruptResult, isSkipResult } from '../results'; + +export class ParallelActivityNodeBuilder + implements ActivityNodeBuilder +{ + public constructor( + private readonly sequenceNodeBuilder: SequenceNodeBuilder, + private readonly config: ParallelActivityConfig + ) {} + + public build(step: TStep, nextNodeTarget: string, buildingContext: BuildingContext): ActivityNodeConfig { + const activityStateProvider = new ActivityStateProvider>( + step, + (s, g) => ({ activityState: this.config.init(s, g) }) + ); + + const nodeId = getStepNodeId(step.id); + const branchNames = Object.keys(step.branches); + const states: Record> = {}; + + for (const branchName of branchNames) { + const branchNodeId = getBranchNodeId(step.id, branchName); + const leaveNodeId = branchNodeId + '_LEAVE'; + const leaveNodeTarget = '#' + leaveNodeId; + + states[branchNodeId] = { + initial: 'TRY_LEAVE', + states: { + TRY_LEAVE: { + invoke: { + src: catchUnhandledError(async () => { + // TODO: emit on enter? + }), + onDone: [ + { + target: 'SEQUENCE', + cond: (context: MachineContext) => { + const activityState = activityStateProvider.get(context, nodeId); + return activityState.branchNames?.includes(branchName) ?? false; + } + }, + { + target: 'LEAVE' + } + ], + onError: STATE_FAILED_TARGET + } + }, + SEQUENCE: this.sequenceNodeBuilder.build(buildingContext, step.branches[branchName], leaveNodeTarget), + LEAVE: { + id: leaveNodeId, + type: 'final' + } + } + }; + } + + const CONDITION: ActivityNodeConfig = { + invoke: { + src: catchUnhandledError(async (context: MachineContext) => { + const internalState = activityStateProvider.get(context, nodeId); + const result = await this.config.handler(step, context.globalState, internalState.activityState); + + if (isInterruptResult(result)) { + context.interrupted = nodeId; + return; + } + if (isSkipResult(result)) { + internalState.branchNames = []; + return; + } + if (Array.isArray(result)) { + internalState.branchNames = result.map(r => r.branchName); + return; + } + throw new Error('Not supported result for parallel activity'); + }), + onDone: [ + { + target: STATE_INTERRUPTED_TARGET, + cond: (context: MachineContext) => Boolean(context.interrupted) + }, + { + target: 'PARALLEL' + } + ], + onError: STATE_FAILED_TARGET + } + }; + + return { + id: nodeId, + initial: 'CONDITION', + states: { + CONDITION, + PARALLEL: { + type: 'parallel', + states, + onDone: 'JOIN' + }, + JOIN: { + invoke: { + src: catchUnhandledError(async (context: MachineContext) => { + const internalState = activityStateProvider.get(context, nodeId); + internalState.branchNames = undefined; + }), + onDone: nextNodeTarget, + onError: STATE_FAILED_TARGET + } + } + } + }; + } +} diff --git a/machine/src/activities/parallel-activity/parallel-activity.spec.ts b/machine/src/activities/parallel-activity/parallel-activity.spec.ts new file mode 100644 index 0000000..0a67172 --- /dev/null +++ b/machine/src/activities/parallel-activity/parallel-activity.spec.ts @@ -0,0 +1,279 @@ +import { BranchedStep, Branches, Definition, Step } from 'sequential-workflow-model'; +import { createActivitySet } from '../../core'; +import { createAtomActivityFromHandler } from '../atom-activity'; +import { createParallelActivity } from './parallel-activity'; +import { createWorkflowMachineBuilder } from '../../workflow-machine-builder'; +import { branchName } from '../results'; + +interface ParallelTestGlobalState { + logger: string; +} + +interface LogStep extends Step { + properties: { + message: string; + }; +} + +interface JobStep extends Step { + properties: { + job: string; + }; +} + +interface ParallelStep extends BranchedStep { + properties: { + activeBranchNames: string[]; + }; +} + +function createLogStep(id: string): LogStep { + return { + id, + componentType: 'task', + name: `Log ${id}`, + type: 'log', + properties: { + message: id + } + }; +} + +function createJobStep(id: string, job: string): JobStep { + return { + id, + componentType: 'task', + name: `Job ${id}`, + type: 'job', + properties: { + job + } + }; +} + +function createParallelStep(id: string, activeBranchNames: string[], branches: Branches): ParallelStep { + return { + id, + componentType: 'activity', + name: 'Parallel', + type: 'parallel', + properties: { activeBranchNames }, + branches + }; +} + +function createDefinition0(activeBranchNames: string[]) { + return { + sequence: [ + createLogStep('before'), + createParallelStep('parallel', activeBranchNames, { + threadAlfa: [createLogStep('alfa_0'), createLogStep('alfa_1'), createLogStep('alfa_2')], + threadBeta: [createLogStep('beta_0'), createLogStep('beta_1')], + threadGamma: [createLogStep('gamma_1')], + threadDelta: [] + }), + createLogStep('after') + ], + properties: {} + }; +} + +function createTest(definition: Definition) { + const activitySet = createActivitySet([ + createAtomActivityFromHandler('log', async (step, globalState) => { + globalState.logger += `;${step.id};`; + const delay = Math.ceil(20 * Math.random()); + await new Promise(resolve => setTimeout(resolve, delay)); + }), + createAtomActivityFromHandler('job', async (step, globalState) => { + globalState.logger += ';job;'; + if (step.properties.job === 'fail') { + throw new Error('Job failed!'); + } + }), + createParallelActivity('parallel', { + init: () => ({}), + handler: async step => step.properties.activeBranchNames.map(branchName) + }) + ]); + + const builder = createWorkflowMachineBuilder(activitySet); + const machine = builder.build(definition); + + const interpreter = machine.create({ + init: () => ({ + logger: '' + }) + }); + + return interpreter; +} + +describe('ParallelActivity', () => { + it('should iterate over all threads', done => { + const interpreter = createTest(createDefinition0(['threadAlfa', 'threadBeta', 'threadGamma', 'threadDelta'])); + const currentStepIdsHistory: Record = {}; + + interpreter.onChange(() => { + const stepIds = interpreter.getSnapshot().getCurrentStepIds(); + for (const stepId of stepIds) { + if (Number.isFinite(currentStepIdsHistory[stepId])) { + currentStepIdsHistory[stepId]++; + } else { + currentStepIdsHistory[stepId] = 1; + } + } + }); + interpreter.onDone(() => { + const snapshot = interpreter.getSnapshot(); + const logger = snapshot.globalState.logger; + + expect(currentStepIdsHistory['parallel']).toBe(13); + expect(currentStepIdsHistory['alfa_0']).toBeGreaterThanOrEqual(1); + expect(currentStepIdsHistory['alfa_1']).toBeGreaterThanOrEqual(1); + expect(currentStepIdsHistory['alfa_2']).toBeGreaterThanOrEqual(1); + expect(currentStepIdsHistory['beta_0']).toBeGreaterThanOrEqual(1); + expect(currentStepIdsHistory['beta_1']).toBeGreaterThanOrEqual(1); + expect(currentStepIdsHistory['gamma_1']).toBeGreaterThanOrEqual(1); + expect(currentStepIdsHistory['after']).toBe(1); + expect(currentStepIdsHistory['before']).toBe(1); + + expect(logger).toContain(';before;'); + expect(logger).toContain(';after;'); + const inside = extractBetween(logger, ';before;', ';after;'); + + expect(inside).toContain(';alfa_0;'); + expect(inside).toContain(';alfa_1;'); + expect(inside).toContain(';alfa_2;'); + expect(inside).toContain(';beta_0;'); + expect(inside).toContain(';beta_1;'); + expect(inside).toContain(';gamma_1;'); + + done(); + }); + interpreter.start(); + }); + + it('should iterate over 2 threads', done => { + const interpreter = createTest(createDefinition0(['threadBeta', 'threadGamma'])); + + interpreter.onDone(() => { + const snapshot = interpreter.getSnapshot(); + const logger = snapshot.globalState.logger; + + expect(logger).toContain(';before;'); + expect(logger).toContain(';after;'); + const inside = extractBetween(logger, ';before;', ';after;'); + + expect(inside).toContain(';beta_0;'); + expect(inside).toContain(';beta_1;'); + expect(inside).toContain(';gamma_1;'); + + done(); + }); + interpreter.start(); + }); + + it('should finish even if the branch is empty', done => { + const interpreter = createTest(createDefinition0(['threadDelta'])); + + interpreter.onDone(() => { + const snapshot = interpreter.getSnapshot(); + const logger = snapshot.globalState.logger; + expect(logger).toBe(';before;;after;'); + done(); + }); + interpreter.start(); + }); + + it('should finish with error if a step inside a branch fails', done => { + const definition: Definition = { + sequence: [ + createLogStep('before'), + createParallelStep('parallel', ['thread0'], { + thread0: [createLogStep('hello'), createJobStep('job', 'fail'), createLogStep('world')] + }), + createLogStep('after') + ], + properties: {} + }; + + const interpreter = createTest(definition); + + interpreter.onDone(() => { + const snapshot = interpreter.getSnapshot(); + const logger = snapshot.globalState.logger; + + expect(logger).toBe(';before;;hello;;job;'); + expect(snapshot.unhandledError?.message).toBe('Job failed!'); + + expect(snapshot.isFailed()).toEqual(true); + expect(snapshot.isInterrupted()).toEqual(false); + expect(snapshot.isFinished()).toEqual(false); + + done(); + }); + interpreter.start(); + }); + + it('should finish definition with multiple parallel sections', done => { + const definition: Definition = { + sequence: [ + createLogStep('before'), + createParallelStep('red', ['red0', 'red1'], { + red0: [ + createLogStep('red0_start'), + createParallelStep('blue', ['blue0', 'blue1', 'blue2'], { + blue0: [createLogStep('red0_blue0')], + blue1: [createLogStep('red0_blue1')], + blue2: [createLogStep('red0_blue2')] + }), + createLogStep('red0_end') + ], + red1: [ + createLogStep('red1_start'), + createParallelStep('green', ['green0', 'green1'], { + green0: [createLogStep('red1_green0')], + green1: [createLogStep('red1_green1')] + }), + createLogStep('red1_end') + ] + }), + createLogStep('after') + ], + properties: {} + }; + + const interpreter = createTest(definition); + + interpreter.onDone(() => { + const snapshot = interpreter.getSnapshot(); + const logger = snapshot.globalState.logger; + + expect(logger).toContain(';before;'); + expect(logger).toContain(';after;'); + + const inside = extractBetween(logger, ';before;', ';after;'); + const red0 = extractBetween(inside, ';red0_start;', ';red0_end;'); + + expect(red0).toContain(';red0_blue0;'); + expect(red0).toContain(';red0_blue1;'); + expect(red0).toContain(';red0_blue2;'); + + const red1 = extractBetween(inside, ';red1_start;', ';red1_end;'); + expect(red1).toContain(';red1_green0;'); + expect(red1).toContain(';red1_green1;'); + + expect(snapshot.isFailed()).toEqual(false); + expect(snapshot.isFinished()).toEqual(true); + expect(snapshot.isInterrupted()).toEqual(false); + + done(); + }); + interpreter.start(); + }); +}); + +function extractBetween(log: string, start: string, end: string) { + return log.split(start)[1].split(end)[0]; +} diff --git a/machine/src/activities/parallel-activity/parallel-activity.ts b/machine/src/activities/parallel-activity/parallel-activity.ts new file mode 100644 index 0000000..17afa6d --- /dev/null +++ b/machine/src/activities/parallel-activity/parallel-activity.ts @@ -0,0 +1,14 @@ +import { Activity } from '../../types'; +import { ParallelActivityNodeBuilder } from './parallel-activity-node-builder'; +import { ParallelActivityConfig } from './types'; +import { BranchedStep } from 'sequential-workflow-model'; + +export function createParallelActivity( + stepType: TStep['type'], + config: ParallelActivityConfig +): Activity { + return { + stepType, + nodeBuilderFactory: sequenceNodeBuilder => new ParallelActivityNodeBuilder(sequenceNodeBuilder, config) + }; +} diff --git a/machine/src/activities/parallel-activity/types.ts b/machine/src/activities/parallel-activity/types.ts new file mode 100644 index 0000000..3104c02 --- /dev/null +++ b/machine/src/activities/parallel-activity/types.ts @@ -0,0 +1,21 @@ +import { BranchedStep } from 'sequential-workflow-model'; +import { ActivityStateInitializer } from '../../types'; +import { BranchNameResult, InterruptResult, SkipResult } from '../results'; + +export type ParallelActivityHandlerResult = InterruptResult | BranchNameResult[] | SkipResult; +export type ParallelActivityHandler = ( + step: TStep, + globalState: TGlobalState, + activityState: TActivityState +) => Promise; + +export interface ParallelActivityState { + activityState: TActivityState; + branchNames?: string[]; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ParallelActivityConfig { + init: ActivityStateInitializer; + handler: ParallelActivityHandler; +} diff --git a/machine/src/core/safe-node-id.spec.ts b/machine/src/core/safe-node-id.spec.ts index bfb575f..d540923 100644 --- a/machine/src/core/safe-node-id.spec.ts +++ b/machine/src/core/safe-node-id.spec.ts @@ -8,6 +8,6 @@ describe('getStepNodeId()', () => { describe('getBranchNodeId()', () => { it('returns safe id', () => { - expect(getBranchNodeId('true')).toBe('BRANCH_true'); + expect(getBranchNodeId('someId', 'true')).toBe('BRANCH_someId_true'); }); }); diff --git a/machine/src/core/safe-node-id.ts b/machine/src/core/safe-node-id.ts index e04265e..463aae6 100644 --- a/machine/src/core/safe-node-id.ts +++ b/machine/src/core/safe-node-id.ts @@ -5,6 +5,6 @@ export function getStepNodeId(stepId: string): string { return STATE_STEP_ID_PREFIX + stepId; } -export function getBranchNodeId(branchName: string): string { - return STATE_BRANCH_ID_PREFIX + branchName; +export function getBranchNodeId(stepId: string, branchName: string): string { + return STATE_BRANCH_ID_PREFIX + stepId + '_' + branchName; } diff --git a/machine/src/core/state-path-reader.spec.ts b/machine/src/core/state-path-reader.spec.ts index 67cedff..3b1ea8f 100644 --- a/machine/src/core/state-path-reader.spec.ts +++ b/machine/src/core/state-path-reader.spec.ts @@ -1,15 +1,43 @@ -import { readStatePath } from './state-path-reader'; +import { readStatePaths } from './state-path-reader'; describe('readStatePath()', () => { it('returns correct value for string', () => { - expect(readStatePath('MAIN')[0]).toBe('MAIN'); + const path = readStatePaths('MAIN'); + expect(path.length).toEqual(1); + expect(path[0]).toStrictEqual(['MAIN']); }); it('returns correct value for object', () => { const value = { MAIN: { _0x1: 'WAIT_FOR_SIGNAL' } }; - const path = readStatePath(value); - expect(path[0]).toBe('MAIN'); - expect(path[1]).toBe('_0x1'); - expect(path[2]).toBe('WAIT_FOR_SIGNAL'); + const path = readStatePaths(value); + expect(path.length).toEqual(1); + expect(path[0][0]).toEqual('MAIN'); + expect(path[0][1]).toEqual('_0x1'); + expect(path[0][2]).toEqual('WAIT_FOR_SIGNAL'); + }); + + it('returns correct path', () => { + const path = readStatePaths({ + MAIN: { + PX: { + PARALLEL: { + BRANCH_0: { + SEQUENCE: 'STEP_alfa_2' + }, + BRANCH_1: { + SEQUENCE: 'STEP_beta_1' + }, + BRANCH_2: 'LEAVE', + BRANCH_3: 'LEAVE' + } + } + } + }); + + expect(path.length).toEqual(4); + expect(path[0]).toStrictEqual(['MAIN', 'PX', 'PARALLEL', 'BRANCH_0', 'SEQUENCE', 'STEP_alfa_2']); + expect(path[1]).toStrictEqual(['MAIN', 'PX', 'PARALLEL', 'BRANCH_1', 'SEQUENCE', 'STEP_beta_1']); + expect(path[2]).toStrictEqual(['MAIN', 'PX', 'PARALLEL', 'BRANCH_2', 'LEAVE']); + expect(path[3]).toStrictEqual(['MAIN', 'PX', 'PARALLEL', 'BRANCH_3', 'LEAVE']); }); }); diff --git a/machine/src/core/state-path-reader.ts b/machine/src/core/state-path-reader.ts index e3ebbbc..553516a 100644 --- a/machine/src/core/state-path-reader.ts +++ b/machine/src/core/state-path-reader.ts @@ -1,19 +1,23 @@ import { StateValue } from 'xstate'; -export function readStatePath(stateValue: StateValue): string[] { +export function readStatePaths(stateValue: StateValue): string[][] { + const result: string[][] = []; + processPath(result, [], stateValue); + return result; +} + +function processPath(result: string[][], path: string[], stateValue: StateValue) { if (typeof stateValue === 'string') { - return [stateValue]; - } - const path: string[] = []; - let current: StateValue = stateValue; - while (typeof current === 'object') { - const keys: string[] = Object.keys(current); - if (keys.length !== 1) { - throw new Error('Invalid state value'); + path.push(stateValue); + result.push(path); + } else if (typeof stateValue === 'object') { + const keys = Object.keys(stateValue); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const childPath = i === key.length - 1 ? path : [...path, key]; + processPath(result, childPath, stateValue[key]); } - path.push(keys[0]); - current = current[keys[0]]; + } else { + throw new Error('Invalid state value'); } - path.push(current); - return path; } diff --git a/machine/src/workflow-machine-snapshot.ts b/machine/src/workflow-machine-snapshot.ts index fbb7083..6b2ba61 100644 --- a/machine/src/workflow-machine-snapshot.ts +++ b/machine/src/workflow-machine-snapshot.ts @@ -1,11 +1,11 @@ import { StateValue } from 'xstate'; import { MachineUnhandledError } from './machine-unhandled-error'; -import { readStatePath } from './core/state-path-reader'; +import { readStatePaths } from './core/state-path-reader'; import { STATE_FAILED_ID, STATE_FINISHED_ID, STATE_INTERRUPTED_ID } from './types'; import { STATE_STEP_ID_PREFIX } from './core'; export class WorkflowMachineSnapshot { - private statePath?: string[]; + private statePaths?: string[][]; public constructor( public readonly globalState: GlobalState, @@ -13,33 +13,84 @@ export class WorkflowMachineSnapshot { private readonly stateValue: StateValue ) {} - public getStatePath(): string[] { - if (!this.statePath) { - this.statePath = readStatePath(this.stateValue); + /** + * @returns The paths of all currently executing states. + * @example `[ ['MAIN', 'STEP_1'], ['MAIN', 'STEP_2'] ]` + */ + public getStatePaths(): string[][] { + if (!this.statePaths) { + this.statePaths = readStatePaths(this.stateValue); } - return this.statePath; + return this.statePaths; } + /** + * @returns The path of the currently executing state. + * If multiple states are currently executing, it returns `null`. + * To get all paths of executing states, use the `getStatePaths` method. + * @example `['MAIN', 'STEP_1']` + * @example `null` + */ + public tryGetStatePath(): string[] | null { + const paths = this.getStatePaths(); + return paths.length === 1 ? paths[0] : null; + } + + /** + * @returns The ID of the currently executing state. + * If multiple states are currently executing, it returns `null`. + */ public tryGetCurrentStepId(): string | null { - const path = this.getStatePath(); - for (let i = path.length - 1; i >= 0; i--) { - const item = path[i]; - if (item.startsWith(STATE_STEP_ID_PREFIX)) { - return item.substring(STATE_STEP_ID_PREFIX.length); + const path = this.tryGetStatePath(); + return path ? tryExtractStepId(path) : null; + } + + /** + * @returns the list of ID of the currently executing steps. + */ + public getCurrentStepIds(): string[] { + const ids = new Set(); + const paths = this.getStatePaths(); + for (const path of paths) { + const id = tryExtractStepId(path); + if (id) { + ids.add(id); } } - return null; + return [...ids]; } + /** + * @returns `true` if the workflow machine is finished, otherwise `false`. + */ public isFinished(): boolean { - return this.getStatePath()[0] === STATE_FINISHED_ID; + const path = this.tryGetStatePath(); + return path !== null && path[0] === STATE_FINISHED_ID; } + /** + * @returns `true` if the workflow has failed, otherwise `false`. + */ public isFailed(): boolean { - return this.getStatePath()[0] === STATE_FAILED_ID; + const path = this.tryGetStatePath(); + return path !== null && path[0] === STATE_FAILED_ID; } + /** + * @returns `true` if the workflow is interrupted, otherwise `false`. + */ public isInterrupted(): boolean { - return this.getStatePath()[0] === STATE_INTERRUPTED_ID; + const path = this.tryGetStatePath(); + return path !== null && path[0] === STATE_INTERRUPTED_ID; + } +} + +function tryExtractStepId(path: string[]): string | null { + for (let i = path.length - 1; i >= 0; i--) { + const item = path[i]; + if (item.startsWith(STATE_STEP_ID_PREFIX)) { + return item.substring(STATE_STEP_ID_PREFIX.length); + } } + return null; } diff --git a/scripts/set-version.cjs b/scripts/set-version.cjs new file mode 100644 index 0000000..755f775 --- /dev/null +++ b/scripts/set-version.cjs @@ -0,0 +1,13 @@ +const fs = require('fs'); +const path = require('path'); + +const version = process.argv[2]; +if (!version || !(/^\d+\.\d+\.\d+$/.test(version))) { + console.log('Usage: node set-version.js 1.2.3'); + return; +} + +const packageJsonPath = path.resolve('../machine/package.json'); +const json = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); +json['version'] = version; +fs.writeFileSync(packageJsonPath, JSON.stringify(json, null, '\t'), 'utf-8'); diff --git a/yarn.lock b/yarn.lock index afd04ea..9faa179 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2902,10 +2902,10 @@ write-file-atomic@^4.0.2: imurmurhash "^0.1.4" signal-exit "^3.0.7" -xstate@^4.38.2: - version "4.38.2" - resolved "https://registry.yarnpkg.com/xstate/-/xstate-4.38.2.tgz#1b74544fc9c8c6c713ba77f81c6017e65aa89804" - integrity sha512-Fba/DwEPDLneHT3tbJ9F3zafbQXszOlyCJyQqqdzmtlY/cwE2th462KK48yaANf98jHlP6lJvxfNtN0LFKXPQg== +xstate@^4.38.3: + version "4.38.3" + resolved "https://registry.yarnpkg.com/xstate/-/xstate-4.38.3.tgz#4e15e7ad3aa0ca1eea2010548a5379966d8f1075" + integrity sha512-SH7nAaaPQx57dx6qvfcIgqKRXIh4L0A1iYEqim4s1u7c9VoCgzZc+63FY90AKU4ZzOC2cfJzTnpO4zK7fCUzzw== y18n@^5.0.5: version "5.0.8"