16 KiB
Capability Spec Delta: workflow (enterprise-workflow)
Package: @suaveplan/workflow
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions.
ADDED Requirements
Requirement: Fluent workflow builder
The package SHALL expose createWorkflow<TData>(name, id?) and a WorkflowBuilder<TData> class that fluently composes steps via addStep(name, execute, id?) -> StepBuilder<TData>, supports per-step configuration through StepBuilder (withCompensation, withGuard, withRetry, withTimeout), and emits a typed WorkflowConfig<TData> via buildConfig() or a ready-to-run WorkflowEngine<TData> via build().
Scenario: builder produces a config with the configured steps and error strategy
- WHEN a caller invokes
createWorkflow("orders").addStep("validate", run).onError("compensate").buildConfig() - THEN the resulting
WorkflowConfighassteps.length === 1,onError === "compensate", andname === "orders"
Scenario: StepBuilder.withRetry applies defaults for omitted fields
- WHEN a caller invokes
addStep(...).withRetry({ maxAttempts: 5 }).build() - THEN the returned
StepDefinition.retryhasmaxAttempts: 5,strategy: "fixed",delayMs: 1000
Verified by: src/core/workflow-builder/workflow-builder.test.ts::"builds a config with steps and error strategy" AND src/core/workflow-builder/workflow-builder.test.ts::"applies retry defaults for omitted fields"
Requirement: Sequential step execution with onError strategies
The WorkflowEngine<TData> SHALL execute steps sequentially in declaration order and SHALL honour the onError strategy on the first failed step: "stop" halts execution and returns a failed result; "compensate" halts execution, runs registered compensations in reverse order, and returns a failed result with status "failed"; "continue" records the failure and proceeds to the next step.
Scenario: onError stop short-circuits remaining steps
- WHEN a workflow with three steps fails on step two under
onError: "stop" - THEN step three is never invoked and the result
status === "failed"
Scenario: onError compensate rolls back completed steps in reverse order
- WHEN a workflow has two steps with compensations and the second fails under
onError: "compensate" - THEN the first step's compensation runs exactly once and the result
status === "failed"
Scenario: onError continue keeps executing after a failure
- WHEN a three-step workflow under
onError: "continue"fails on step two - THEN step three still executes and
result.stepscontains all three step results
Verified by: src/core/workflow-engine/workflow-engine.test.ts::"halts on first failure when onError is stop" AND src/core/workflow-engine/workflow-engine.test.ts::"runs reverse-order compensation when onError is compensate" AND src/core/workflow-engine/workflow-engine.test.ts::"continues past a failure when onError is continue"
Requirement: Step retry, timeout, and guard
Step<TCtx> SHALL support optional guard (skips execution when the guard returns false), retry (re-executes on failure using fixed/linear/exponential strategy with maxDelayMs cap), timeout (rejects the in-flight execution after the configured milliseconds), and compensate (rolls back the side effect).
Scenario: guard returning false yields a skipped step
- WHEN
Step.shouldExecute(ctx)returns false - THEN the engine records the step result with
status === "skipped"and never invokesexecute
Scenario: retry escalates delay per strategy and caps at maxDelayMs
- WHEN a step with
strategy: "exponential", delayMs: 100, backoffMultiplier: 2, maxDelayMs: 250retries - THEN
calculateRetryDelay(3, ...)returns250(capped)
Scenario: timeout rejects when the step exceeds the budget
- WHEN a step's
executeresolves after 50ms buttimeoutis 10ms - THEN
Step.runreturnsstatus: "failed"and the error message contains "timed out"
Verified by: src/steps/step/step.test.ts::"skips when guard returns false" AND src/utils/utils.test.ts::"caps exponential backoff at maxDelayMs" AND src/steps/step/step.test.ts::"fails with timeout error when execute exceeds budget"
Requirement: Parallel step execution with concurrency and fail-fast
ParallelStep<TCtx> SHALL execute its registered child steps concurrently, SHALL respect maxConcurrency (default Infinity) by batching when finite, SHALL support failFast: true to skip subsequent batches once any error is observed, and SHALL aggregate per-step StepResult plus a collected errors array.
Scenario: parallel run returns one result per child step
- WHEN a parallel step with three children runs and all succeed
- THEN
result.results.length === 3andresult.status === "completed"
Scenario: maxConcurrency=1 serialises execution into batches
- WHEN a parallel step with four children and
maxConcurrency: 1runs - THEN four sequential batches are produced (verified by recording start order)
Scenario: failFast halts subsequent batches after first error
- WHEN a parallel step with
maxConcurrency: 1, failFast: trueand four children sees child two fail - THEN children three and four are not executed
Verified by: src/steps/parallel-step/parallel-step.test.ts::"returns one result per child step" AND src/steps/parallel-step/parallel-step.test.ts::"serialises into batches when maxConcurrency is 1" AND src/steps/parallel-step/parallel-step.test.ts::"halts subsequent batches when failFast is true"
Requirement: Conditional step with true/false branches
ConditionalStep<TCtx> SHALL evaluate its condition against the context, run onTrue when the condition is truthy, run onFalse when falsy if present, and yield a skipped StepResult when falsy without a false branch. compensate(ctx) SHALL roll back only the branch that actually ran.
Scenario: truthy branch runs onTrue and records lastBranch
- WHEN the condition returns true
- THEN
lastBranch === "true"and the result comes from the true-branch step
Scenario: falsy with no else yields a skipped result
- WHEN the condition returns false and
onFalseis undefined - THEN the result has
status === "skipped"andlastBranch === "skipped"
Verified by: src/steps/conditional-step/conditional-step.test.ts::"runs the true branch and records lastBranch" AND src/steps/conditional-step/conditional-step.test.ts::"skips when condition is false and no false branch is provided"
Requirement: Saga orchestration with reverse-order compensation
Saga<TCtx> SHALL execute its steps sequentially and on the first failure SHALL run registered compensations in reverse registration order via the internal Compensator. The result SHALL expose stepResults, compensationRecords, and a WorkflowStatus of "completed" or "failed".
Scenario: failure mid-saga compensates earlier completed steps
- WHEN a three-step saga completes steps one and two, then step three fails
- THEN
compensationRecordscontains entries for steps two then one (reverse order) andstatus === "failed"
Verified by: src/compensation/saga/saga.test.ts::"compensates earlier steps in reverse order on failure"
Requirement: Compensator registry
Compensator<TCtx> SHALL allow register(stepId, fn), SHALL execute compensateAll(ctx) in reverse registration order, SHALL execute compensateSteps(ctx, ids) filtered to the given ids in reverse order, SHALL record CompensationRecord for every attempt (success or failure), and SHALL stop on first error when continueOnError: false.
Scenario: continueOnError defaults to true
- WHEN three compensations are registered and the second throws
- THEN all three records are present and the third still ran
Scenario: continueOnError false breaks on first failure
- WHEN three compensations are registered with
continueOnError: falseand the second throws - THEN only two records are present
Verified by: src/compensation/compensator/compensator.test.ts::"runs all compensations when continueOnError is true" AND src/compensation/compensator/compensator.test.ts::"stops at first failure when continueOnError is false"
Requirement: Hook registry lifecycle dispatch
HookRegistry<TCtx> SHALL register listeners for HookEvent topics, SHALL invoke listeners sequentially in registration order on emit, SHALL support once semantics (auto-unregister after the first invocation), and SHALL allow removal via the unregister callback returned from on or via off(event?).
Scenario: listeners fire in registration order
- WHEN two listeners are registered for
"workflow:start"andemitis called - THEN they fire in registration order with the same payload
eventandtimestamp
Scenario: once listener auto-unregisters
- WHEN a listener registered via
once("step:start", ...)is emitted twice - THEN the listener is invoked exactly once and
listenerCount("step:start") === 0
Verified by: src/middleware/hook/hook.test.ts::"invokes listeners in registration order" AND src/middleware/hook/hook.test.ts::"once listener auto-unregisters after first emit"
Requirement: Retry middleware (withRetry, RetryPresets)
The package SHALL expose withRetry(fn, options) that retries fn per the resolved RetryConfig, SHALL invoke onRetry(error, attempt) between attempts, SHALL respect shouldRetry(error, attempt) to short-circuit, and SHALL expose RetryPresets (none, quick, standard, aggressive) returning ready-to-use configs.
Scenario: withRetry exhausts attempts and throws the last error
- WHEN a function always throws and
withRetryruns withmaxAttempts: 3 - THEN the function is invoked exactly three times and the final rejection is the last thrown error
Scenario: shouldRetry false short-circuits
- WHEN
shouldRetryreturns false after attempt one - THEN
withRetryrejects immediately with that error and the function is invoked exactly once
Verified by: src/middleware/retry/retry.test.ts::"exhausts attempts and rethrows the final error" AND src/middleware/retry/retry.test.ts::"short-circuits when shouldRetry returns false"
Requirement: Workflow context state container
WorkflowContext<TData> SHALL hold mutable workflow data and status, SHALL accumulate StepResult entries via addStepResult, SHALL store metadata via setMeta/getMeta, SHALL produce immutable snapshots via snapshot(), and SHALL be resettable to initial data via reset(data).
Scenario: snapshot is a frozen point-in-time copy
- WHEN a context with one step result and one metadata key is snapshotted, then mutated
- THEN the snapshot reflects the original state and is not mutated by the post-snapshot changes
Verified by: src/core/context/context.test.ts::"snapshot reflects state at the moment of capture"
Requirement: Cancellation
WorkflowEngine<TData>.cancel() SHALL mark the engine cancelled. When invoked between steps, the next loop iteration SHALL transition status to "cancelled", emit workflow:cancel, and return a result with status: "cancelled".
Scenario: cancel between steps short-circuits remaining work
- WHEN a workflow's first step calls
engine.cancel()from within itsexecute - THEN subsequent steps are not invoked and the result has
status: "cancelled"
Verified by: src/core/workflow-engine/workflow-engine.test.ts::"cancels between steps and returns cancelled status"
Requirement: Workflow-level timeout
The WorkflowEngine SHALL apply config.timeout (defaulting to DEFAULT_TIMEOUT_MS) over the entire runSteps pass and SHALL produce a failed result when the timeout elapses, with the error message naming the workflow.
Scenario: workflow timeout produces failed status
- WHEN a workflow with a 10ms timeout runs a step that takes 100ms
- THEN the result has
status: "failed"anderror.messageincludes the workflow name
Verified by: src/core/workflow-engine/workflow-engine.test.ts::"fails when workflow timeout elapses"
Requirement: Transition data-pipeline hook
The engine SHALL expose transitionHooks: Hooks<WorkflowTransitionHookMap<TData>> and SHALL invoke workflow:transitioning between resolving shouldExecute and emitting step:start. Returned handlers may mutate the context field of the data, and the engine SHALL pass the resulting context to the step's execute.
Scenario: transition hook can rewrite the context handed to the step
- WHEN a
workflow:transitioninghandler returns{ ..., context: rewritten } - THEN the next step receives
rewrittenas its execute argument
Verified by: src/core/workflow-engine/workflow-engine-hooks.test.ts::"applies workflow transitioning hook context rewrite"
Requirement: Stable error codes via defineCodes
The package SHALL expose WORKFLOW_CODES registered via @suaveplan/error's defineCodes("WORKFLOW", [...]) and SHALL export a WorkflowError class extending SuaveplanError<WorkflowCode>. Codes are exactly STEP_FAILED, TIMEOUT, CANCELLED, COMPENSATION_FAILED, GUARD_REJECTED, RETRY_EXHAUSTED. Every code SHALL be reachable from a real production path.
Scenario: WorkflowError carries the stable code
- WHEN a workflow times out via
WorkflowEngine.run - THEN the surfaced error is a
WorkflowErrorwithcode === WORKFLOW_CODES.TIMEOUT
Verified by: src/errors/workflow-error.test.ts::"WorkflowError surfaces WORKFLOW.TIMEOUT when the workflow times out"
Requirement: OTel instrumentation
The package SHALL acquire a meter named @suaveplan/workflow, SHALL increment counter workflow.step.executed_total{outcome,step_id} on every step terminal state (completed, failed, skipped), SHALL increment counter workflow.step.compensated_total{outcome,step_id} on every compensation attempt (compensated, compensation-failed), and SHALL record histogram workflow.step.duration_ms{step_id} on every executed step.
Scenario: instruments are emitted from production paths
- WHEN a workflow runs one completed step and one failed step under
onError: "compensate"with a registered compensation - THEN the
executed_totalcounter records{outcome: "completed"}once and{outcome: "failed"}once; thecompensated_totalcounter records{outcome: "compensated"}at least once
Verified by: src/otel/instruments.test.ts::"emits step counters and histogram from production paths"
Requirement: Injectable Clock
Step, WorkflowEngine, Saga, Compensator, ConditionalStep, and ParallelStep SHALL accept an optional Clock (from @suaveplan/types) defaulting to Date.now. All now() reads inside the package SHALL go through the injected clock so tests can pin timing deterministically.
Scenario: injected clock controls recorded timestamps
- WHEN a
Stepis constructed with a clock returning1000 - THEN a successful run records
startedAt === 1000andcompletedAt === 1000
Verified by: src/steps/step/step.test.ts::"uses the injected clock for timestamps"
Requirement: Subpath exports per feature
The package's package.json exports map SHALL provide a subpath per feature module so consumers can tree-shake unused features (./core/workflow-engine/workflow-engine, ./core/workflow-builder/workflow-builder, ./core/context/context, ./steps/step/step, ./steps/parallel-step/parallel-step, ./steps/conditional-step/conditional-step, ./compensation/saga/saga, ./compensation/compensator/compensator, ./middleware/hook/hook, ./middleware/retry/retry, ./errors, ./otel).
Scenario: every feature module is independently importable
- WHEN dependency tooling enumerates
package.jsonexports - THEN at minimum twelve non-
.subpaths are present (one per feature module + errors + otel)
Verified by: tests/dep-graph.test.ts::"package exports map covers every feature module"