From fa1bd9e401d6558ba99576e57d2715da09c6d735 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 16:14:20 -0500 Subject: [PATCH 001/302] Improve typed task producer helpers --- packages/stem/example/stem_example.dart | 2 +- .../stem/example/task_usage_patterns.dart | 20 ++- packages/stem/lib/src/core/stem.dart | 126 ++++++++++++--- .../stem/test/unit/core/stem_core_test.dart | 149 ++++++++++++++++++ 4 files changed, 275 insertions(+), 22 deletions(-) diff --git a/packages/stem/example/stem_example.dart b/packages/stem/example/stem_example.dart index 1edd6285..9c1ce9ed 100644 --- a/packages/stem/example/stem_example.dart +++ b/packages/stem/example/stem_example.dart @@ -64,7 +64,7 @@ Future main() async { await stem.enqueue('demo.hello', args: {'name': 'Stem'}); // Typed helper with TaskDefinition for compile-time safety. - await stem.enqueueCall(HelloTask.definition(const HelloArgs(name: 'Stem'))); + await HelloTask.definition(const HelloArgs(name: 'Stem')).enqueueWith(stem); await Future.delayed(const Duration(seconds: 1)); await worker.shutdown(); await broker.close(); diff --git a/packages/stem/example/task_usage_patterns.dart b/packages/stem/example/task_usage_patterns.dart index b7879ebc..80341b02 100644 --- a/packages/stem/example/task_usage_patterns.dart +++ b/packages/stem/example/task_usage_patterns.dart @@ -46,6 +46,7 @@ FutureOr childEntrypoint( Map args, ) { final value = args['value'] as String? ?? 'unknown'; + // Example output keeps the script runnable without adding logging setup. // ignore: avoid_print print('[child] value=$value attempt=${context.attempt}'); return 'ok'; @@ -98,7 +99,24 @@ Future main() async { await stem.enqueue('tasks.parent', args: const {}); await stem.enqueue('tasks.invocation_parent', args: const {}); - await stem.enqueueCall(childDefinition.call(const ChildArgs('direct-call'))); + final directTaskId = await childDefinition + .call(const ChildArgs('direct-call')) + .enqueueWith(stem); + final directResult = await childDefinition.waitFor( + stem, + directTaskId, + timeout: const Duration(seconds: 1), + ); + // Example output keeps the script runnable without adding logging setup. + // ignore: avoid_print + print('[direct] result=${directResult?.value}'); + + final inlineResult = await childDefinition + .call(const ChildArgs('inline-wait')) + .enqueueAndWaitWith(stem, timeout: const Duration(seconds: 1)); + // Example output keeps the script runnable without adding logging setup. + // ignore: avoid_print + print('[inline] result=${inlineResult?.value}'); await Future.delayed(const Duration(seconds: 1)); await worker.shutdown(); diff --git a/packages/stem/lib/src/core/stem.dart b/packages/stem/lib/src/core/stem.dart index d363135a..715f8b4a 100644 --- a/packages/stem/lib/src/core/stem.dart +++ b/packages/stem/lib/src/core/stem.dart @@ -162,14 +162,21 @@ class Stem implements TaskEnqueuer { TaskCall call, { TaskEnqueueOptions? enqueueOptions, }) { - return enqueue( - call.name, + final definition = call.definition; + final resolvedOptions = call.resolveOptions(); + final metadata = definition.metadata; + return _enqueueResolved( + name: call.name, args: call.encodeArgs(), headers: call.headers, - options: call.resolveOptions(), + options: resolvedOptions, + fallbackOptions: definition.defaultOptions, notBefore: call.notBefore, meta: call.meta, enqueueOptions: enqueueOptions ?? call.enqueueOptions, + metadata: metadata, + argsEncoder: _resolveArgsEncoderFromMetadata(metadata), + resultEncoder: _resolveResultEncoderFromMetadata(metadata), ); } @@ -183,6 +190,38 @@ class Stem implements TaskEnqueuer { DateTime? notBefore, Map meta = const {}, TaskEnqueueOptions? enqueueOptions, + }) async { + final handler = registry.resolve(name); + if (handler == null) { + throw ArgumentError.value(name, 'name', 'Task is not registered'); + } + return _enqueueResolved( + name: name, + args: args, + headers: headers, + options: options, + fallbackOptions: handler.options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + metadata: handler.metadata, + argsEncoder: _resolveArgsEncoder(handler), + resultEncoder: _resolveResultEncoder(handler), + ); + } + + Future _enqueueResolved({ + required String name, + required Map args, + required Map headers, + required TaskOptions options, + required TaskOptions fallbackOptions, + required DateTime? notBefore, + required Map meta, + required TaskEnqueueOptions? enqueueOptions, + required TaskMetadata metadata, + required TaskPayloadEncoder argsEncoder, + required TaskPayloadEncoder resultEncoder, }) async { final tracer = StemTracer.instance; final queueOverride = enqueueOptions?.queue ?? options.queue; @@ -192,14 +231,6 @@ class Stem implements TaskEnqueuer { final targetName = decision.targetName; final basePriority = enqueueOptions?.priority ?? options.priority; final resolvedPriority = decision.effectivePriority(basePriority); - - final handler = registry.resolve(name); - if (handler == null) { - throw ArgumentError.value(name, 'name', 'Task is not registered'); - } - final metadata = handler.metadata; - final argsEncoder = _resolveArgsEncoder(handler); - final resultEncoder = _resolveResultEncoder(handler); final scopeMeta = TaskEnqueueScope.currentMeta(); final mergedMeta = scopeMeta == null ? meta @@ -225,7 +256,7 @@ class Stem implements TaskEnqueuer { ); final maxRetries = _resolveMaxRetries( options, - handler.options, + fallbackOptions, enqueueOptions, ); final taskId = enqueueOptions?.taskId ?? generateEnvelopeId(); @@ -472,10 +503,8 @@ class Stem implements TaskEnqueuer { } /// Waits for [taskId] using the decoding rules from a [TaskDefinition]. - Future?> waitForTaskDefinition< - TArgs, - TResult extends Object? - >( + Future?> + waitForTaskDefinition( String taskId, TaskDefinition definition, { Duration? timeout, @@ -880,14 +909,24 @@ class Stem implements TaskEnqueuer { /// Resolves the args encoder for a handler and registers it if needed. TaskPayloadEncoder _resolveArgsEncoder(TaskHandler handler) { - final encoder = handler.metadata.argsEncoder; - payloadEncoders.register(encoder); - return encoder ?? payloadEncoders.defaultArgsEncoder; + return _resolveArgsEncoderFromMetadata(handler.metadata); } /// Resolves the result encoder for a handler and registers it if needed. TaskPayloadEncoder _resolveResultEncoder(TaskHandler handler) { - final encoder = handler.metadata.resultEncoder; + return _resolveResultEncoderFromMetadata(handler.metadata); + } + + /// Resolves the args encoder for producer-side task metadata. + TaskPayloadEncoder _resolveArgsEncoderFromMetadata(TaskMetadata metadata) { + final encoder = metadata.argsEncoder; + payloadEncoders.register(encoder); + return encoder ?? payloadEncoders.defaultArgsEncoder; + } + + /// Resolves the result encoder for producer-side task metadata. + TaskPayloadEncoder _resolveResultEncoderFromMetadata(TaskMetadata metadata) { + final encoder = metadata.resultEncoder; payloadEncoders.register(encoder); return encoder ?? payloadEncoders.defaultResultEncoder; } @@ -981,3 +1020,50 @@ extension TaskEnqueueBuilderExtension ); } } + +/// Convenience helpers for dispatching prebuilt [TaskCall] instances. +extension TaskCallExtension + on TaskCall { + /// Enqueues this typed call with the provided [enqueuer]. + /// + /// Ambient [TaskEnqueueScope] metadata is merged the same way as the fluent + /// [TaskEnqueueBuilder] helper so producers and task contexts behave + /// consistently. + Future enqueueWith( + TaskEnqueuer enqueuer, { + TaskEnqueueOptions? enqueueOptions, + }) { + final scopeMeta = TaskEnqueueScope.currentMeta(); + if (scopeMeta == null || scopeMeta.isEmpty) { + return enqueuer.enqueueCall(this, enqueueOptions: enqueueOptions); + } + final mergedMeta = Map.from(scopeMeta)..addAll(meta); + return enqueuer.enqueueCall( + copyWith(meta: Map.unmodifiable(mergedMeta)), + enqueueOptions: enqueueOptions, + ); + } + + /// Enqueues this call on [stem] and waits for the typed task result. + Future?> enqueueAndWaitWith( + Stem stem, { + TaskEnqueueOptions? enqueueOptions, + Duration? timeout, + }) async { + final taskId = await enqueueWith(stem, enqueueOptions: enqueueOptions); + return definition.waitFor(stem, taskId, timeout: timeout); + } +} + +/// Convenience helpers for waiting on typed task definitions. +extension TaskDefinitionExtension + on TaskDefinition { + /// Waits for [taskId] using this definition's decoding rules. + Future?> waitFor( + Stem stem, + String taskId, { + Duration? timeout, + }) { + return stem.waitForTaskDefinition(taskId, this, timeout: timeout); + } +} diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index eda93ff9..3c00a4f5 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -77,6 +77,140 @@ void main() { expect(backend.records.single.id, equals(id)); expect(backend.records.single.state, equals(TaskState.queued)); }); + + test( + 'enqueueCall publishes typed calls without requiring registry handlers', + () async { + final broker = _RecordingBroker(); + final backend = _RecordingBackend(); + final stem = Stem(broker: broker, backend: backend); + final definition = TaskDefinition<({String value}), Object?>( + name: 'sample.typed', + encodeArgs: (args) => {'value': args.value}, + defaultOptions: const TaskOptions(queue: 'typed'), + ); + + final id = await stem.enqueueCall(definition.call((value: 'ok'))); + + expect(id, isNotEmpty); + expect(broker.published.single.envelope.name, 'sample.typed'); + expect(broker.published.single.envelope.queue, 'typed'); + expect(backend.records.single.id, id); + expect(backend.records.single.state, TaskState.queued); + }, + ); + + test( + 'enqueueCall uses definition encoder metadata on producer-only paths', + () async { + final broker = _RecordingBroker(); + final backend = _RecordingBackend(); + final stem = Stem( + broker: broker, + backend: backend, + encoderRegistry: ensureTaskPayloadEncoderRegistry( + null, + additionalEncoders: [_codecReceiptEncoder, _passthroughMapEncoder], + ), + ); + final definition = TaskDefinition<({String value}), _CodecReceipt>( + name: 'sample.typed.encoded', + encodeArgs: (args) => {'value': args.value}, + metadata: const TaskMetadata( + argsEncoder: _passthroughMapEncoder, + resultEncoder: _codecReceiptEncoder, + ), + ); + + final id = await stem.enqueueCall( + definition.call((value: 'encoded')), + ); + + expect( + broker.published.single.envelope.headers[stemArgsEncoderHeader], + _passthroughMapEncoder.id, + ); + expect( + backend.records.single.meta[stemResultEncoderMetaKey], + _codecReceiptEncoder.id, + ); + expect(backend.records.single.id, id); + }, + ); + }); + + group('TaskCall helpers', () { + test('enqueueWith enqueues typed calls with scoped metadata', () async { + final broker = _RecordingBroker(); + final backend = _RecordingBackend(); + final stem = Stem(broker: broker, backend: backend); + final definition = TaskDefinition<({String value}), String>( + name: 'sample.task_call', + encodeArgs: (args) => {'value': args.value}, + defaultOptions: const TaskOptions(queue: 'typed'), + ); + + final taskId = await TaskEnqueueScope.run({'traceId': 'scope-1'}, () { + return definition.call((value: 'ok')).enqueueWith(stem); + }); + + expect(taskId, isNotEmpty); + expect(broker.published.single.envelope.name, 'sample.task_call'); + expect(broker.published.single.envelope.queue, 'typed'); + expect( + broker.published.single.envelope.meta, + containsPair('traceId', 'scope-1'), + ); + }); + + test('enqueueAndWaitWith returns typed results', () async { + final broker = _RecordingBroker(); + final backend = _RecordingBackend(); + final stem = Stem(broker: broker, backend: backend); + final definition = TaskDefinition<({String value}), String>( + name: 'sample.task_call_wait', + encodeArgs: (args) => {'value': args.value}, + ); + + unawaited( + Future(() async { + while (broker.published.isEmpty) { + await Future.delayed(Duration.zero); + } + final taskId = broker.published.single.envelope.id; + await backend.set(taskId, TaskState.succeeded, payload: 'done'); + }), + ); + + final result = await definition + .call((value: 'ok')) + .enqueueAndWaitWith(stem, timeout: const Duration(seconds: 1)); + + expect(result?.isSucceeded, isTrue); + expect(result?.value, 'done'); + }); + }); + + group('TaskDefinition.waitFor', () { + test('uses definition decoding rules', () async { + final backend = _codecAwareBackend(); + final stem = _codecAwareStem(backend); + + await backend.set( + 'task-definition-wait', + TaskState.succeeded, + payload: const _CodecReceipt('receipt-definition'), + meta: {stemResultEncoderMetaKey: _codecReceiptEncoder.id}, + ); + + final result = await _codecReceiptDefinition.waitFor( + stem, + 'task-definition-wait', + ); + + expect(result?.value?.id, 'receipt-definition'); + expect(result?.rawPayload, isA<_CodecReceipt>()); + }); }); group('Stem.waitForTaskDefinition', () { @@ -168,6 +302,8 @@ const _codecReceiptEncoder = CodecTaskPayloadEncoder<_CodecReceipt>( codec: _codecReceiptCodec, ); +const _passthroughMapEncoder = _MapPassthroughEncoder('test.args.map'); + final _codecReceiptDefinition = TaskDefinition, _CodecReceipt>( name: 'codec.receipt', @@ -277,6 +413,19 @@ class _RecordingBroker implements Broker { Future close() async {} } +class _MapPassthroughEncoder implements TaskPayloadEncoder { + const _MapPassthroughEncoder(this.id); + + @override + final String id; + + @override + Object? encode(Object? value) => value; + + @override + Object? decode(Object? value) => value; +} + class _RecordingBackend implements ResultBackend { final List records = []; final Map> _controllers = {}; From 91d9038c64f485b09eaba09105365ad2c3abff85 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 16:20:20 -0500 Subject: [PATCH 002/302] Add runtime helpers for typed workflow refs --- .../workflow/runtime/workflow_runtime.dart | 68 +++++++++++++++++++ .../workflow/workflow_runtime_ref_test.dart | 49 +++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 packages/stem/test/workflow/workflow_runtime_ref_test.dart diff --git a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart index 18aa338b..bf03dfa7 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart @@ -44,6 +44,7 @@ import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; import 'package:stem/src/workflow/core/workflow_clock.dart'; import 'package:stem/src/workflow/core/workflow_definition.dart'; import 'package:stem/src/workflow/core/workflow_ref.dart'; +import 'package:stem/src/workflow/core/workflow_result.dart'; import 'package:stem/src/workflow/core/workflow_runtime_metadata.dart'; import 'package:stem/src/workflow/core/workflow_script_context.dart'; import 'package:stem/src/workflow/core/workflow_status.dart'; @@ -238,6 +239,45 @@ class WorkflowRuntime { ); } + /// Waits for [runId] to reach a terminal state. + Future?> waitForCompletion( + String runId, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + T Function(Object? payload)? decode, + }) async { + final startedAt = _clock.now(); + while (true) { + final state = await _store.get(runId); + if (state == null) { + return null; + } + if (state.isTerminal) { + return _buildResult(state, decode, timedOut: false); + } + if (timeout != null && _clock.now().difference(startedAt) >= timeout) { + return _buildResult(state, decode, timedOut: true); + } + await Future.delayed(pollInterval); + } + } + + /// Waits for [runId] using the decoding rules from a [WorkflowRef]. + Future?> + waitForWorkflowRef( + String runId, + WorkflowRef definition, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return waitForCompletion( + runId, + pollInterval: pollInterval, + timeout: timeout, + decode: definition.decode, + ); + } + /// Emits an external event and resumes all runs waiting on [topic]. /// /// Each resumed run receives the event as `resumeData` for the awaiting step @@ -276,6 +316,34 @@ class WorkflowRuntime { } } + WorkflowResult _buildResult( + RunState state, + T Function(Object? payload)? decode, { + required bool timedOut, + }) { + final value = state.status == WorkflowStatus.completed + ? _decodeResult(state.result, decode) + : null; + return WorkflowResult( + runId: state.id, + status: state.status, + state: state, + value: value, + rawResult: state.result, + timedOut: timedOut && !state.isTerminal, + ); + } + + T? _decodeResult( + Object? payload, + T Function(Object? payload)? decode, + ) { + if (decode != null) { + return decode(payload); + } + return payload as T?; + } + /// Emits a typed external event that serializes to the existing map-based /// workflow event transport. /// diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart new file mode 100644 index 00000000..89b4b0d0 --- /dev/null +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -0,0 +1,49 @@ +import 'package:stem/stem.dart'; +import 'package:test/test.dart'; + +void main() { + group('runtime workflow refs', () { + test('start and wait helpers work directly with WorkflowRuntime', () async { + final flow = Flow( + name: 'runtime.ref.flow', + build: (builder) { + builder.step('hello', (ctx) async { + final name = ctx.params['name'] as String? ?? 'world'; + return 'hello $name'; + }); + }, + ); + final workflowRef = WorkflowRef, String>( + name: 'runtime.ref.flow', + encodeParams: (params) => params, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + await workflowApp.start(); + + final runId = await workflowRef + .call(const {'name': 'runtime'}) + .startWithRuntime(workflowApp.runtime); + final waited = await workflowRef.waitForWithRuntime( + workflowApp.runtime, + runId, + timeout: const Duration(seconds: 2), + ); + + expect(waited?.value, 'hello runtime'); + + final oneShot = await workflowRef + .call(const {'name': 'inline'}) + .startAndWaitWithRuntime( + workflowApp.runtime, + timeout: const Duration(seconds: 2), + ); + + expect(oneShot?.value, 'hello inline'); + } finally { + await workflowApp.shutdown(); + } + }); + }); +} From d3e2885094227efb5cc0096e5a283de7c7f4c793 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 16:22:10 -0500 Subject: [PATCH 003/302] Add runtime workflow call extensions --- .../stem/lib/src/bootstrap/workflow_app.dart | 30 +++++++++++ ...workflow_runtime_call_extensions_test.dart | 52 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index c5254e23..f08c1d21 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -565,6 +565,21 @@ extension WorkflowStartCallAppExtension Future startWithRuntime(WorkflowRuntime runtime) { return runtime.startWorkflowCall(this); } + + /// Starts this workflow call with [runtime] and waits for the typed result. + Future?> startAndWaitWithRuntime( + WorkflowRuntime runtime, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) async { + final runId = await runtime.startWorkflowCall(this); + return runtime.waitForWorkflowRef( + runId, + definition, + pollInterval: pollInterval, + timeout: timeout, + ); + } } /// Convenience helpers for waiting on workflow results using a typed reference. @@ -584,4 +599,19 @@ extension WorkflowRefAppExtension timeout: timeout, ); } + + /// Waits for [runId] using this workflow reference and [runtime]. + Future?> waitForWithRuntime( + WorkflowRuntime runtime, + String runId, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return runtime.waitForWorkflowRef( + runId, + this, + pollInterval: pollInterval, + timeout: timeout, + ); + } } diff --git a/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart b/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart new file mode 100644 index 00000000..eb9b985a --- /dev/null +++ b/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart @@ -0,0 +1,52 @@ +import 'package:stem/stem.dart'; +import 'package:test/test.dart'; + +void main() { + group('runtime workflow call extensions', () { + test( + 'startAndWaitWithRuntime and waitForWithRuntime use typed workflow refs', + () async { + final flow = Flow( + name: 'runtime.extension.flow', + build: (builder) { + builder.step('hello', (ctx) async { + final name = ctx.params['name'] as String? ?? 'world'; + return 'hello $name'; + }); + }, + ); + final workflowRef = WorkflowRef, String>( + name: 'runtime.extension.flow', + encodeParams: (params) => params, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + await workflowApp.start(); + + final runId = await workflowRef + .call(const {'name': 'runtime'}) + .startWithRuntime(workflowApp.runtime); + final waited = await workflowRef.waitForWithRuntime( + workflowApp.runtime, + runId, + timeout: const Duration(seconds: 2), + ); + + expect(waited?.value, 'hello runtime'); + + final oneShot = await workflowRef + .call(const {'name': 'inline'}) + .startAndWaitWithRuntime( + workflowApp.runtime, + timeout: const Duration(seconds: 2), + ); + + expect(oneShot?.value, 'hello inline'); + } finally { + await workflowApp.shutdown(); + } + }, + ); + }); +} From 297142b1faabcc31792d7e662dccde2880717f8e Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 16:24:36 -0500 Subject: [PATCH 004/302] Add module-aware app bootstrap --- packages/stem/lib/src/bootstrap/stem_app.dart | 36 +++++-- .../stem/lib/src/bootstrap/stem_client.dart | 26 ++++- .../stem/lib/src/bootstrap/stem_module.dart | 95 ++++++++++++++++++ .../test/bootstrap/module_bootstrap_test.dart | 96 +++++++++++++++++++ 4 files changed, 245 insertions(+), 8 deletions(-) create mode 100644 packages/stem/test/bootstrap/module_bootstrap_test.dart diff --git a/packages/stem/lib/src/bootstrap/stem_app.dart b/packages/stem/lib/src/bootstrap/stem_app.dart index ec76be59..b3bcd18d 100644 --- a/packages/stem/lib/src/bootstrap/stem_app.dart +++ b/packages/stem/lib/src/bootstrap/stem_app.dart @@ -1,6 +1,7 @@ import 'package:stem/src/backend/encoding_result_backend.dart'; import 'package:stem/src/bootstrap/factories.dart'; import 'package:stem/src/bootstrap/stem_client.dart'; +import 'package:stem/src/bootstrap/stem_module.dart'; import 'package:stem/src/bootstrap/stem_stack.dart'; import 'package:stem/src/canvas/canvas.dart'; import 'package:stem/src/control/revoke_store.dart'; @@ -87,6 +88,7 @@ class StemApp { /// Creates a new Stem application with the provided configuration. static Future create({ + StemModule? module, Iterable> tasks = const [], TaskRegistry? registry, StemBrokerFactory? broker, @@ -103,8 +105,10 @@ class StemApp { TaskPayloadEncoder argsEncoder = const JsonTaskPayloadEncoder(), Iterable additionalEncoders = const [], }) async { + final bundledTasks = module?.tasks ?? const >[]; + final allTasks = [...bundledTasks, ...tasks]; final taskRegistry = registry ?? InMemoryTaskRegistry(); - tasks.forEach(taskRegistry.register); + registerModuleTaskHandlers(taskRegistry, allTasks); final brokerFactory = broker ?? StemBrokerFactory.inMemory(); final backendFactory = backend ?? StemBackendFactory.inMemory(); @@ -143,6 +147,12 @@ class StemApp { final workerUniqueTaskCoordinator = workerConfig.uniqueTaskCoordinator ?? uniqueTaskCoordinator; final workerSigner = workerConfig.signer ?? signer; + final inferredSubscription = + workerConfig.subscription ?? + module?.inferTaskWorkerSubscription( + defaultQueue: workerConfig.queue, + additionalTasks: tasks, + ); final worker = Worker( broker: brokerInstance, @@ -154,7 +164,7 @@ class StemApp { uniqueTaskCoordinator: workerUniqueTaskCoordinator, retryStrategy: workerRetryStrategy, queue: workerConfig.queue, - subscription: workerConfig.subscription, + subscription: inferredSubscription, consumerName: workerConfig.consumerName, concurrency: workerConfig.concurrency, prefetchMultiplier: workerConfig.prefetchMultiplier, @@ -194,6 +204,7 @@ class StemApp { /// Creates an in-memory Stem application (broker + result backend). static Future inMemory({ + StemModule? module, Iterable> tasks = const [], StemWorkerConfig workerConfig = const StemWorkerConfig(), TaskPayloadEncoderRegistry? encoderRegistry, @@ -202,6 +213,7 @@ class StemApp { Iterable additionalEncoders = const [], }) { return StemApp.create( + module: module, tasks: tasks, broker: StemBrokerFactory.inMemory(), backend: StemBackendFactory.inMemory(), @@ -219,6 +231,7 @@ class StemApp { /// can optionally auto-wire revoke and unique-task coordination stores. static Future fromUrl( String url, { + StemModule? module, Iterable> tasks = const [], TaskRegistry? registry, Iterable adapters = const [], @@ -292,6 +305,7 @@ class StemApp { try { final app = await create( + module: module, tasks: tasks, registry: registry, broker: resolvedStack.broker, @@ -331,14 +345,24 @@ class StemApp { /// Creates a Stem app using a shared [StemClient]. static Future fromClient( StemClient client, { + StemModule? module, Iterable> tasks = const [], StemWorkerConfig workerConfig = const StemWorkerConfig(), }) async { - tasks.forEach(client.taskRegistry.register); + final bundledTasks = module?.tasks ?? const >[]; + final allTasks = [...bundledTasks, ...tasks]; + final taskRegistry = client.taskRegistry; + registerModuleTaskHandlers(taskRegistry, allTasks); + final inferredSubscription = + workerConfig.subscription ?? + module?.inferTaskWorkerSubscription( + defaultQueue: workerConfig.queue, + additionalTasks: tasks, + ); final worker = Worker( broker: client.broker, - registry: client.taskRegistry, + registry: taskRegistry, backend: client.backend, enqueuer: client.stem, rateLimiter: workerConfig.rateLimiter, @@ -348,7 +372,7 @@ class StemApp { workerConfig.uniqueTaskCoordinator ?? client.uniqueTaskCoordinator, retryStrategy: workerConfig.retryStrategy ?? client.retryStrategy, queue: workerConfig.queue, - subscription: workerConfig.subscription, + subscription: inferredSubscription, consumerName: workerConfig.consumerName, concurrency: workerConfig.concurrency, prefetchMultiplier: workerConfig.prefetchMultiplier, @@ -365,7 +389,7 @@ class StemApp { ); return StemApp._( - registry: client.taskRegistry, + registry: taskRegistry, broker: client.broker, backend: client.backend, stem: client.stem, diff --git a/packages/stem/lib/src/bootstrap/stem_client.dart b/packages/stem/lib/src/bootstrap/stem_client.dart index 6a7fd57d..2a519c4c 100644 --- a/packages/stem/lib/src/bootstrap/stem_client.dart +++ b/packages/stem/lib/src/bootstrap/stem_client.dart @@ -21,6 +21,7 @@ import 'package:stem/src/workflow/runtime/workflow_registry.dart'; abstract class StemClient { /// Creates a client using the provided factories and defaults. static Future create({ + StemModule? module, Iterable> tasks = const [], TaskRegistry? taskRegistry, WorkflowRegistry? workflowRegistry, @@ -39,6 +40,7 @@ abstract class StemClient { }) async { return _DefaultStemClient.create( tasks: tasks, + module: module, taskRegistry: taskRegistry, workflowRegistry: workflowRegistry, broker: broker, @@ -58,6 +60,7 @@ abstract class StemClient { /// Creates an in-memory client using in-memory broker/backend. static Future inMemory({ + StemModule? module, Iterable> tasks = const [], StemWorkerConfig defaultWorkerConfig = const StemWorkerConfig(), TaskPayloadEncoderRegistry? encoderRegistry, @@ -66,6 +69,7 @@ abstract class StemClient { Iterable additionalEncoders = const [], }) { return create( + module: module, tasks: tasks, broker: StemBrokerFactory.inMemory(), backend: StemBackendFactory.inMemory(), @@ -83,6 +87,7 @@ abstract class StemClient { /// can avoid manual factory wiring for common Redis/Postgres/SQLite setups. static Future fromUrl( String url, { + StemModule? module, Iterable> tasks = const [], TaskRegistry? taskRegistry, WorkflowRegistry? workflowRegistry, @@ -105,6 +110,7 @@ abstract class StemClient { overrides: overrides, ); return create( + module: module, tasks: tasks, taskRegistry: taskRegistry, workflowRegistry: workflowRegistry, @@ -135,6 +141,9 @@ abstract class StemClient { /// Shared workflow registry for workflow definitions. WorkflowRegistry get workflowRegistry; + /// Optional default bundle registered into this client. + StemModule? get module; + /// Enqueue facade for producers. Stem get stem; @@ -208,9 +217,10 @@ abstract class StemClient { Duration leaseExtension = const Duration(seconds: 30), WorkflowIntrospectionSink? introspectionSink, }) { + final effectiveModule = module ?? this.module; return StemWorkflowApp.fromClient( client: this, - module: module, + module: effectiveModule, workflows: workflows, flows: flows, scripts: scripts, @@ -225,11 +235,14 @@ abstract class StemClient { /// Creates a StemApp wrapper using the shared client configuration. Future createApp({ + StemModule? module, Iterable> tasks = const [], StemWorkerConfig? workerConfig, }) { + final effectiveModule = module ?? this.module; return StemApp.fromClient( this, + module: effectiveModule, tasks: tasks, workerConfig: workerConfig ?? defaultWorkerConfig, ); @@ -245,6 +258,7 @@ class _DefaultStemClient extends StemClient { required this.backend, required this.taskRegistry, required this.workflowRegistry, + required this.module, required this.stem, required this.encoderRegistry, required this.routing, @@ -258,6 +272,7 @@ class _DefaultStemClient extends StemClient { }) : middleware = List.unmodifiable(middleware); static Future create({ + StemModule? module, Iterable> tasks = const [], TaskRegistry? taskRegistry, WorkflowRegistry? workflowRegistry, @@ -274,9 +289,12 @@ class _DefaultStemClient extends StemClient { TaskPayloadEncoder argsEncoder = const JsonTaskPayloadEncoder(), Iterable additionalEncoders = const [], }) async { + final bundledTasks = module?.tasks ?? const >[]; + final allTasks = [...bundledTasks, ...tasks]; final registry = taskRegistry ?? InMemoryTaskRegistry(); - tasks.forEach(registry.register); + registerModuleTaskHandlers(registry, allTasks); final workflows = workflowRegistry ?? InMemoryWorkflowRegistry(); + module?.registerInto(workflows: workflows); final brokerFactory = broker ?? StemBrokerFactory.inMemory(); final backendFactory = backend ?? StemBackendFactory.inMemory(); @@ -305,6 +323,7 @@ class _DefaultStemClient extends StemClient { backend: backendInstance, taskRegistry: registry, workflowRegistry: workflows, + module: module, stem: stem, encoderRegistry: stem.payloadEncoders, routing: stem.routing, @@ -330,6 +349,9 @@ class _DefaultStemClient extends StemClient { @override final WorkflowRegistry workflowRegistry; + @override + final StemModule? module; + @override final Stem stem; diff --git a/packages/stem/lib/src/bootstrap/stem_module.dart b/packages/stem/lib/src/bootstrap/stem_module.dart index 4c6748cf..a634dfb9 100644 --- a/packages/stem/lib/src/bootstrap/stem_module.dart +++ b/packages/stem/lib/src/bootstrap/stem_module.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/workflow/core/flow.dart'; import 'package:stem/src/workflow/core/workflow_definition.dart'; @@ -5,6 +7,21 @@ import 'package:stem/src/workflow/core/workflow_script.dart'; import 'package:stem/src/workflow/runtime/workflow_manifest.dart'; import 'package:stem/src/workflow/runtime/workflow_registry.dart'; +/// Registers task handlers while tolerating re-registration of identical +/// handler instances. +void registerModuleTaskHandlers( + TaskRegistry registry, + Iterable> handlers, +) { + for (final handler in handlers) { + final existing = registry.resolve(handler.name); + if (identical(existing, handler)) { + continue; + } + registry.register(handler); + } +} + /// Generated or hand-authored bundle of tasks and workflow definitions. /// /// The intended use is to pass one module into bootstrap helpers rather than @@ -70,6 +87,84 @@ class StemModule { } } + /// Returns the default queues implied by the bundled task handlers. + /// + /// The [workflowQueue] is always included so workflow orchestration remains + /// runnable when the inferred queues are used to bootstrap a worker. + List inferredWorkerQueues({ + String workflowQueue = 'workflow', + Iterable> additionalTasks = const [], + }) { + final queues = SplayTreeSet(); + final normalizedWorkflowQueue = workflowQueue.trim(); + if (normalizedWorkflowQueue.isNotEmpty) { + queues.add(normalizedWorkflowQueue); + } + + void addTaskQueue(TaskHandler handler) { + final queue = handler.options.queue.trim(); + if (queue.isNotEmpty) { + queues.add(queue); + } + } + + tasks.forEach(addTaskQueue); + additionalTasks.forEach(addTaskQueue); + return queues.toList(growable: false); + } + + /// Infers a worker subscription from the bundled task handlers. + /// + /// Returns `null` when only the [workflowQueue] is needed, allowing the + /// worker's default queue configuration to remain unchanged. + RoutingSubscription? inferWorkerSubscription({ + String workflowQueue = 'workflow', + Iterable> additionalTasks = const [], + }) { + final queues = inferredWorkerQueues( + workflowQueue: workflowQueue, + additionalTasks: additionalTasks, + ); + if (queues.length <= 1) { + return null; + } + return RoutingSubscription(queues: queues); + } + + /// Returns the default queues implied by the bundled task handlers only. + List inferredTaskQueues({ + Iterable> additionalTasks = const [], + }) { + final queues = SplayTreeSet(); + + void addTaskQueue(TaskHandler handler) { + final queue = handler.options.queue.trim(); + if (queue.isNotEmpty) { + queues.add(queue); + } + } + + tasks.forEach(addTaskQueue); + additionalTasks.forEach(addTaskQueue); + return queues.toList(growable: false); + } + + /// Infers a worker subscription from bundled task handlers only. + /// + /// Returns `null` when the bundled tasks only target [defaultQueue], allowing + /// the worker's default queue configuration to remain unchanged. + RoutingSubscription? inferTaskWorkerSubscription({ + String defaultQueue = 'default', + Iterable> additionalTasks = const [], + }) { + final queues = inferredTaskQueues(additionalTasks: additionalTasks); + if (queues.isEmpty) return null; + if (queues.length == 1 && queues.first == defaultQueue.trim()) { + return null; + } + return RoutingSubscription(queues: queues); + } + static Iterable _defaultManifest({ required Iterable workflows, required Iterable flows, diff --git a/packages/stem/test/bootstrap/module_bootstrap_test.dart b/packages/stem/test/bootstrap/module_bootstrap_test.dart new file mode 100644 index 00000000..e6c9389e --- /dev/null +++ b/packages/stem/test/bootstrap/module_bootstrap_test.dart @@ -0,0 +1,96 @@ +import 'package:stem/stem.dart'; +import 'package:test/test.dart'; + +void main() { + group('module bootstrap', () { + test('StemApp.inMemory registers module tasks and infers queues', () async { + final moduleTask = FunctionTaskHandler( + name: 'module.bootstrap.task', + options: const TaskOptions(queue: 'priority'), + entrypoint: (context, args) async => 'task-ok', + runInIsolate: false, + ); + + final app = await StemApp.inMemory( + module: StemModule(tasks: [moduleTask]), + ); + await app.start(); + try { + expect(app.registry.resolve('module.bootstrap.task'), same(moduleTask)); + expect(app.worker.subscription.queues, ['priority']); + + final taskId = await app.stem.enqueue( + 'module.bootstrap.task', + enqueueOptions: const TaskEnqueueOptions(queue: 'priority'), + ); + final result = await app.stem.waitForTask( + taskId, + timeout: const Duration(seconds: 2), + ); + + expect(result?.value, 'task-ok'); + } finally { + await app.close(); + } + }); + + test('StemClient.createApp reuses its default module', () async { + final moduleTask = FunctionTaskHandler( + name: 'module.client.task', + options: const TaskOptions(queue: 'priority'), + entrypoint: (context, args) async => 'task-ok', + runInIsolate: false, + ); + final client = await StemClient.inMemory( + module: StemModule(tasks: [moduleTask]), + ); + + final app = await client.createApp(); + await app.start(); + try { + expect(app.registry.resolve('module.client.task'), same(moduleTask)); + expect(app.worker.subscription.queues, ['priority']); + } finally { + await app.close(); + await client.close(); + } + }); + + test('StemClient.createWorkflowApp reuses its default module', () async { + final moduleTask = FunctionTaskHandler( + name: 'module.client.workflow-task', + entrypoint: (context, args) async => 'task-ok', + runInIsolate: false, + ); + final moduleFlow = Flow( + name: 'module.client.workflow', + build: (builder) { + builder.step('hello', (ctx) async => 'module-ok'); + }, + ); + final client = await StemClient.inMemory( + module: StemModule(flows: [moduleFlow], tasks: [moduleTask]), + ); + + final app = await client.createWorkflowApp(); + await app.start(); + try { + expect( + app.app.registry.resolve('module.client.workflow-task'), + same(moduleTask), + ); + + final runId = await app.startWorkflow('module.client.workflow'); + final result = await app.waitForCompletion( + runId, + timeout: const Duration(seconds: 2), + ); + + expect(result?.value, 'module-ok'); + } finally { + await app.close(); + await client.close(); + } + }); + }); +} From c3c50ba093b072962b06b4b2d3744684f9d19d21 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 16:26:31 -0500 Subject: [PATCH 005/302] Infer workflow app subscriptions from modules --- .../stem/lib/src/bootstrap/factories.dart | 47 ++++++++++ .../stem/lib/src/bootstrap/workflow_app.dart | 90 ++++++++++++++++--- .../workflow_module_bootstrap_test.dart | 58 ++++++++++++ 3 files changed, 181 insertions(+), 14 deletions(-) create mode 100644 packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart diff --git a/packages/stem/lib/src/bootstrap/factories.dart b/packages/stem/lib/src/bootstrap/factories.dart index 6ad9e45d..259d969f 100644 --- a/packages/stem/lib/src/bootstrap/factories.dart +++ b/packages/stem/lib/src/bootstrap/factories.dart @@ -228,4 +228,51 @@ class StemWorkerConfig { /// Optional payload signer used to verify envelopes. final PayloadSigner? signer; + + /// Returns a copy of this worker configuration with the provided overrides. + StemWorkerConfig copyWith({ + String? queue, + String? consumerName, + int? concurrency, + int? prefetchMultiplier, + int? prefetch, + RateLimiter? rateLimiter, + List? middleware, + RevokeStore? revokeStore, + UniqueTaskCoordinator? uniqueTaskCoordinator, + RetryStrategy? retryStrategy, + RoutingSubscription? subscription, + Duration? heartbeatInterval, + Duration? workerHeartbeatInterval, + HeartbeatTransport? heartbeatTransport, + String? heartbeatNamespace, + WorkerAutoscaleConfig? autoscale, + WorkerLifecycleConfig? lifecycle, + ObservabilityConfig? observability, + PayloadSigner? signer, + }) { + return StemWorkerConfig( + queue: queue ?? this.queue, + consumerName: consumerName ?? this.consumerName, + concurrency: concurrency ?? this.concurrency, + prefetchMultiplier: prefetchMultiplier ?? this.prefetchMultiplier, + prefetch: prefetch ?? this.prefetch, + rateLimiter: rateLimiter ?? this.rateLimiter, + middleware: middleware ?? this.middleware, + revokeStore: revokeStore ?? this.revokeStore, + uniqueTaskCoordinator: + uniqueTaskCoordinator ?? this.uniqueTaskCoordinator, + retryStrategy: retryStrategy ?? this.retryStrategy, + subscription: subscription ?? this.subscription, + heartbeatInterval: heartbeatInterval ?? this.heartbeatInterval, + workerHeartbeatInterval: + workerHeartbeatInterval ?? this.workerHeartbeatInterval, + heartbeatTransport: heartbeatTransport ?? this.heartbeatTransport, + heartbeatNamespace: heartbeatNamespace ?? this.heartbeatNamespace, + autoscale: autoscale ?? this.autoscale, + lifecycle: lifecycle ?? this.lifecycle, + observability: observability ?? this.observability, + signer: signer ?? this.signer, + ); + } } diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index f08c1d21..54698376 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -28,7 +28,7 @@ import 'package:stem/src/workflow/runtime/workflow_runtime.dart'; /// This wrapper wires together broker/backend infrastructure, registers flows, /// and exposes convenience helpers for scheduling and observing workflow runs /// without having to manage [WorkflowRuntime] directly. -class StemWorkflowApp { +class StemWorkflowApp implements WorkflowCaller { StemWorkflowApp._({ required this.app, required this.runtime, @@ -117,6 +117,7 @@ class StemWorkflowApp { } /// Schedules a workflow run from a typed [WorkflowRef]. + @override Future startWorkflowRef( WorkflowRef definition, TParams params, { @@ -145,6 +146,7 @@ class StemWorkflowApp { } /// Schedules a workflow run from a prebuilt [WorkflowStartCall]. + @override Future startWorkflowCall( WorkflowStartCall call, ) { @@ -220,10 +222,8 @@ class StemWorkflowApp { } /// Waits for [runId] using the decoding rules from a [WorkflowRef]. - Future?> waitForWorkflowRef< - TParams, - TResult extends Object? - >( + Future?> + waitForWorkflowRef( String runId, WorkflowRef definition, { Duration pollInterval = const Duration(milliseconds: 100), @@ -288,7 +288,10 @@ class StemWorkflowApp { /// Creates a workflow app with custom backends and factories. /// /// Useful for wiring Redis/Postgres adapters or sharing an existing - /// [StemApp] instance with job processors. + /// [StemApp] instance with job processors. When [module] or [tasks] are + /// provided and [StemWorkerConfig.subscription] is omitted, the helper + /// infers a worker subscription that includes the workflow queue plus the + /// default queues declared on those task handlers. /// /// Example: /// ```dart @@ -322,12 +325,19 @@ class StemWorkflowApp { final moduleTasks = module?.tasks ?? const >[]; final moduleWorkflowDefinitions = module?.workflowDefinitions ?? const []; + final resolvedWorkerConfig = stemApp == null + ? _resolveWorkflowWorkerConfig( + workerConfig, + module: module, + tasks: tasks, + ) + : workerConfig; final appInstance = stemApp ?? await StemApp.create( broker: broker ?? StemBrokerFactory.inMemory(), backend: backend ?? StemBackendFactory.inMemory(), - workerConfig: workerConfig, + workerConfig: resolvedWorkerConfig, encoderRegistry: encoderRegistry, resultEncoder: resultEncoder, argsEncoder: argsEncoder, @@ -346,12 +356,15 @@ class StemWorkflowApp { eventBus: eventBus, pollInterval: pollInterval, leaseExtension: leaseExtension, - queue: workerConfig.queue, + queue: resolvedWorkerConfig.queue, registry: workflowRegistry, introspectionSink: introspectionSink, ); - [...moduleTasks, ...tasks].forEach(appInstance.register); + registerModuleTaskHandlers( + appInstance.registry, + [...moduleTasks, ...tasks], + ); appInstance.register(runtime.workflowRunnerHandler()); [ @@ -374,6 +387,10 @@ class StemWorkflowApp { /// Creates an in-memory workflow app (in-memory broker, backend, and store). /// /// Ideal for unit tests and examples since it requires no external services. + /// When [module] or [tasks] are provided and + /// [StemWorkerConfig.subscription] is omitted, the helper infers a worker + /// subscription that includes the workflow queue plus the default queues + /// declared on those task handlers. /// /// Example: /// ```dart @@ -422,7 +439,10 @@ class StemWorkflowApp { /// Creates a workflow app from a single backend URL plus adapter wiring. /// /// This wires broker/backend and workflow-store factories from one URL and - /// optional per-store overrides via [StemStack.fromUrl]. + /// optional per-store overrides via [StemStack.fromUrl]. When [module] or + /// [tasks] are provided and [StemWorkerConfig.subscription] is omitted, the + /// helper infers a worker subscription that includes the workflow queue plus + /// the default queues declared on those task handlers. static Future fromUrl( String url, { StemModule? module, @@ -449,6 +469,11 @@ class StemWorkflowApp { TaskPayloadEncoder argsEncoder = const JsonTaskPayloadEncoder(), Iterable additionalEncoders = const [], }) async { + final resolvedWorkerConfig = _resolveWorkflowWorkerConfig( + workerConfig, + module: module, + tasks: tasks, + ); final stack = StemStack.fromUrl( url, adapters: adapters, @@ -461,7 +486,7 @@ class StemWorkflowApp { adapters: adapters, overrides: overrides, stack: stack, - workerConfig: workerConfig, + workerConfig: resolvedWorkerConfig, uniqueTasks: uniqueTasks, uniqueTaskDefaultTtl: uniqueTaskDefaultTtl, uniqueTaskNamespace: uniqueTaskNamespace, @@ -484,7 +509,7 @@ class StemWorkflowApp { stemApp: app, storeFactory: stack.workflowStore, eventBusFactory: eventBusFactory, - workerConfig: workerConfig, + workerConfig: resolvedWorkerConfig, pollInterval: pollInterval, leaseExtension: leaseExtension, workflowRegistry: workflowRegistry, @@ -503,6 +528,11 @@ class StemWorkflowApp { } /// Creates a workflow app backed by a shared [StemClient]. + /// + /// When [module] or [tasks] are provided and + /// [StemWorkerConfig.subscription] is omitted, the helper infers a worker + /// subscription that includes the workflow queue plus the default queues + /// declared on those task handlers. static Future fromClient({ required StemClient client, StemModule? module, @@ -517,9 +547,14 @@ class StemWorkflowApp { Duration leaseExtension = const Duration(seconds: 30), WorkflowIntrospectionSink? introspectionSink, }) async { + final resolvedWorkerConfig = _resolveWorkflowWorkerConfig( + workerConfig, + module: module, + tasks: tasks, + ); final appInstance = await StemApp.fromClient( client, - workerConfig: workerConfig, + workerConfig: resolvedWorkerConfig, ); return StemWorkflowApp.create( module: module, @@ -529,7 +564,7 @@ class StemWorkflowApp { stemApp: appInstance, storeFactory: storeFactory, eventBusFactory: eventBusFactory, - workerConfig: workerConfig, + workerConfig: resolvedWorkerConfig, pollInterval: pollInterval, leaseExtension: leaseExtension, workflowRegistry: client.workflowRegistry, @@ -538,6 +573,33 @@ class StemWorkflowApp { } } +StemWorkerConfig _resolveWorkflowWorkerConfig( + StemWorkerConfig workerConfig, { + StemModule? module, + Iterable> tasks = const [], +}) { + if (workerConfig.subscription != null) { + return workerConfig; + } + + final inferredSubscription = + module?.inferWorkerSubscription( + workflowQueue: workerConfig.queue, + additionalTasks: tasks, + ) ?? + (() { + final tempModule = StemModule(tasks: tasks); + return tempModule.inferWorkerSubscription( + workflowQueue: workerConfig.queue, + ); + })(); + + if (inferredSubscription == null) { + return workerConfig; + } + return workerConfig.copyWith(subscription: inferredSubscription); +} + /// Convenience helpers for typed workflow start calls. extension WorkflowStartCallAppExtension on WorkflowStartCall { diff --git a/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart b/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart new file mode 100644 index 00000000..d1a858d8 --- /dev/null +++ b/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart @@ -0,0 +1,58 @@ +import 'package:stem/stem.dart'; +import 'package:test/test.dart'; + +void main() { + group('workflow module bootstrap', () { + test('StemWorkflowApp.inMemory infers workflow and task queues', () async { + final helperTask = FunctionTaskHandler( + name: 'workflow.module.queue-helper', + entrypoint: (context, args) async => 'queued-ok', + runInIsolate: false, + ); + final workflowApp = await StemWorkflowApp.inMemory( + module: StemModule(tasks: [helperTask]), + ); + try { + expect( + workflowApp.app.worker.subscription.queues, + unorderedEquals(['workflow', 'default']), + ); + + await workflowApp.start(); + final taskId = await workflowApp.app.stem.enqueue( + 'workflow.module.queue-helper', + ); + final result = await workflowApp.app.stem.waitForTask( + taskId, + timeout: const Duration(seconds: 2), + ); + expect(result?.value, 'queued-ok'); + } finally { + await workflowApp.shutdown(); + } + }); + + test( + 'explicit workflow subscription overrides inferred module queues', + () async { + final helperTask = FunctionTaskHandler( + name: 'workflow.module.explicit-subscription', + entrypoint: (context, args) async => 'ignored', + runInIsolate: false, + ); + final workflowApp = await StemWorkflowApp.inMemory( + module: StemModule(tasks: [helperTask]), + workerConfig: StemWorkerConfig( + queue: 'workflow', + subscription: RoutingSubscription.singleQueue('workflow'), + ), + ); + try { + expect(workflowApp.app.worker.subscription.queues, ['workflow']); + } finally { + await workflowApp.shutdown(); + } + }, + ); + }); +} From 27e5847b54c28c7fbd215d597d65f5ea4effd839 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 16:28:52 -0500 Subject: [PATCH 006/302] Add workflow suspension helpers --- .../workflows/cancellation_policy.dart | 6 +- .../example/workflows/sleep_and_event.dart | 9 +- .../src/workflow/core/workflow_resume.dart | 85 +++++++++++ .../unit/workflow/workflow_resume_test.dart | 141 +++++++++++++++++- 4 files changed, 228 insertions(+), 13 deletions(-) diff --git a/packages/stem/example/workflows/cancellation_policy.dart b/packages/stem/example/workflows/cancellation_policy.dart index fe2a1267..e5de70a3 100644 --- a/packages/stem/example/workflows/cancellation_policy.dart +++ b/packages/stem/example/workflows/cancellation_policy.dart @@ -15,15 +15,13 @@ Future main() async { name: 'reports.generate', build: (flow) { flow.step('poll-status', (ctx) async { - final resume = ctx.takeResumeValue(); - if (resume != true) { + if (!ctx.sleepUntilResumed(const Duration(seconds: 5))) { print('[workflow] polling external system…'); // Simulate a slow external service; the cancellation policy will // cap this suspension to 2 seconds. - ctx.sleep(const Duration(seconds: 5)); return null; } - print('[workflow] resumed with payload: $resume'); + print('[workflow] resumed after sleep'); return 'finished'; }); }, diff --git a/packages/stem/example/workflows/sleep_and_event.dart b/packages/stem/example/workflows/sleep_and_event.dart index a78ff94f..bf076b8d 100644 --- a/packages/stem/example/workflows/sleep_and_event.dart +++ b/packages/stem/example/workflows/sleep_and_event.dart @@ -12,18 +12,17 @@ Future main() async { name: 'durable.sleep.event', build: (flow) { flow.step('initial', (ctx) async { - final resumePayload = ctx.takeResumeValue(); - if (resumePayload != true) { - ctx.sleep(const Duration(milliseconds: 200)); + if (!ctx.sleepUntilResumed(const Duration(milliseconds: 200))) { return null; } return 'awake'; }); flow.step('await-event', (ctx) async { - final payload = ctx.takeResumeValue>(); + final payload = ctx.waitForEventValue>( + 'demo.event', + ); if (payload == null) { - ctx.awaitEvent('demo.event'); return null; } return payload['message']; diff --git a/packages/stem/lib/src/workflow/core/workflow_resume.dart b/packages/stem/lib/src/workflow/core/workflow_resume.dart index 8e2c7f18..b83c9330 100644 --- a/packages/stem/lib/src/workflow/core/workflow_resume.dart +++ b/packages/stem/lib/src/workflow/core/workflow_resume.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/workflow/core/flow_context.dart'; import 'package:stem/src/workflow/core/workflow_script_context.dart'; @@ -14,6 +16,59 @@ extension FlowContextResumeValues on FlowContext { if (codec != null) return codec.decodeDynamic(payload) as T; return payload as T; } + + /// Suspends the current step with [sleep] on the first invocation and + /// returns `true` once the step resumes. + /// + /// This helper is for the common: + /// + /// ```dart + /// if (ctx.takeResumeData() == null) { + /// ctx.sleep(duration); + /// return null; + /// } + /// ``` + /// + /// pattern. + bool sleepUntilResumed( + Duration duration, { + Map? data, + }) { + final resume = takeResumeData(); + if (resume != null) { + return true; + } + sleep(duration, data: data); + return false; + } + + /// Returns the next event payload as [T] when the step has resumed, or + /// registers an event wait and returns `null` on the first invocation. + /// + /// This helper is for the common: + /// + /// ```dart + /// final payload = ctx.takeResumeValue(codec: codec); + /// if (payload == null) { + /// ctx.awaitEvent(topic); + /// return null; + /// } + /// ``` + /// + /// pattern. + T? waitForEventValue( + String topic, { + DateTime? deadline, + Map? data, + PayloadCodec? codec, + }) { + final payload = takeResumeValue(codec: codec); + if (payload != null) { + return payload; + } + awaitEvent(topic, deadline: deadline, data: data); + return null; + } } /// Typed resume helpers for durable script checkpoints. @@ -28,4 +83,34 @@ extension WorkflowScriptStepResumeValues on WorkflowScriptStepContext { if (codec != null) return codec.decodeDynamic(payload) as T; return payload as T; } + + /// Suspends the current checkpoint with [sleep] on the first invocation and + /// returns `true` once the checkpoint resumes. + bool sleepUntilResumed( + Duration duration, { + Map? data, + }) { + final resume = takeResumeData(); + if (resume != null) { + return true; + } + unawaited(sleep(duration, data: data)); + return false; + } + + /// Returns the next event payload as [T] when the checkpoint has resumed, or + /// registers an event wait and returns `null` on the first invocation. + T? waitForEventValue( + String topic, { + DateTime? deadline, + Map? data, + PayloadCodec? codec, + }) { + final payload = takeResumeValue(codec: codec); + if (payload != null) { + return payload; + } + unawaited(awaitEvent(topic, deadline: deadline, data: data)); + return null; + } } diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart index 6c8c3dcd..c0b552b6 100644 --- a/packages/stem/test/unit/workflow/workflow_resume_test.dart +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -1,6 +1,7 @@ import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/workflow/core/flow_context.dart'; +import 'package:stem/src/workflow/core/flow_step.dart'; import 'package:stem/src/workflow/core/workflow_resume.dart'; import 'package:stem/src/workflow/core/workflow_script_context.dart'; import 'package:test/test.dart'; @@ -40,13 +41,135 @@ void main() { expect(value!['approvedBy'], 'gateway'); expect(context.takeResumeValue>(), isNull); }); + + test('FlowContext.sleepUntilResumed suspends once then resumes', () { + final firstContext = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + ); + + final firstResult = firstContext.sleepUntilResumed( + const Duration(seconds: 1), + data: const {'phase': 'initial'}, + ); + + expect(firstResult, isFalse); + final control = firstContext.takeControl(); + expect(control, isNotNull); + expect(control!.type, FlowControlType.sleep); + + final resumedContext = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + resumeData: true, + ); + + final resumed = resumedContext.sleepUntilResumed( + const Duration(seconds: 1), + ); + + expect(resumed, isTrue); + expect(resumedContext.takeControl(), isNull); + }); + + test( + 'FlowContext.waitForEventValue registers watcher then decodes payload', + () { + final firstContext = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + ); + + final firstResult = firstContext.waitForEventValue<_ResumePayload>( + 'demo.event', + codec: _resumePayloadCodec, + ); + + expect(firstResult, isNull); + final control = firstContext.takeControl(); + expect(control, isNotNull); + expect(control!.type, FlowControlType.waitForEvent); + expect(control.topic, 'demo.event'); + + final resumedContext = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + resumeData: const {'message': 'approved'}, + ); + + final resumed = resumedContext.waitForEventValue<_ResumePayload>( + 'demo.event', + codec: _resumePayloadCodec, + ); + + expect(resumed, isNotNull); + expect(resumed!.message, 'approved'); + expect(resumedContext.takeControl(), isNull); + }, + ); + + test( + 'WorkflowScriptStepContext helpers suspend once and decode resumed values', + () { + final sleeping = _FakeWorkflowScriptStepContext(); + + final firstSleep = sleeping.sleepUntilResumed( + const Duration(milliseconds: 10), + ); + + expect(firstSleep, isFalse); + expect(sleeping.sleepCalls, hasLength(1)); + + final resumedSleep = _FakeWorkflowScriptStepContext(resumeData: true); + expect( + resumedSleep.sleepUntilResumed(const Duration(milliseconds: 10)), + isTrue, + ); + expect(resumedSleep.sleepCalls, isEmpty); + + final waiting = _FakeWorkflowScriptStepContext(); + final firstEvent = waiting.waitForEventValue<_ResumePayload>( + 'demo.event', + codec: _resumePayloadCodec, + ); + expect(firstEvent, isNull); + expect(waiting.awaitedTopics, ['demo.event']); + + final resumedEvent = _FakeWorkflowScriptStepContext( + resumeData: const {'message': 'approved'}, + ); + final resumedValue = resumedEvent.waitForEventValue<_ResumePayload>( + 'demo.event', + codec: _resumePayloadCodec, + ); + expect(resumedValue, isNotNull); + expect(resumedValue!.message, 'approved'); + expect(resumedEvent.awaitedTopics, isEmpty); + }, + ); } class _ResumePayload { const _ResumePayload({required this.message}); factory _ResumePayload.fromJson(Map json) { - return _ResumePayload(message: json['message'] as String); + return _ResumePayload(message: json['message']! as String); } final String message; @@ -62,8 +185,9 @@ const _resumePayloadCodec = PayloadCodec<_ResumePayload>( Object? _encodeResumePayload(_ResumePayload value) => value.toJson(); _ResumePayload _decodeResumePayload(Object? payload) { + final map = payload! as Map; return _ResumePayload.fromJson( - Map.from(payload! as Map), + Map.from(map), ); } @@ -72,10 +196,15 @@ class _FakeWorkflowScriptStepContext implements WorkflowScriptStepContext { : _resumeData = resumeData; Object? _resumeData; + final List awaitedTopics = []; + final List sleepCalls = []; @override TaskEnqueuer? get enqueuer => null; + @override + Never? get workflows => null; + @override int get iteration => 0; @@ -102,14 +231,18 @@ class _FakeWorkflowScriptStepContext implements WorkflowScriptStepContext { String topic, { DateTime? deadline, Map? data, - }) async {} + }) async { + awaitedTopics.add(topic); + } @override String idempotencyKey([String? scope]) => 'demo.workflow/run-1/${scope ?? stepName}'; @override - Future sleep(Duration duration, {Map? data}) async {} + Future sleep(Duration duration, {Map? data}) async { + sleepCalls.add(duration); + } @override Object? takeResumeData() { From 7ba27b6cca5188cf78f4036e2df6afc7dec9961a Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 16:33:12 -0500 Subject: [PATCH 007/302] Split script checkpoints from flow steps --- packages/dashboard/lib/src/server.dart | 9 +- .../dashboard/lib/src/services/models.dart | 22 +- .../lib/src/services/stem_service.dart | 7 +- packages/dashboard/lib/src/ui/content.dart | 4 +- packages/dashboard/lib/src/ui/overview.dart | 2 +- .../dashboard/lib/src/ui/task_detail.dart | 16 +- packages/dashboard/lib/src/ui/workflows.dart | 2 +- .../test/dashboard_browser_test.dart | 2 +- .../test/dashboard_state_poll_test.dart | 4 +- .../test/dashboard_state_property_test.dart | 2 +- packages/dashboard/test/server_test.dart | 2 +- .../example/annotated_workflows/bin/main.dart | 55 ++- .../annotated_workflows/lib/definitions.dart | 39 +- .../lib/definitions.stem.g.dart | 51 ++- .../src/workflows/annotated_defs.stem.g.dart | 8 +- .../lib/src/workflow/core/flow_context.dart | 5 + .../stem/lib/src/workflow/core/run_state.dart | 4 + .../workflow/core/workflow_checkpoint.dart | 118 +++++ .../workflow/core/workflow_definition.dart | 86 +++- .../lib/src/workflow/core/workflow_ref.dart | 32 ++ .../core/workflow_runtime_metadata.dart | 17 +- .../src/workflow/core/workflow_script.dart | 6 +- .../core/workflow_script_context.dart | 29 +- .../workflow/runtime/workflow_manifest.dart | 154 ++++--- .../workflow/runtime/workflow_runtime.dart | 107 +++-- .../src/workflow/runtime/workflow_views.dart | 40 +- packages/stem/lib/src/workflow/workflow.dart | 1 + .../stem/test/bootstrap/stem_app_test.dart | 153 +++++-- .../unit/workflow/workflow_manifest_test.dart | 34 +- .../workflow/workflow_runtime_ref_test.dart | 25 +- .../test/workflow/workflow_runtime_test.dart | 131 +++++- packages/stem/tool/proxy_runtime_check.dart | 16 +- .../example/lib/definitions.stem.g.dart | 13 +- .../lib/src/stem_registry_builder.dart | 405 +++++++++++++----- .../test/stem_registry_builder_test.dart | 170 +++++++- packages/stem_cli/lib/src/cli/workflow.dart | 60 ++- .../lib/src/cli/workflow_agent_help.dart | 4 +- .../test/unit/cli/cli_workflow_test.dart | 6 +- 38 files changed, 1370 insertions(+), 471 deletions(-) create mode 100644 packages/stem/lib/src/workflow/core/workflow_checkpoint.dart diff --git a/packages/dashboard/lib/src/server.dart b/packages/dashboard/lib/src/server.dart index 6d66b1f6..3953f933 100644 --- a/packages/dashboard/lib/src/server.dart +++ b/packages/dashboard/lib/src/server.dart @@ -550,9 +550,10 @@ Future _renderPage( final workflowRun = page == DashboardPage.taskDetail && runId != null ? await service.fetchWorkflowRun(runId) : null; - final workflowSteps = page == DashboardPage.taskDetail && runId != null - ? await service.fetchWorkflowSteps(runId) - : const []; + final workflowCheckpoints = + page == DashboardPage.taskDetail && runId != null + ? await service.fetchWorkflowCheckpoints(runId) + : const []; final content = buildPageContent( page: page, @@ -562,7 +563,7 @@ Future _renderPage( taskDetail: taskDetail, runTimeline: runTimeline, workflowRun: workflowRun, - workflowSteps: workflowSteps, + workflowCheckpoints: workflowCheckpoints, auditEntries: page == DashboardPage.search || page == DashboardPage.audit ? state.auditEntries : const [], diff --git a/packages/dashboard/lib/src/services/models.dart b/packages/dashboard/lib/src/services/models.dart index 28c888bb..a73a0bf6 100644 --- a/packages/dashboard/lib/src/services/models.dart +++ b/packages/dashboard/lib/src/services/models.dart @@ -514,7 +514,7 @@ class DashboardWorkflowRunSummary { const DashboardWorkflowRunSummary({ required this.runId, required this.workflowName, - required this.lastStep, + required this.lastCheckpoint, required this.total, required this.queued, required this.running, @@ -530,8 +530,8 @@ class DashboardWorkflowRunSummary { /// Workflow name, when available. final String workflowName; - /// Most recent step marker, when available. - final String? lastStep; + /// Most recent checkpoint marker, when available. + final String? lastCheckpoint; /// Total sampled statuses for this run. final int total; @@ -762,9 +762,9 @@ class DashboardWorkflowRunSnapshot { } /// Projection of a persisted workflow checkpoint. -class DashboardWorkflowStepSnapshot { +class DashboardWorkflowCheckpointSnapshot { /// Creates a workflow checkpoint snapshot. - const DashboardWorkflowStepSnapshot({ + const DashboardWorkflowCheckpointSnapshot({ required this.name, required this.position, required this.value, @@ -772,8 +772,10 @@ class DashboardWorkflowStepSnapshot { }); /// Builds a workflow checkpoint snapshot from [WorkflowStepEntry]. - factory DashboardWorkflowStepSnapshot.fromEntry(WorkflowStepEntry entry) { - return DashboardWorkflowStepSnapshot( + factory DashboardWorkflowCheckpointSnapshot.fromEntry( + WorkflowStepEntry entry, + ) { + return DashboardWorkflowCheckpointSnapshot( name: entry.name, position: entry.position, value: entry.value, @@ -906,7 +908,7 @@ class _DashboardWorkflowSummaryBuilder { final String runId; String _workflowName = 'workflow'; - String? _lastStep; + String? _lastCheckpoint; var _total = 0; var _queued = 0; var _running = 0; @@ -921,7 +923,7 @@ class _DashboardWorkflowSummaryBuilder { _workflowName = task.workflowName!; } if (task.workflowStep != null && task.workflowStep!.isNotEmpty) { - _lastStep = task.workflowStep; + _lastCheckpoint = task.workflowStep; } if (task.state == TaskState.queued || task.state == TaskState.retried) { _queued += 1; @@ -939,7 +941,7 @@ class _DashboardWorkflowSummaryBuilder { return DashboardWorkflowRunSummary( runId: runId, workflowName: _workflowName, - lastStep: _lastStep, + lastCheckpoint: _lastCheckpoint, total: _total, queued: _queued, running: _running, diff --git a/packages/dashboard/lib/src/services/stem_service.dart b/packages/dashboard/lib/src/services/stem_service.dart index b90ded04..08d584cf 100644 --- a/packages/dashboard/lib/src/services/stem_service.dart +++ b/packages/dashboard/lib/src/services/stem_service.dart @@ -38,7 +38,8 @@ abstract class DashboardDataSource { Future fetchWorkflowRun(String runId); /// Fetches persisted workflow checkpoints, if a workflow store is available. - Future> fetchWorkflowSteps(String runId); + Future> + fetchWorkflowCheckpoints(String runId); /// Enqueues a task request through the backing broker. Future enqueueTask(EnqueueRequest request); @@ -285,7 +286,7 @@ class StemDashboardService implements DashboardDataSource { } @override - Future> fetchWorkflowSteps( + Future> fetchWorkflowCheckpoints( String runId, ) async { final store = _workflowStore; @@ -297,7 +298,7 @@ class StemDashboardService implements DashboardDataSource { try { final steps = await store.listSteps(trimmed); return steps - .map(DashboardWorkflowStepSnapshot.fromEntry) + .map(DashboardWorkflowCheckpointSnapshot.fromEntry) .toList(growable: false) ..sort((a, b) => a.position.compareTo(b.position)); } on Object { diff --git a/packages/dashboard/lib/src/ui/content.dart b/packages/dashboard/lib/src/ui/content.dart index b88cc745..05ecfa18 100644 --- a/packages/dashboard/lib/src/ui/content.dart +++ b/packages/dashboard/lib/src/ui/content.dart @@ -24,7 +24,7 @@ String buildPageContent({ DashboardTaskStatusEntry? taskDetail, List runTimeline = const [], DashboardWorkflowRunSnapshot? workflowRun, - List workflowSteps = const [], + List workflowCheckpoints = const [], List auditEntries = const [], DashboardThroughput? throughput, List events = const [], @@ -53,7 +53,7 @@ String buildPageContent({ taskDetail, runTimeline, workflowRun, - workflowSteps, + workflowCheckpoints, ); case DashboardPage.failures: return buildFailuresContent(taskStatuses, failuresOptions); diff --git a/packages/dashboard/lib/src/ui/overview.dart b/packages/dashboard/lib/src/ui/overview.dart index 5aa140ff..86cf62a2 100644 --- a/packages/dashboard/lib/src/ui/overview.dart +++ b/packages/dashboard/lib/src/ui/overview.dart @@ -238,7 +238,7 @@ OverviewSections buildOverviewSections( ${escapeHtml(run.runId)} ${escapeHtml(run.workflowName)} - ${escapeHtml(run.lastStep ?? '—')} + ${escapeHtml(run.lastCheckpoint ?? '—')} ${formatInt(run.queued)} ${formatInt(run.running)} ${formatInt(run.succeeded)} diff --git a/packages/dashboard/lib/src/ui/task_detail.dart b/packages/dashboard/lib/src/ui/task_detail.dart index e3d9f3c0..f641fb99 100644 --- a/packages/dashboard/lib/src/ui/task_detail.dart +++ b/packages/dashboard/lib/src/ui/task_detail.dart @@ -12,7 +12,7 @@ String buildTaskDetailContent( DashboardTaskStatusEntry? task, List runTimeline, DashboardWorkflowRunSnapshot? workflowRun, - List workflowSteps, + List workflowCheckpoints, ) { if (task == null) { return ''' @@ -68,7 +68,7 @@ String buildTaskDetailContent( Updated${formatDateTime(task.updatedAt)} Run ID${task.runId == null ? '' : '${escapeHtml(task.runId!)}'} Workflow${task.workflowName == null ? '' : escapeHtml(task.workflowName!)} - Workflow Step${task.workflowStep == null ? '' : escapeHtml(task.workflowStep!)} + Workflow Checkpoint${task.workflowStep == null ? '' : escapeHtml(task.workflowStep!)} @@ -113,14 +113,14 @@ String buildTaskDetailContent( -${buildWorkflowSection(task, workflowRun, workflowSteps, timeline)} +${buildWorkflowSection(task, workflowRun, workflowCheckpoints, timeline)} '''; } String buildWorkflowSection( DashboardTaskStatusEntry task, DashboardWorkflowRunSnapshot? workflowRun, - List workflowSteps, + List workflowCheckpoints, List timeline, ) { if (task.runId == null) { @@ -182,11 +182,11 @@ String buildWorkflowSection( - ${workflowSteps.isEmpty ? ''' + ${workflowCheckpoints.isEmpty ? ''' - No persisted workflow step checkpoints found. + No persisted workflow checkpoints found. -''' : workflowSteps.map((step) => ''' +''' : workflowCheckpoints.map((step) => ''' ${escapeHtml(step.name)} ${formatInt(step.position)} @@ -207,7 +207,7 @@ String buildWorkflowSection( Task ID Task - Step + Checkpoint State Attempt Updated diff --git a/packages/dashboard/lib/src/ui/workflows.dart b/packages/dashboard/lib/src/ui/workflows.dart index db3b97ea..4308b983 100644 --- a/packages/dashboard/lib/src/ui/workflows.dart +++ b/packages/dashboard/lib/src/ui/workflows.dart @@ -83,7 +83,7 @@ String buildWorkflowsContent({ ${escapeHtml(entry.runId)} ${escapeHtml(entry.workflowName)} - ${escapeHtml(entry.lastStep ?? '—')} + ${escapeHtml(entry.lastCheckpoint ?? '—')} ${formatInt(entry.queued)} ${formatInt(entry.running)} ${formatInt(entry.succeeded)} diff --git a/packages/dashboard/test/dashboard_browser_test.dart b/packages/dashboard/test/dashboard_browser_test.dart index a073ba29..d64c4f73 100644 --- a/packages/dashboard/test/dashboard_browser_test.dart +++ b/packages/dashboard/test/dashboard_browser_test.dart @@ -105,7 +105,7 @@ class _FakeDashboardService implements DashboardDataSource { null; @override - Future> fetchWorkflowSteps( + Future> fetchWorkflowCheckpoints( String runId, ) async => const []; diff --git a/packages/dashboard/test/dashboard_state_poll_test.dart b/packages/dashboard/test/dashboard_state_poll_test.dart index 6482ced1..85a3b2b0 100644 --- a/packages/dashboard/test/dashboard_state_poll_test.dart +++ b/packages/dashboard/test/dashboard_state_poll_test.dart @@ -47,7 +47,7 @@ class _FailingPollService implements DashboardDataSource { null; @override - Future> fetchWorkflowSteps( + Future> fetchWorkflowCheckpoints( String runId, ) async => const []; @@ -114,7 +114,7 @@ class _BacklogOnlyService implements DashboardDataSource { null; @override - Future> fetchWorkflowSteps( + Future> fetchWorkflowCheckpoints( String runId, ) async => const []; diff --git a/packages/dashboard/test/dashboard_state_property_test.dart b/packages/dashboard/test/dashboard_state_property_test.dart index 405079c0..9e5e2f18 100644 --- a/packages/dashboard/test/dashboard_state_property_test.dart +++ b/packages/dashboard/test/dashboard_state_property_test.dart @@ -100,7 +100,7 @@ class _SequenceDashboardService implements DashboardDataSource { null; @override - Future> fetchWorkflowSteps( + Future> fetchWorkflowCheckpoints( String runId, ) async => const []; diff --git a/packages/dashboard/test/server_test.dart b/packages/dashboard/test/server_test.dart index a0f13451..cbe09087 100644 --- a/packages/dashboard/test/server_test.dart +++ b/packages/dashboard/test/server_test.dart @@ -93,7 +93,7 @@ class _RecordingService implements DashboardDataSource { null; @override - Future> fetchWorkflowSteps( + Future> fetchWorkflowCheckpoints( String runId, ) async => const []; diff --git a/packages/stem/example/annotated_workflows/bin/main.dart b/packages/stem/example/annotated_workflows/bin/main.dart index 208b05e1..69d8a974 100644 --- a/packages/stem/example/annotated_workflows/bin/main.dart +++ b/packages/stem/example/annotated_workflows/bin/main.dart @@ -5,13 +5,7 @@ import 'package:stem_annotated_workflows/definitions.dart'; Future main() async { final client = await StemClient.inMemory(); - final app = await client.createWorkflowApp( - module: stemModule, - workerConfig: StemWorkerConfig( - queue: 'workflow', - subscription: RoutingSubscription(queues: ['workflow', 'default']), - ), - ); + final app = await client.createWorkflowApp(module: stemModule); await app.start(); final flowRunId = await StemWorkflowDefinitions.flow @@ -23,10 +17,22 @@ Future main() async { timeout: const Duration(seconds: 2), ); print('Flow result: ${jsonEncode(flowResult?.value)}'); + final flowChildRunId = flowResult?.value?['childRunId'] as String?; + if (flowChildRunId != null) { + final flowChildResult = await StemWorkflowDefinitions.script.waitFor( + app, + flowChildRunId, + timeout: const Duration(seconds: 2), + ); + print( + 'Flow child workflow result: ' + '${jsonEncode(flowChildResult?.value?.toJson())}', + ); + } - final scriptCall = StemWorkflowDefinitions.script.call( - (request: const WelcomeRequest(email: ' SomeEmail@Example.com ')), - ); + final scriptCall = StemWorkflowDefinitions.script.call(( + request: const WelcomeRequest(email: ' SomeEmail@Example.com '), + )); final scriptResult = await scriptCall.startAndWaitWithApp( app, timeout: const Duration(seconds: 2), @@ -34,11 +40,13 @@ Future main() async { print('Script result: ${jsonEncode(scriptResult?.value?.toJson())}'); final scriptDetail = await app.runtime.viewRunDetail(scriptResult!.runId); - final scriptCheckpoints = scriptDetail?.steps - .map((step) => step.baseStepName) + final scriptCheckpoints = scriptDetail?.checkpoints + .map((checkpoint) => checkpoint.baseCheckpointName) .join(' -> '); - final persistedPreparation = scriptDetail?.steps - .firstWhere((step) => step.baseStepName == 'prepare-welcome') + final persistedPreparation = scriptDetail?.checkpoints + .firstWhere( + (checkpoint) => checkpoint.baseCheckpointName == 'prepare-welcome', + ) .value; print('Script checkpoints: $scriptCheckpoints'); print( @@ -47,9 +55,9 @@ Future main() async { print('Persisted script result: ${jsonEncode(scriptDetail?.run.result)}'); print('Script detail: ${jsonEncode(scriptDetail?.toJson())}'); - final contextCall = StemWorkflowDefinitions.contextScript.call( - (request: const WelcomeRequest(email: ' ContextEmail@Example.com ')), - ); + final contextCall = StemWorkflowDefinitions.contextScript.call(( + request: const WelcomeRequest(email: ' ContextEmail@Example.com '), + )); final contextResult = await contextCall.startAndWaitWithApp( app, timeout: const Duration(seconds: 2), @@ -57,12 +65,21 @@ Future main() async { print('Context script result: ${jsonEncode(contextResult?.value?.toJson())}'); final contextDetail = await app.runtime.viewRunDetail(contextResult!.runId); - final contextCheckpoints = contextDetail?.steps - .map((step) => step.baseStepName) + final contextCheckpoints = contextDetail?.checkpoints + .map((checkpoint) => checkpoint.baseCheckpointName) .join(' -> '); print('Context script checkpoints: $contextCheckpoints'); print('Persisted context result: ${jsonEncode(contextDetail?.run.result)}'); print('Context script detail: ${jsonEncode(contextDetail?.toJson())}'); + final contextChildResult = await StemWorkflowDefinitions.script.waitFor( + app, + contextResult.value!.childRunId, + timeout: const Duration(seconds: 2), + ); + print( + 'Context child workflow result: ' + '${jsonEncode(contextChildResult?.value?.toJson())}', + ); final typedTaskId = await app.app.stem.enqueueSendEmailTyped( dispatch: const EmailDispatch( diff --git a/packages/stem/example/annotated_workflows/lib/definitions.dart b/packages/stem/example/annotated_workflows/lib/definitions.dart index 16938b64..c9318887 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.dart @@ -140,6 +140,7 @@ class ContextCaptureResult { required this.idempotencyKey, required this.normalizedEmail, required this.subject, + required this.childRunId, }); final String workflow; @@ -150,6 +151,7 @@ class ContextCaptureResult { final String idempotencyKey; final String normalizedEmail; final String subject; + final String childRunId; Map toJson() => { 'workflow': workflow, @@ -160,6 +162,7 @@ class ContextCaptureResult { 'idempotencyKey': idempotencyKey, 'normalizedEmail': normalizedEmail, 'subject': subject, + 'childRunId': childRunId, }; factory ContextCaptureResult.fromJson(Map json) { @@ -172,6 +175,7 @@ class ContextCaptureResult { idempotencyKey: json['idempotencyKey'] as String, normalizedEmail: json['normalizedEmail'] as String, subject: json['subject'] as String, + childRunId: json['childRunId'] as String, ); } } @@ -179,12 +183,17 @@ class ContextCaptureResult { @WorkflowDefn(name: 'annotated.flow') class AnnotatedFlowWorkflow { @WorkflowStep() - Future?> start(FlowContext ctx) async { - final resume = ctx.takeResumeData(); - if (resume == null) { - ctx.sleep(const Duration(milliseconds: 50)); + Future?> start({FlowContext? context}) async { + final ctx = context!; + if (!ctx.sleepUntilResumed(const Duration(milliseconds: 50))) { return null; } + final childRunId = await ctx.workflows!.startWorkflowRef( + StemWorkflowDefinitions.script, + ( + request: const WelcomeRequest(email: 'flow-child@example.com'), + ), + ); return { 'workflow': ctx.workflow, 'runId': ctx.runId, @@ -192,6 +201,7 @@ class AnnotatedFlowWorkflow { 'stepIndex': ctx.stepIndex, 'iteration': ctx.iteration, 'idempotencyKey': ctx.idempotencyKey(), + 'childRunId': childRunId, }; } } @@ -243,24 +253,29 @@ class AnnotatedScriptWorkflow { @WorkflowDefn(name: 'annotated.context_script', kind: WorkflowKind.script) class AnnotatedContextScriptWorkflow { - @WorkflowRun() Future run( - WorkflowScriptContext script, WelcomeRequest request, + {WorkflowScriptContext? context} ) async { + final script = context!; return script.step( 'enter-context-step', - (ctx) => captureContext(ctx, request), + (ctx) => captureContext(request, context: ctx), ); } @WorkflowStep(name: 'capture-context') Future captureContext( - WorkflowScriptStepContext ctx, WelcomeRequest request, + {WorkflowScriptStepContext? context} ) async { + final ctx = context!; final normalizedEmail = await normalizeEmail(request.email); final subject = await buildWelcomeSubject(normalizedEmail); + final childRunId = await ctx.workflows!.startWorkflowRef( + StemWorkflowDefinitions.script, + (request: WelcomeRequest(email: normalizedEmail)), + ); return ContextCaptureResult( workflow: ctx.workflow, runId: ctx.runId, @@ -270,6 +285,7 @@ class AnnotatedContextScriptWorkflow { idempotencyKey: ctx.idempotencyKey('welcome'), normalizedEmail: normalizedEmail, subject: subject, + childRunId: childRunId, ); } @@ -286,17 +302,20 @@ class AnnotatedContextScriptWorkflow { @TaskDefn(name: 'send_email', options: TaskOptions(maxRetries: 1)) Future sendEmail( - TaskInvocationContext ctx, Map args, + {TaskInvocationContext? context} ) async { + final ctx = context!; + ctx.heartbeat(); // No-op task for example purposes. } @TaskDefn(name: 'send_email_typed', options: TaskOptions(maxRetries: 1)) Future sendEmailTyped( - TaskInvocationContext ctx, EmailDispatch dispatch, + {TaskInvocationContext? context} ) async { + final ctx = context!; ctx.heartbeat(); await ctx.progress( 100, diff --git a/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart b/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart index 42c0f107..d8352bea 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart @@ -72,7 +72,7 @@ final List _stemFlows = [ final impl = AnnotatedFlowWorkflow(); flow.step?>( "start", - (ctx) => impl.start(ctx), + (ctx) => impl.start(context: ctx), kind: WorkflowStepKind.task, taskNames: [], ); @@ -129,12 +129,12 @@ class _StemScriptProxy1 extends AnnotatedContextScriptWorkflow { final WorkflowScriptContext _script; @override Future captureContext( - WorkflowScriptStepContext context, - WelcomeRequest request, - ) { + WelcomeRequest request, { + WorkflowScriptStepContext? context, + }) { return _script.step( "capture-context", - (context) => super.captureContext(context, request), + (context) => super.captureContext(request, context: context), ); } @@ -159,34 +159,29 @@ final List _stemScripts = [ WorkflowScript( name: "annotated.script", checkpoints: [ - FlowStep.typed( + WorkflowCheckpoint.typed( name: "prepare-welcome", - handler: _stemScriptManifestStepNoop, valueCodec: StemPayloadCodecs.welcomePreparation, kind: WorkflowStepKind.task, taskNames: [], ), - FlowStep( + WorkflowCheckpoint( name: "normalize-email", - handler: _stemScriptManifestStepNoop, kind: WorkflowStepKind.task, taskNames: [], ), - FlowStep( + WorkflowCheckpoint( name: "build-welcome-subject", - handler: _stemScriptManifestStepNoop, kind: WorkflowStepKind.task, taskNames: [], ), - FlowStep( + WorkflowCheckpoint( name: "deliver-welcome", - handler: _stemScriptManifestStepNoop, kind: WorkflowStepKind.task, taskNames: [], ), - FlowStep( + WorkflowCheckpoint( name: "build-follow-up", - handler: _stemScriptManifestStepNoop, kind: WorkflowStepKind.task, taskNames: [], ), @@ -201,32 +196,29 @@ final List _stemScripts = [ WorkflowScript( name: "annotated.context_script", checkpoints: [ - FlowStep.typed( + WorkflowCheckpoint.typed( name: "capture-context", - handler: _stemScriptManifestStepNoop, valueCodec: StemPayloadCodecs.contextCaptureResult, kind: WorkflowStepKind.task, taskNames: [], ), - FlowStep( + WorkflowCheckpoint( name: "normalize-email", - handler: _stemScriptManifestStepNoop, kind: WorkflowStepKind.task, taskNames: [], ), - FlowStep( + WorkflowCheckpoint( name: "build-welcome-subject", - handler: _stemScriptManifestStepNoop, kind: WorkflowStepKind.task, taskNames: [], ), ], resultCodec: StemPayloadCodecs.contextCaptureResult, run: (script) => _StemScriptProxy1(script).run( - script, StemPayloadCodecs.welcomeRequest.decode( _stemRequireArg(script.params, "request"), ), + context: script, ), ), ]; @@ -255,8 +247,6 @@ abstract final class StemWorkflowDefinitions { ); } -Future _stemScriptManifestStepNoop(FlowContext context) async => null; - Object? _stemRequireArg(Map args, String name) { if (!args.containsKey(name)) { throw ArgumentError('Missing required argument "$name".'); @@ -267,11 +257,18 @@ Object? _stemRequireArg(Map args, String name) { Future _stemTaskAdapter0( TaskInvocationContext context, Map args, +) async { + return await Future.value(sendEmail(args, context: context)); +} + +Future _stemTaskAdapter1( + TaskInvocationContext context, + Map args, ) async { return await Future.value( sendEmailTyped( - context, StemPayloadCodecs.emailDispatch.decode(_stemRequireArg(args, "dispatch")), + context: context, ), ); } @@ -366,13 +363,13 @@ extension StemGeneratedTaskResults on Stem { final List> _stemTasks = >[ FunctionTaskHandler( name: "send_email", - entrypoint: sendEmail, + entrypoint: _stemTaskAdapter0, options: const TaskOptions(maxRetries: 1), metadata: const TaskMetadata(), ), FunctionTaskHandler( name: "send_email_typed", - entrypoint: _stemTaskAdapter0, + entrypoint: _stemTaskAdapter1, options: const TaskOptions(maxRetries: 1), metadata: TaskMetadata( tags: [], diff --git a/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart b/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart index 65d4e4fa..d64e105f 100644 --- a/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart +++ b/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart @@ -37,15 +37,13 @@ final List _stemScripts = [ WorkflowScript( name: "ecommerce.cart.add_item", checkpoints: [ - FlowStep( + WorkflowCheckpoint( name: "validate-input", - handler: _stemScriptManifestStepNoop, kind: WorkflowStepKind.task, taskNames: [], ), - FlowStep( + WorkflowCheckpoint( name: "price-line-item", - handler: _stemScriptManifestStepNoop, kind: WorkflowStepKind.task, taskNames: [], ), @@ -78,8 +76,6 @@ abstract final class StemWorkflowDefinitions { ); } -Future _stemScriptManifestStepNoop(FlowContext context) async => null; - Object? _stemRequireArg(Map args, String name) { if (!args.containsKey(name)) { throw ArgumentError('Missing required argument "$name".'); diff --git a/packages/stem/lib/src/workflow/core/flow_context.dart b/packages/stem/lib/src/workflow/core/flow_context.dart index 33c8d866..e985327e 100644 --- a/packages/stem/lib/src/workflow/core/flow_context.dart +++ b/packages/stem/lib/src/workflow/core/flow_context.dart @@ -1,6 +1,7 @@ import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/workflow/core/flow_step.dart'; import 'package:stem/src/workflow/core/workflow_clock.dart'; +import 'package:stem/src/workflow/core/workflow_ref.dart'; /// Context provided to each workflow step invocation. /// @@ -26,6 +27,7 @@ class FlowContext { WorkflowClock clock = const SystemWorkflowClock(), Object? resumeData, this.enqueuer, + this.workflows, }) : _clock = clock, _resumeData = resumeData; @@ -52,6 +54,9 @@ class FlowContext { /// Optional enqueuer for scheduling tasks with workflow metadata. final TaskEnqueuer? enqueuer; + + /// Optional typed workflow caller for spawning child workflows. + final WorkflowCaller? workflows; final WorkflowClock _clock; FlowStepControl? _control; diff --git a/packages/stem/lib/src/workflow/core/run_state.dart b/packages/stem/lib/src/workflow/core/run_state.dart index 6b31b49c..d1efd514 100644 --- a/packages/stem/lib/src/workflow/core/run_state.dart +++ b/packages/stem/lib/src/workflow/core/run_state.dart @@ -79,6 +79,10 @@ class RunState { WorkflowRunRuntimeMetadata get runtimeMetadata => WorkflowRunRuntimeMetadata.fromParams(params); + /// Parent workflow run identifier, if this run was started as a child. + String? get parentRunId => + params[workflowParentRunIdParamKey]?.toString(); + /// Timestamp when the workflow run was created. final DateTime createdAt; diff --git a/packages/stem/lib/src/workflow/core/workflow_checkpoint.dart b/packages/stem/lib/src/workflow/core/workflow_checkpoint.dart new file mode 100644 index 00000000..4845e59f --- /dev/null +++ b/packages/stem/lib/src/workflow/core/workflow_checkpoint.dart @@ -0,0 +1,118 @@ +import 'package:stem/src/core/payload_codec.dart'; +import 'package:stem/src/workflow/core/flow_step.dart'; + +/// Declared script checkpoint metadata used for tooling and replay boundaries. +/// +/// Unlike [FlowStep], a [WorkflowCheckpoint] does not define execution logic. +/// Script workflows execute their `run(...)` body directly and use these +/// declarations only for manifests, introspection, encoding, and replay +/// metadata. +class WorkflowCheckpoint { + /// Creates declared checkpoint metadata for a script workflow. + WorkflowCheckpoint({ + required this.name, + this.autoVersion = false, + String? title, + Object? Function(Object? value)? valueEncoder, + Object? Function(Object? payload)? valueDecoder, + this.kind = WorkflowStepKind.task, + Iterable taskNames = const [], + Map? metadata, + }) : title = title ?? name, + _valueEncoder = valueEncoder, + _valueDecoder = valueDecoder, + taskNames = List.unmodifiable(taskNames), + metadata = metadata == null ? null : Map.unmodifiable(metadata); + + /// Creates checkpoint metadata backed by a typed [valueCodec]. + static WorkflowCheckpoint typed({ + required String name, + required PayloadCodec valueCodec, + bool autoVersion = false, + String? title, + WorkflowStepKind kind = WorkflowStepKind.task, + Iterable taskNames = const [], + Map? metadata, + }) { + return WorkflowCheckpoint( + name: name, + autoVersion: autoVersion, + title: title, + valueEncoder: valueCodec.encodeDynamic, + valueDecoder: valueCodec.decodeDynamic, + kind: kind, + taskNames: taskNames, + metadata: metadata, + ); + } + + /// Rehydrates declared checkpoint metadata from serialized JSON. + factory WorkflowCheckpoint.fromJson(Map json) { + return WorkflowCheckpoint( + name: json['name']?.toString() ?? '', + title: json['title']?.toString(), + kind: _checkpointKindFromJson(json['kind']), + taskNames: (json['taskNames'] as List?)?.cast() ?? const [], + autoVersion: json['autoVersion'] == true, + metadata: (json['metadata'] as Map?)?.cast(), + ); + } + + /// Checkpoint name used for persistence and replay. + final String name; + + /// Human-friendly checkpoint title exposed for introspection. + final String title; + + /// Checkpoint kind classification. + final WorkflowStepKind kind; + + final Object? Function(Object? value)? _valueEncoder; + final Object? Function(Object? payload)? _valueDecoder; + + /// Task names associated with this checkpoint. + final List taskNames; + + /// Optional metadata associated with the checkpoint. + final Map? metadata; + + /// Whether to auto-version repeated checkpoint executions. + final bool autoVersion; + + /// Serializes checkpoint metadata for workflow introspection. + Map toJson() { + return { + 'name': name, + 'title': title, + 'kind': kind.name, + 'taskNames': taskNames, + 'autoVersion': autoVersion, + if (metadata != null) 'metadata': metadata, + }; + } + + /// Encodes a checkpoint value before it is persisted. + Object? encodeValue(Object? value) { + if (value == null) return null; + final encoder = _valueEncoder; + if (encoder == null) return value; + return encoder(value); + } + + /// Decodes a persisted checkpoint value back into the author-facing type. + Object? decodeValue(Object? payload) { + if (payload == null) return null; + final decoder = _valueDecoder; + if (decoder == null) return payload; + return decoder(payload); + } +} + +WorkflowStepKind _checkpointKindFromJson(Object? value) { + final raw = value?.toString(); + if (raw == null || raw.isEmpty) return WorkflowStepKind.task; + return WorkflowStepKind.values.firstWhere( + (kind) => kind.name == raw, + orElse: () => WorkflowStepKind.task, + ); +} diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index 814d422c..3364a6c2 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -60,6 +60,7 @@ import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/workflow/core/flow.dart' show Flow; import 'package:stem/src/workflow/core/flow_context.dart'; import 'package:stem/src/workflow/core/flow_step.dart'; +import 'package:stem/src/workflow/core/workflow_checkpoint.dart'; import 'package:stem/src/workflow/core/workflow_script_context.dart'; import 'package:stem/src/workflow/workflow.dart' show Flow; import 'package:stem/stem.dart' show Flow; @@ -87,6 +88,7 @@ class WorkflowDefinition { required this.name, required WorkflowDefinitionKind kind, required List steps, + List checkpoints = const [], List edges = const [], this.version, this.description, @@ -96,6 +98,7 @@ class WorkflowDefinition { Object? Function(Object? payload)? resultDecoder, }) : _kind = kind, _steps = steps, + _checkpoints = checkpoints, _edges = edges, _resultEncoder = resultEncoder, _resultDecoder = resultDecoder, @@ -105,10 +108,19 @@ class WorkflowDefinition { factory WorkflowDefinition.fromJson(Map json) { final kind = _kindFromJson(json['kind']); final stepsJson = (json['steps'] as List?) ?? const []; - final steps = stepsJson - .whereType>() - .map(FlowStep.fromJson) - .toList(); + final steps = kind == WorkflowDefinitionKind.flow + ? stepsJson + .whereType>() + .map(FlowStep.fromJson) + .toList() + : []; + final checkpointsJson = ((json['checkpoints'] as List?) ?? stepsJson); + final checkpoints = kind == WorkflowDefinitionKind.script + ? checkpointsJson + .whereType>() + .map(WorkflowCheckpoint.fromJson) + .toList() + : []; final edgesJson = (json['edges'] as List?) ?? const []; final edges = edgesJson .whereType>() @@ -119,6 +131,7 @@ class WorkflowDefinition { name: json['name']?.toString() ?? '', kind: kind, steps: steps, + checkpoints: checkpoints, edges: edges, version: json['version']?.toString(), description: json['description']?.toString(), @@ -129,6 +142,7 @@ class WorkflowDefinition { name: json['name']?.toString() ?? '', kind: kind, steps: steps, + checkpoints: checkpoints, edges: edges, version: json['version']?.toString(), description: json['description']?.toString(), @@ -178,14 +192,12 @@ class WorkflowDefinition { factory WorkflowDefinition.script({ required String name, required WorkflowScriptBody run, - Iterable steps = const [], - Iterable checkpoints = const [], + Iterable checkpoints = const [], String? version, String? description, Map? metadata, PayloadCodec? resultCodec, }) { - final declaredCheckpoints = checkpoints.isNotEmpty ? checkpoints : steps; Object? Function(Object?)? resultEncoder; Object? Function(Object?)? resultDecoder; if (resultCodec != null) { @@ -199,7 +211,8 @@ class WorkflowDefinition { return WorkflowDefinition._( name: name, kind: WorkflowDefinitionKind.script, - steps: List.unmodifiable(declaredCheckpoints), + steps: const [], + checkpoints: List.unmodifiable(checkpoints), version: version, description: description, metadata: metadata, @@ -213,6 +226,7 @@ class WorkflowDefinition { final String name; final WorkflowDefinitionKind _kind; final List _steps; + final List _checkpoints; final List _edges; /// Optional version identifier for the workflow definition. @@ -233,13 +247,16 @@ class WorkflowDefinition { /// Ordered list of steps for flow-based workflows. List get steps => List.unmodifiable(_steps); + /// Declared checkpoints for script-based workflows. + List get checkpoints => List.unmodifiable(_checkpoints); + /// Directed edges describing the workflow graph. List get edges => List.unmodifiable(_edges); /// Whether this definition represents a script-based workflow. bool get isScript => _kind == WorkflowDefinitionKind.script; - /// Looks up a declared step/checkpoint by its base name. + /// Looks up a declared flow step by its base name. FlowStep? stepByName(String name) { for (final step in _steps) { if (step.name == name) { @@ -249,6 +266,16 @@ class WorkflowDefinition { return null; } + /// Looks up declared script checkpoint metadata by its base name. + WorkflowCheckpoint? checkpointByName(String name) { + for (final checkpoint in _checkpoints) { + if (checkpoint.name == name) { + return checkpoint; + } + } + return null; + } + /// Encodes a final workflow result before it is persisted. Object? encodeResult(Object? value) { if (value == null) return null; @@ -274,25 +301,43 @@ class WorkflowDefinition { ..write('|') ..write(version ?? '') ..write('|'); - for (final step in _steps) { - basis - ..write(step.name) - ..write(':') - ..write(step.kind.name) - ..write(':') - ..write(step.autoVersion ? '1' : '0') - ..write('|'); + if (isScript) { + for (final checkpoint in _checkpoints) { + basis + ..write(checkpoint.name) + ..write(':') + ..write(checkpoint.kind.name) + ..write(':') + ..write(checkpoint.autoVersion ? '1' : '0') + ..write('|'); + } + } else { + for (final step in _steps) { + basis + ..write(step.name) + ..write(':') + ..write(step.kind.name) + ..write(':') + ..write(step.autoVersion ? '1' : '0') + ..write('|'); + } } return _stableHexDigest(basis.toString()); } /// Serialize the workflow definition for introspection. Map toJson() { - final steps = >[]; + final serializedSteps = >[]; for (var i = 0; i < _steps.length; i += 1) { final step = _steps[i].toJson(); step['position'] = i; - steps.add(step); + serializedSteps.add(step); + } + final serializedCheckpoints = >[]; + for (var i = 0; i < _checkpoints.length; i += 1) { + final checkpoint = _checkpoints[i].toJson(); + checkpoint['position'] = i; + serializedCheckpoints.add(checkpoint); } return { 'name': name, @@ -300,7 +345,8 @@ class WorkflowDefinition { if (version != null) 'version': version, if (description != null) 'description': description, if (metadata != null) 'metadata': metadata, - 'steps': steps, + if (_steps.isNotEmpty) 'steps': serializedSteps, + if (_checkpoints.isNotEmpty) 'checkpoints': serializedCheckpoints, 'edges': _edges.map((edge) => edge.toJson()).toList(), }; } diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index c6fc2f70..df700838 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -51,6 +51,23 @@ class WorkflowRef { } } +/// Shared typed workflow-start surface used by apps, runtimes, and contexts. +abstract interface class WorkflowCaller { + /// Starts a workflow from a typed [WorkflowRef]. + Future startWorkflowRef( + WorkflowRef definition, + TParams params, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }); + + /// Starts a workflow from a prebuilt [WorkflowStartCall]. + Future startWorkflowCall( + WorkflowStartCall call, + ); +} + /// Typed start request built from a [WorkflowRef]. class WorkflowStartCall { const WorkflowStartCall._({ @@ -81,4 +98,19 @@ class WorkflowStartCall { /// Encodes typed parameters into the workflow parameter map. Map encodeParams() => definition.encodeParams(params); + + /// Returns a copy of this call with updated workflow start options. + WorkflowStartCall copyWith({ + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return WorkflowStartCall._( + definition: definition, + params: params, + parentRunId: parentRunId ?? this.parentRunId, + ttl: ttl ?? this.ttl, + cancellationPolicy: cancellationPolicy ?? this.cancellationPolicy, + ); + } } diff --git a/packages/stem/lib/src/workflow/core/workflow_runtime_metadata.dart b/packages/stem/lib/src/workflow/core/workflow_runtime_metadata.dart index ff9e15a9..263c4ec4 100644 --- a/packages/stem/lib/src/workflow/core/workflow_runtime_metadata.dart +++ b/packages/stem/lib/src/workflow/core/workflow_runtime_metadata.dart @@ -3,6 +3,9 @@ import 'dart:collection'; /// Reserved params key storing internal runtime metadata for workflow runs. const String workflowRuntimeMetadataParamKey = '__stem.workflow.runtime'; +/// Reserved params key storing the parent workflow run identifier. +const String workflowParentRunIdParamKey = '__stem.workflow.parentRunId'; + /// Logical channel used by workflow-related task enqueues. enum WorkflowChannelKind { /// Orchestration channel used by workflow continuation tasks. @@ -151,20 +154,28 @@ class WorkflowRunRuntimeMetadata { } /// Returns a new params map containing this metadata under the reserved key. - Map attachToParams(Map params) { + Map attachToParams( + Map params, { + String? parentRunId, + }) { return Map.unmodifiable({ ...params, workflowRuntimeMetadataParamKey: toJson(), + if (parentRunId != null && parentRunId.isNotEmpty) + workflowParentRunIdParamKey: parentRunId, }); } /// Returns params without internal runtime metadata. static Map stripFromParams(Map params) { if (!params.containsKey(workflowRuntimeMetadataParamKey)) { - return Map.unmodifiable(params); + if (!params.containsKey(workflowParentRunIdParamKey)) { + return Map.unmodifiable(params); + } } final copy = Map.from(params) - ..remove(workflowRuntimeMetadataParamKey); + ..remove(workflowRuntimeMetadataParamKey) + ..remove(workflowParentRunIdParamKey); return UnmodifiableMapView(copy); } } diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index 467885de..afe204d3 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -1,5 +1,5 @@ import 'package:stem/src/core/payload_codec.dart'; -import 'package:stem/src/workflow/core/flow_step.dart'; +import 'package:stem/src/workflow/core/workflow_checkpoint.dart'; import 'package:stem/src/workflow/core/workflow_definition.dart'; /// High-level workflow facade that allows scripts to be authored as a single @@ -13,8 +13,7 @@ class WorkflowScript { WorkflowScript({ required String name, required WorkflowScriptBody run, - Iterable steps = const [], - Iterable checkpoints = const [], + Iterable checkpoints = const [], String? version, String? description, Map? metadata, @@ -22,7 +21,6 @@ class WorkflowScript { }) : definition = WorkflowDefinition.script( name: name, run: run, - steps: steps, checkpoints: checkpoints, version: version, description: description, diff --git a/packages/stem/lib/src/workflow/core/workflow_script_context.dart b/packages/stem/lib/src/workflow/core/workflow_script_context.dart index 420495f1..ef4cfb0a 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script_context.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/workflow/core/flow_context.dart' show FlowContext; +import 'package:stem/src/workflow/core/workflow_ref.dart'; /// Runtime context exposed to workflow scripts. Implementations are provided by /// the workflow runtime so scripts can execute with durable semantics. @@ -16,8 +17,9 @@ abstract class WorkflowScriptContext { /// Parameters supplied when the workflow was started. Map get params; - /// Invokes or replays a workflow step. The provided [handler] persists its - /// return value and the resolved value is replayed on subsequent runs. + /// Invokes or replays a workflow checkpoint. The provided [handler] + /// persists its return value and the resolved value is replayed on + /// subsequent runs. Future step( String name, FutureOr Function(WorkflowScriptStepContext context) handler, { @@ -25,8 +27,8 @@ abstract class WorkflowScriptContext { }); } -/// Context provided to each script step invocation. Mirrors [FlowContext] but -/// tailored for the facade helpers. +/// Context provided to each script checkpoint invocation. Mirrors +/// [FlowContext] but tailored for the facade helpers. abstract class WorkflowScriptStepContext { /// Name of the workflow currently executing. String get workflow; @@ -34,23 +36,23 @@ abstract class WorkflowScriptStepContext { /// Identifier for the workflow run. String get runId; - /// Name of the current step. + /// Name of the current checkpoint. String get stepName; - /// Zero-based step index in the workflow definition. + /// Zero-based checkpoint index in the workflow definition. int get stepIndex; - /// Iteration count for looped steps. + /// Iteration count for looped checkpoints. int get iteration; /// Parameters provided when the workflow started. Map get params; - /// Result of the previous step, if any. + /// Result of the previous checkpoint, if any. Object? get previousResult; - /// Schedules a wake-up after [duration]. The workflow suspends once the step - /// handler returns. + /// Schedules a wake-up after [duration]. The workflow suspends once the + /// checkpoint handler returns. Future sleep(Duration duration, {Map? data}); /// Suspends the workflow until the given [topic] is emitted. @@ -61,12 +63,15 @@ abstract class WorkflowScriptStepContext { }); /// Returns and clears the resume payload provided by the runtime when the - /// step resumes after a suspension. + /// checkpoint resumes after a suspension. Object? takeResumeData(); - /// Returns a stable idempotency key derived from workflow/run/step. + /// Returns a stable idempotency key derived from workflow/run/checkpoint. String idempotencyKey([String? scope]); /// Optional enqueuer for scheduling tasks with workflow metadata. TaskEnqueuer? get enqueuer; + + /// Optional typed workflow caller for spawning child workflows. + WorkflowCaller? get workflows; } diff --git a/packages/stem/lib/src/workflow/runtime/workflow_manifest.dart b/packages/stem/lib/src/workflow/runtime/workflow_manifest.dart index 091b4b32..6dfa3ddb 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_manifest.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_manifest.dart @@ -3,15 +3,6 @@ import 'dart:convert'; import 'package:stem/src/workflow/core/flow_step.dart'; import 'package:stem/src/workflow/core/workflow_definition.dart'; -/// Distinguishes between declared flow steps and script checkpoints. -enum WorkflowManifestStepRole { - /// Step that belongs to a declarative flow execution plan. - flowStep, - - /// Checkpoint declared by a script workflow for tooling/introspection. - scriptCheckpoint, -} - /// Immutable manifest entry describing a workflow definition. class WorkflowManifestEntry { /// Creates a workflow manifest entry. @@ -23,6 +14,7 @@ class WorkflowManifestEntry { this.description, this.metadata, this.steps = const [], + this.checkpoints = const [], }); /// Stable workflow identifier. @@ -43,21 +35,18 @@ class WorkflowManifestEntry { /// Optional workflow metadata. final Map? metadata; - /// Declared flow steps or script checkpoints. - final List steps; + /// Declared flow steps. + final List steps; + + /// Declared script checkpoints. + final List checkpoints; /// Human-friendly label for the declared nodes on this workflow. - String get stepCollectionLabel => + String get declaredNodeLabel => kind == WorkflowDefinitionKind.script ? 'checkpoints' : 'steps'; - /// Alias for [steps] when treating script nodes as checkpoints. - List get checkpoints => steps; - /// Serializes this entry to a JSON-compatible map. Map toJson() { - final serializedSteps = steps - .map((step) => step.toJson()) - .toList(growable: false); return { 'id': id, 'name': name, @@ -65,21 +54,24 @@ class WorkflowManifestEntry { if (version != null) 'version': version, if (description != null) 'description': description, if (metadata != null) 'metadata': metadata, - 'stepCollectionLabel': stepCollectionLabel, - 'steps': serializedSteps, - if (kind == WorkflowDefinitionKind.script) 'checkpoints': serializedSteps, + 'declaredNodeLabel': declaredNodeLabel, + if (steps.isNotEmpty) + 'steps': steps.map((step) => step.toJson()).toList(growable: false), + if (checkpoints.isNotEmpty) + 'checkpoints': checkpoints + .map((checkpoint) => checkpoint.toJson()) + .toList(growable: false), }; } } -/// Immutable manifest entry describing a workflow step or script checkpoint. -class WorkflowManifestStep { - /// Creates a workflow step manifest entry. - const WorkflowManifestStep({ +/// Immutable manifest entry describing a declared flow step. +class WorkflowManifestFlowStep { + /// Creates a flow step manifest entry. + const WorkflowManifestFlowStep({ required this.id, required this.name, required this.position, - required this.role, required this.kind, required this.autoVersion, this.title, @@ -96,9 +88,6 @@ class WorkflowManifestStep { /// Zero-based position in the workflow. final int position; - /// Whether this node is part of a flow plan or a script checkpoint list. - final WorkflowManifestStepRole role; - /// Step kind. final WorkflowStepKind kind; @@ -120,7 +109,59 @@ class WorkflowManifestStep { 'id': id, 'name': name, 'position': position, - 'role': role.name, + 'kind': kind.name, + 'autoVersion': autoVersion, + if (title != null) 'title': title, + if (taskNames.isNotEmpty) 'taskNames': taskNames, + if (metadata != null) 'metadata': metadata, + }; + } +} + +/// Immutable manifest entry describing a declared script checkpoint. +class WorkflowManifestCheckpoint { + /// Creates a script checkpoint manifest entry. + const WorkflowManifestCheckpoint({ + required this.id, + required this.name, + required this.position, + required this.kind, + required this.autoVersion, + this.title, + this.taskNames = const [], + this.metadata, + }); + + /// Stable checkpoint identifier. + final String id; + + /// Checkpoint name. + final String name; + + /// Zero-based position in the script declaration list. + final int position; + + /// Checkpoint kind. + final WorkflowStepKind kind; + + /// Whether this checkpoint auto-versions replays. + final bool autoVersion; + + /// Optional title. + final String? title; + + /// Associated task names. + final List taskNames; + + /// Optional checkpoint metadata. + final Map? metadata; + + /// Serializes this entry to a JSON-compatible map. + Map toJson() { + return { + 'id': id, + 'name': name, + 'position': position, 'kind': kind.name, 'autoVersion': autoVersion, if (title != null) 'title': title, @@ -135,24 +176,40 @@ extension WorkflowManifestDefinition on WorkflowDefinition { /// Builds a manifest entry for this definition. WorkflowManifestEntry toManifestEntry() { final workflowId = stableId; - final stepEntries = []; - for (var index = 0; index < steps.length; index += 1) { - final step = steps[index]; - stepEntries.add( - WorkflowManifestStep( - id: _stableHexDigest('$workflowId:${step.name}:$index'), - name: step.name, - position: index, - role: isScript - ? WorkflowManifestStepRole.scriptCheckpoint - : WorkflowManifestStepRole.flowStep, - kind: step.kind, - autoVersion: step.autoVersion, - title: step.title, - taskNames: step.taskNames, - metadata: step.metadata, - ), - ); + final stepEntries = []; + final checkpointEntries = []; + if (isScript) { + for (var index = 0; index < checkpoints.length; index += 1) { + final checkpoint = checkpoints[index]; + checkpointEntries.add( + WorkflowManifestCheckpoint( + id: _stableHexDigest('$workflowId:${checkpoint.name}:$index'), + name: checkpoint.name, + position: index, + kind: checkpoint.kind, + autoVersion: checkpoint.autoVersion, + title: checkpoint.title, + taskNames: checkpoint.taskNames, + metadata: checkpoint.metadata, + ), + ); + } + } else { + for (var index = 0; index < steps.length; index += 1) { + final step = steps[index]; + stepEntries.add( + WorkflowManifestFlowStep( + id: _stableHexDigest('$workflowId:${step.name}:$index'), + name: step.name, + position: index, + kind: step.kind, + autoVersion: step.autoVersion, + title: step.title, + taskNames: step.taskNames, + metadata: step.metadata, + ), + ); + } } return WorkflowManifestEntry( id: workflowId, @@ -164,6 +221,7 @@ extension WorkflowManifestDefinition on WorkflowDefinition { description: description, metadata: metadata, steps: stepEntries, + checkpoints: checkpointEntries, ); } } diff --git a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart index bf03dfa7..057aac88 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart @@ -67,7 +67,7 @@ const int _leaseConflictMaxRetries = 1000000; /// The runtime is durable: each step is re-executed from the top after a /// suspension or worker crash. Handlers must therefore be idempotent and rely /// on persisted step outputs or resume payloads to detect prior progress. -class WorkflowRuntime { +class WorkflowRuntime implements WorkflowCaller { /// Creates a workflow runtime backed by a [Stem] instance and /// [WorkflowStore]. WorkflowRuntime({ @@ -183,7 +183,10 @@ class WorkflowRuntime { encryptionEnabled: _stem.signer != null, streamId: '${name}_$requestedRunId', ); - final persistedParams = runtimeMetadata.attachToParams(params); + final persistedParams = runtimeMetadata.attachToParams( + params, + parentRunId: parentRunId, + ); final runId = await _store.createRun( runId: requestedRunId, workflow: name, @@ -210,6 +213,7 @@ class WorkflowRuntime { } /// Starts a workflow from a typed [WorkflowRef]. + @override Future startWorkflowRef( WorkflowRef definition, TParams params, { @@ -227,6 +231,7 @@ class WorkflowRuntime { } /// Starts a workflow from a prebuilt [WorkflowStartCall]. + @override Future startWorkflowCall( WorkflowStartCall call, ) { @@ -419,14 +424,14 @@ class WorkflowRuntime { return WorkflowRunView.fromState(state); } - /// Returns persisted step views for [runId]. - Future> viewSteps(String runId) async { + /// Returns persisted checkpoint views for [runId]. + Future> viewCheckpoints(String runId) async { final state = await _store.get(runId); if (state == null) return const []; - final steps = await _store.listSteps(runId); - return steps + final checkpoints = await _store.listSteps(runId); + return checkpoints .map( - (entry) => WorkflowStepView.fromEntry( + (entry) => WorkflowCheckpointView.fromEntry( runId: runId, workflow: state.workflow, entry: entry, @@ -435,12 +440,12 @@ class WorkflowRuntime { .toList(growable: false); } - /// Returns combined run+step drilldown view for [runId]. + /// Returns combined run+checkpoint drilldown view for [runId]. Future viewRunDetail(String runId) async { final run = await viewRun(runId); if (run == null) return null; - final steps = await viewSteps(runId); - return WorkflowRunDetailView(run: run, steps: steps); + final checkpoints = await viewCheckpoints(runId); + return WorkflowRunDetailView(run: run, checkpoints: checkpoints); } /// Returns uniform run views filtered by workflow/status. @@ -647,6 +652,7 @@ class WorkflowRuntime { baseMeta: stepMeta, targetExecutionQueue: runState.executionQueue, ), + workflows: _ChildWorkflowCaller(runtime: this, parentRunId: runId), ); resumeData = null; dynamic result; @@ -882,14 +888,14 @@ class WorkflowRuntime { ), ); } - final steps = await _store.listSteps(runId); + final checkpoints = await _store.listSteps(runId); final completedIterations = await _loadCompletedIterations(runId); Object? previousResult; - if (steps.isNotEmpty) { + if (checkpoints.isNotEmpty) { previousResult = definition - .stepByName(steps.last.baseName) - ?.decodeValue(steps.last.value) ?? - steps.last.value; + .checkpointByName(checkpoints.last.baseName) + ?.decodeValue(checkpoints.last.value) ?? + checkpoints.last.value; } final execution = _WorkflowScriptExecution( runtime: this, @@ -898,7 +904,7 @@ class WorkflowRuntime { completedIterations: completedIterations, definition: definition, previousResult: previousResult, - initialStepIndex: steps.length, + initialStepIndex: checkpoints.length, suspensionData: runState.suspensionData, policy: runState.cancellationPolicy, ); @@ -1014,7 +1020,7 @@ class WorkflowRuntime { final entries = await _store.listSteps(runId); final counts = {}; for (final entry in entries) { - final base = _baseStepName(entry.name); + final base = _basePersistedNodeName(entry.name); final suffix = _parseIterationSuffix(entry.name); final nextIndex = suffix != null ? suffix + 1 : 1; final current = counts[base] ?? 0; @@ -1078,8 +1084,8 @@ class WorkflowRuntime { return int.tryParse(suffix); } - /// Removes an iteration suffix from a versioned step name. - String _baseStepName(String name) { + /// Removes an iteration suffix from a persisted step/checkpoint name. + String _basePersistedNodeName(String name) { final hashIndex = name.indexOf('#'); if (hashIndex == -1) return name; return name.substring(0, hashIndex); @@ -1422,10 +1428,10 @@ class _WorkflowScriptExecution implements WorkflowScriptContext { int? _suspensionIteration; Object? _resumePayload; - /// Whether a script step suspended the run. + /// Whether a script checkpoint suspended the run. bool get wasSuspended => _wasSuspended; - /// Last executed step name, if any. + /// Last executed checkpoint name, if any. String? get lastStepName => _lastStepName; @override @@ -1443,7 +1449,7 @@ class _WorkflowScriptExecution implements WorkflowScriptContext { FutureOr Function(WorkflowScriptStepContext context) handler, { bool autoVersion = false, }) async { - /// Executes a script step with checkpoint replay and suspension handling. + /// Executes a script checkpoint with replay and suspension handling. _lastStepName = name; final policy = this.policy; if (policy != null && policy.maxRunDuration != null) { @@ -1490,13 +1496,13 @@ class _WorkflowScriptExecution implements WorkflowScriptContext { ), ); - final declaredStep = definition.stepByName(name); + final declaredCheckpoint = definition.checkpointByName(name); final cached = await runtime._store.readStep( runId, checkpointName, ); if (cached != null) { - final decodedCached = declaredStep?.decodeValue(cached) ?? cached; + final decodedCached = declaredCheckpoint?.decodeValue(cached) ?? cached; _previousResult = decodedCached; await runtime._recordStepEvent( WorkflowStepEventType.completed, @@ -1534,6 +1540,7 @@ class _WorkflowScriptExecution implements WorkflowScriptContext { baseMeta: stepMeta, targetExecutionQueue: runState.executionQueue, ), + workflows: _ChildWorkflowCaller(runtime: runtime, parentRunId: runId), ); T result; try { @@ -1560,7 +1567,7 @@ class _WorkflowScriptExecution implements WorkflowScriptContext { } } - final storedResult = declaredStep?.encodeValue(result) ?? result; + final storedResult = declaredCheckpoint?.encodeValue(result) ?? result; await runtime._store.saveStep(runId, checkpointName, storedResult); await runtime._extendLeases(taskContext, runId); await runtime._recordStepEvent( @@ -1580,7 +1587,7 @@ class _WorkflowScriptExecution implements WorkflowScriptContext { return result; } - /// Computes the next iteration for an auto-versioned step. + /// Computes the next iteration for an auto-versioned checkpoint. int _nextIteration(String name) { final completed = _completedIterations[name] ?? 0; if (_suspensionStep == name && _suspensionIteration != null) { @@ -1589,7 +1596,7 @@ class _WorkflowScriptExecution implements WorkflowScriptContext { return completed; } - /// Returns resume payload if it matches the current step/iteration. + /// Returns resume payload if it matches the current checkpoint/iteration. Object? _takeResumePayload(String stepName, int? iteration) { final matchesStep = _suspensionStep == stepName; if (!matchesStep) return null; @@ -1722,10 +1729,10 @@ class _WorkflowScriptExecution implements WorkflowScriptContext { _wasSuspended = true; } - /// Previously completed step result, if any. + /// Previously completed checkpoint result, if any. Object? get previousResult => _previousResult; - /// Builds a stable idempotency key for a step/iteration scope. + /// Builds a stable idempotency key for a checkpoint/iteration scope. String idempotencyKey(String stepName, int iteration, [String? scope]) { final defaultScope = iteration > 0 ? '$stepName#$iteration' : stepName; final effectiveScope = (scope == null || scope.isEmpty) @@ -1735,7 +1742,7 @@ class _WorkflowScriptExecution implements WorkflowScriptContext { } } -/// Workflow script step context used by script-defined workflows. +/// Workflow script checkpoint context used by script-defined workflows. class _WorkflowScriptStepContextImpl implements WorkflowScriptStepContext { _WorkflowScriptStepContextImpl({ required this.execution, @@ -1744,6 +1751,7 @@ class _WorkflowScriptStepContextImpl implements WorkflowScriptStepContext { required int iteration, Object? resumeData, this.enqueuer, + this.workflows, }) : _stepName = stepName, _stepIndex = stepIndex, _iteration = iteration, @@ -1831,6 +1839,9 @@ class _WorkflowScriptStepContextImpl implements WorkflowScriptStepContext { @override final TaskEnqueuer? enqueuer; + @override + final WorkflowCaller? workflows; + @override String get workflow => execution.workflow; } @@ -1899,6 +1910,42 @@ class _WorkflowStepEnqueuer implements TaskEnqueuer { } } +class _ChildWorkflowCaller implements WorkflowCaller { + const _ChildWorkflowCaller({ + required this.runtime, + required this.parentRunId, + }); + + final WorkflowRuntime runtime; + final String parentRunId; + + @override + Future startWorkflowRef( + WorkflowRef definition, + TParams params, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return runtime.startWorkflowRef( + definition, + params, + parentRunId: this.parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + + @override + Future startWorkflowCall( + WorkflowStartCall call, + ) { + return runtime.startWorkflowCall( + call.copyWith(parentRunId: parentRunId), + ); + } +} + Map _coerceEventPayload(String topic, Object? payload) { if (payload is Map) { return Map.from(payload); diff --git a/packages/stem/lib/src/workflow/runtime/workflow_views.dart b/packages/stem/lib/src/workflow/runtime/workflow_views.dart index 1a4f1075..9cc2ba08 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_views.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_views.dart @@ -87,31 +87,31 @@ class WorkflowRunView { } } -/// Uniform workflow checkpoint view for dashboard/CLI step drilldowns. -class WorkflowStepView { - /// Creates an immutable step view. - const WorkflowStepView({ +/// Uniform workflow checkpoint view for dashboard/CLI drilldowns. +class WorkflowCheckpointView { + /// Creates an immutable checkpoint view. + const WorkflowCheckpointView({ required this.runId, required this.workflow, - required this.stepName, - required this.baseStepName, + required this.checkpointName, + required this.baseCheckpointName, this.iteration, required this.position, this.completedAt, this.value, }); - /// Creates a step view from a [WorkflowStepEntry]. - factory WorkflowStepView.fromEntry({ + /// Creates a checkpoint view from a [WorkflowStepEntry]. + factory WorkflowCheckpointView.fromEntry({ required String runId, required String workflow, required WorkflowStepEntry entry, }) { - return WorkflowStepView( + return WorkflowCheckpointView( runId: runId, workflow: workflow, - stepName: entry.name, - baseStepName: entry.baseName, + checkpointName: entry.name, + baseCheckpointName: entry.baseName, iteration: entry.iteration, position: entry.position, completedAt: entry.completedAt, @@ -126,10 +126,10 @@ class WorkflowStepView { final String workflow; /// Persisted checkpoint name. - final String stepName; + final String checkpointName; /// Base step name without iteration suffix. - final String baseStepName; + final String baseCheckpointName; /// Optional iteration suffix. final int? iteration; @@ -148,8 +148,8 @@ class WorkflowStepView { return { 'runId': runId, 'workflow': workflow, - 'stepName': stepName, - 'baseStepName': baseStepName, + 'checkpointName': checkpointName, + 'baseCheckpointName': baseCheckpointName, if (iteration != null) 'iteration': iteration, 'position': position, if (completedAt != null) 'completedAt': completedAt!.toIso8601String(), @@ -158,20 +158,20 @@ class WorkflowStepView { } } -/// Combined run + step drilldown view. +/// Combined run + checkpoint drilldown view. class WorkflowRunDetailView { /// Creates an immutable run detail view. - const WorkflowRunDetailView({required this.run, required this.steps}); + const WorkflowRunDetailView({required this.run, required this.checkpoints}); /// Run summary view. final WorkflowRunView run; - /// Persisted step views. - final List steps; + /// Persisted checkpoint views. + final List checkpoints; /// Serializes this detail view into JSON. Map toJson() => { 'run': run.toJson(), - 'steps': steps.map((step) => step.toJson()).toList(), + 'checkpoints': checkpoints.map((step) => step.toJson()).toList(), }; } diff --git a/packages/stem/lib/src/workflow/workflow.dart b/packages/stem/lib/src/workflow/workflow.dart index 15d1f11c..9d0e0009 100644 --- a/packages/stem/lib/src/workflow/workflow.dart +++ b/packages/stem/lib/src/workflow/workflow.dart @@ -8,6 +8,7 @@ export 'core/flow_step.dart'; export 'core/run_state.dart'; export 'core/workflow_cancellation_policy.dart'; export 'core/workflow_clock.dart'; +export 'core/workflow_checkpoint.dart'; export 'core/workflow_definition.dart'; export 'core/workflow_ref.dart'; export 'core/workflow_result.dart'; diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index d6c977e9..bb4805ca 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -27,6 +27,40 @@ void main() { } }); + test( + 'inMemory registers module tasks and infers queued subscriptions', + () async { + final handler = FunctionTaskHandler( + name: 'test.module.queue', + options: const TaskOptions(queue: 'priority'), + entrypoint: (context, args) async => 'module-ok', + runInIsolate: false, + ); + + final app = await StemApp.inMemory( + module: StemModule(tasks: [handler]), + ); + try { + expect(app.registry.resolve('test.module.queue'), same(handler)); + expect(app.worker.subscription.queues, ['priority']); + + await app.start(); + + final taskId = await app.stem.enqueue( + 'test.module.queue', + enqueueOptions: const TaskEnqueueOptions(queue: 'priority'), + ); + final completed = await app.stem.waitForTask( + taskId, + timeout: const Duration(seconds: 2), + ); + expect(completed?.value, 'module-ok'); + } finally { + await app.shutdown(); + } + }, + ); + test('inMemory applies worker config overrides', () async { final handler = FunctionTaskHandler( name: 'test.worker-config', @@ -464,6 +498,61 @@ void main() { } }); + test( + 'inMemory infers worker subscription from module task queues', + () async { + final helperTask = FunctionTaskHandler( + name: 'workflow.module.queue-helper', + entrypoint: (context, args) async => 'queued-ok', + runInIsolate: false, + ); + final workflowApp = await StemWorkflowApp.inMemory( + module: StemModule(tasks: [helperTask]), + ); + try { + expect( + workflowApp.app.worker.subscription.queues, + unorderedEquals(['workflow', 'default']), + ); + + await workflowApp.start(); + final taskId = await workflowApp.app.stem.enqueue( + 'workflow.module.queue-helper', + ); + final result = await workflowApp.app.stem.waitForTask( + taskId, + timeout: const Duration(seconds: 2), + ); + expect(result?.value, 'queued-ok'); + } finally { + await workflowApp.shutdown(); + } + }, + ); + + test( + 'explicit workflow subscription overrides inferred module queues', + () async { + final helperTask = FunctionTaskHandler( + name: 'workflow.module.explicit-subscription', + entrypoint: (context, args) async => 'ignored', + runInIsolate: false, + ); + final workflowApp = await StemWorkflowApp.inMemory( + module: StemModule(tasks: [helperTask]), + workerConfig: StemWorkerConfig( + queue: 'workflow', + subscription: RoutingSubscription.singleQueue('workflow'), + ), + ); + try { + expect(workflowApp.app.worker.subscription.queues, ['workflow']); + } finally { + await workflowApp.shutdown(); + } + }, + ); + test('workflow refs start and decode runs through app helpers', () async { final moduleFlow = Flow( name: 'workflow.ref.flow', @@ -474,17 +563,18 @@ void main() { }); }, ); - final workflowRef = - WorkflowRef, String>( - name: 'workflow.ref.flow', - encodeParams: (params) => params, - ); + final workflowRef = WorkflowRef, String>( + name: 'workflow.ref.flow', + encodeParams: (params) => params, + ); final workflowApp = await StemWorkflowApp.inMemory(flows: [moduleFlow]); try { - final runId = await workflowRef.call( - const {'name': 'stem'}, - ).startWithApp(workflowApp); + final runId = await workflowRef + .call( + const {'name': 'stem'}, + ) + .startWithApp(workflowApp); final result = await workflowRef.waitFor( workflowApp, runId, @@ -520,18 +610,19 @@ void main() { ); }, ); - final workflowRef = - WorkflowRef, _DemoPayload>( - name: 'workflow.codec.flow', - encodeParams: (params) => params, - decodeResult: _demoPayloadCodec.decode, - ); + final workflowRef = WorkflowRef, _DemoPayload>( + name: 'workflow.codec.flow', + encodeParams: (params) => params, + decodeResult: _demoPayloadCodec.decode, + ); final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); try { - final runId = await workflowRef.call(const {}).startWithApp( - workflowApp, - ); + final runId = await workflowRef + .call(const {}) + .startWithApp( + workflowApp, + ); final result = await workflowRef.waitFor( workflowApp, runId, @@ -562,20 +653,19 @@ void main() { ); test( - 'script workflow codecs persist encoded checkpoints and decode typed results', + 'script workflow codecs persist encoded checkpoints ' + 'and decode typed results', () async { final script = WorkflowScript<_DemoPayload>( name: 'workflow.codec.script', resultCodec: _demoPayloadCodec, checkpoints: [ - FlowStep.typed<_DemoPayload>( + WorkflowCheckpoint.typed<_DemoPayload>( name: 'build', - handler: (_) async => null, valueCodec: _demoPayloadCodec, ), - FlowStep.typed<_DemoPayload>( + WorkflowCheckpoint.typed<_DemoPayload>( name: 'finish', - handler: (_) async => null, valueCodec: _demoPayloadCodec, ), ], @@ -590,18 +680,19 @@ void main() { ); }, ); - final workflowRef = - WorkflowRef, _DemoPayload>( - name: 'workflow.codec.script', - encodeParams: (params) => params, - decodeResult: _demoPayloadCodec.decode, - ); + final workflowRef = WorkflowRef, _DemoPayload>( + name: 'workflow.codec.script', + encodeParams: (params) => params, + decodeResult: _demoPayloadCodec.decode, + ); final workflowApp = await StemWorkflowApp.inMemory(scripts: [script]); try { - final runId = await workflowRef.call(const {}).startWithApp( - workflowApp, - ); + final runId = await workflowRef + .call(const {}) + .startWithApp( + workflowApp, + ); final result = await workflowRef.waitFor( workflowApp, runId, diff --git a/packages/stem/test/unit/workflow/workflow_manifest_test.dart b/packages/stem/test/unit/workflow/workflow_manifest_test.dart index e1559327..21d27960 100644 --- a/packages/stem/test/unit/workflow/workflow_manifest_test.dart +++ b/packages/stem/test/unit/workflow/workflow_manifest_test.dart @@ -21,15 +21,10 @@ void main() { expect(manifest.id, equals(firstId)); expect(manifest.name, equals('manifest.flow')); expect(manifest.kind, equals(WorkflowDefinitionKind.flow)); - expect(manifest.stepCollectionLabel, equals('steps')); - expect(manifest.checkpoints, hasLength(2)); expect(manifest.steps, hasLength(2)); + expect(manifest.checkpoints, isEmpty); expect(manifest.steps.first.position, equals(0)); expect(manifest.steps.first.name, equals('first')); - expect( - manifest.steps.first.role, - equals(WorkflowManifestStepRole.flowStep), - ); expect(manifest.steps.first.id, isNotEmpty); expect(manifest.steps.first.id, isNot(equals(manifest.steps.last.id))); }); @@ -42,37 +37,36 @@ void main() { return {'email': email, 'status': 'done'}; }, checkpoints: [ - FlowStep( + WorkflowCheckpoint( name: 'create-user', title: 'Create user', kind: WorkflowStepKind.task, taskNames: const ['user.create'], - handler: (context) async => {'id': '1'}, ), - FlowStep( + WorkflowCheckpoint( name: 'send-welcome-email', title: 'Send welcome email', kind: WorkflowStepKind.task, taskNames: const ['email.send'], - handler: (context) async => null, ), ], ).definition; final manifest = definition.toManifestEntry(); expect(manifest.kind, equals(WorkflowDefinitionKind.script)); - expect(manifest.stepCollectionLabel, equals('checkpoints')); expect(manifest.checkpoints, hasLength(2)); - expect(manifest.steps, hasLength(2)); - expect(manifest.steps.first.name, equals('create-user')); - expect(manifest.steps.first.position, equals(0)); + expect(manifest.steps, isEmpty); + expect(manifest.checkpoints.first.name, equals('create-user')); + expect(manifest.checkpoints.first.position, equals(0)); + expect( + manifest.checkpoints.first.taskNames, + equals(const ['user.create']), + ); + expect(manifest.checkpoints.last.name, equals('send-welcome-email')); + expect(manifest.checkpoints.last.position, equals(1)); expect( - manifest.steps.first.role, - equals(WorkflowManifestStepRole.scriptCheckpoint), + manifest.checkpoints.last.taskNames, + equals(const ['email.send']), ); - expect(manifest.steps.first.taskNames, equals(const ['user.create'])); - expect(manifest.steps.last.name, equals('send-welcome-email')); - expect(manifest.steps.last.position, equals(1)); - expect(manifest.steps.last.taskNames, equals(const ['email.send'])); }); } diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 89b4b0d0..cb820948 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -22,23 +22,26 @@ void main() { try { await workflowApp.start(); - final runId = await workflowRef - .call(const {'name': 'runtime'}) - .startWithRuntime(workflowApp.runtime); - final waited = await workflowRef.waitForWithRuntime( - workflowApp.runtime, + final runId = await workflowApp.runtime.startWorkflowCall( + workflowRef.call(const {'name': 'runtime'}), + ); + final waited = await workflowApp.runtime.waitForWorkflowRef( runId, + workflowRef, timeout: const Duration(seconds: 2), ); expect(waited?.value, 'hello runtime'); - final oneShot = await workflowRef - .call(const {'name': 'inline'}) - .startAndWaitWithRuntime( - workflowApp.runtime, - timeout: const Duration(seconds: 2), - ); + final inlineRunId = await workflowApp.runtime.startWorkflowRef( + workflowRef, + const {'name': 'inline'}, + ); + final oneShot = await workflowApp.runtime.waitForCompletion( + inlineRunId, + timeout: const Duration(seconds: 2), + decode: workflowRef.decode, + ); expect(oneShot?.value, 'hello inline'); } finally { diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index c04b1486..74f3e5ce 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -92,6 +92,10 @@ void main() { state.workflowParams.containsKey(workflowRuntimeMetadataParamKey), isFalse, ); + expect( + state.workflowParams.containsKey(workflowParentRunIdParamKey), + isFalse, + ); expect(introspection.runtimeEvents, isNotEmpty); expect( introspection.runtimeEvents.last.type, @@ -100,7 +104,126 @@ void main() { }, ); - test('viewRunDetail exposes uniform run and step views', () async { + test('startWorkflow persists parent run id without exposing it to handlers', () async { + runtime.registerWorkflow( + Flow( + name: 'parent.runtime.workflow', + build: (flow) { + flow.step('inspect', (context) async => context.params); + }, + ).definition, + ); + + final runId = await runtime.startWorkflow( + 'parent.runtime.workflow', + parentRunId: 'wf-parent', + params: const {'tenant': 'acme'}, + ); + + final state = await store.get(runId); + expect(state, isNotNull); + expect(state!.parentRunId, 'wf-parent'); + expect(state.workflowParams, equals(const {'tenant': 'acme'})); + expect( + state.params[workflowParentRunIdParamKey], + equals('wf-parent'), + ); + }); + + test('flow context workflows starts typed child workflows', () async { + final childRef = WorkflowRef, String>( + name: 'child.runtime.flow', + encodeParams: (params) => params, + ); + + runtime + ..registerWorkflow( + Flow( + name: 'child.runtime.flow', + build: (flow) { + flow.step('hello', (context) async { + final value = context.params['value'] as String? ?? 'child'; + return 'ok:$value'; + }); + }, + ).definition, + ) + ..registerWorkflow( + Flow( + name: 'parent.runtime.flow', + build: (flow) { + flow.step('spawn', (context) async { + return context.workflows!.startWorkflowRef( + childRef, + const {'value': 'spawned'}, + ); + }); + }, + ).definition, + ); + + final parentRunId = await runtime.startWorkflow('parent.runtime.flow'); + await runtime.executeRun(parentRunId); + + final parentState = await store.get(parentRunId); + final childRunId = parentState!.result as String; + final childState = await store.get(childRunId); + + expect(childState, isNotNull); + expect(childState!.workflow, 'child.runtime.flow'); + expect(childState.parentRunId, parentRunId); + expect(childState.workflowParams, equals(const {'value': 'spawned'})); + }); + + test('script checkpoint workflows starts typed child workflows', () async { + final childRef = WorkflowRef, String>( + name: 'child.runtime.script', + encodeParams: (params) => params, + ); + + runtime + ..registerWorkflow( + Flow( + name: 'child.runtime.script', + build: (flow) { + flow.step('hello', (context) async { + final value = context.params['value'] as String? ?? 'child'; + return 'ok:$value'; + }); + }, + ).definition, + ) + ..registerWorkflow( + WorkflowScript( + name: 'parent.runtime.script', + checkpoints: [ + WorkflowCheckpoint(name: 'spawn'), + ], + run: (script) async { + return script.step('spawn', (context) async { + return context.workflows!.startWorkflowRef( + childRef, + const {'value': 'script-child'}, + ); + }); + }, + ).definition, + ); + + final parentRunId = await runtime.startWorkflow('parent.runtime.script'); + await runtime.executeRun(parentRunId); + + final parentState = await store.get(parentRunId); + final childRunId = parentState!.result as String; + final childState = await store.get(childRunId); + + expect(childState, isNotNull); + expect(childState!.workflow, 'child.runtime.script'); + expect(childState.parentRunId, parentRunId); + expect(childState.workflowParams, equals(const {'value': 'script-child'})); + }); + + test('viewRunDetail exposes uniform run and checkpoint views', () async { runtime.registerWorkflow( Flow( name: 'views.workflow', @@ -117,9 +240,9 @@ void main() { expect(detail, isNotNull); expect(detail!.run.runId, equals(runId)); expect(detail.run.workflow, equals('views.workflow')); - expect(detail.steps, hasLength(1)); - expect(detail.steps.first.baseStepName, equals('only')); - expect(detail.steps.first.stepName, equals('only')); + expect(detail.checkpoints, hasLength(1)); + expect(detail.checkpoints.first.baseCheckpointName, equals('only')); + expect(detail.checkpoints.first.checkpointName, equals('only')); }); test('workflowManifest exposes typed manifest entries', () { diff --git a/packages/stem/tool/proxy_runtime_check.dart b/packages/stem/tool/proxy_runtime_check.dart index c33db2b8..9c0ef764 100644 --- a/packages/stem/tool/proxy_runtime_check.dart +++ b/packages/stem/tool/proxy_runtime_check.dart @@ -3,7 +3,11 @@ import 'dart:io'; import 'package:stem/stem.dart'; class ScriptDef { - Future run(WorkflowScriptContext script) async { + Future run({WorkflowScriptContext? context}) async { + assert( + context == null || context.runId.isNotEmpty, + 'workflow context should carry a runId', + ); return sendEmail('user@example.com'); } @@ -42,7 +46,7 @@ Future main() async { runtime.registerWorkflow( WorkflowScript( name: 'proxy.script', - run: (script) => ScriptProxy(script).run(script), + run: (script) => ScriptProxy(script).run(context: script), ).definition, ); @@ -50,10 +54,12 @@ Future main() async { await runtime.executeRun(runId); final detail = await runtime.viewRunDetail(runId); stdout.writeln( - 'result=${detail?.run.result} checkpoints=${detail?.steps.length}', + 'result=${detail?.run.result} checkpoints=${detail?.checkpoints.length}', ); - if ((detail?.steps.length ?? 0) > 0) { - stdout.writeln('checkpointName=${detail!.steps.first.stepName}'); + if ((detail?.checkpoints.length ?? 0) > 0) { + stdout.writeln( + 'checkpointName=${detail!.checkpoints.first.checkpointName}', + ); } await runtime.dispose(); diff --git a/packages/stem_builder/example/lib/definitions.stem.g.dart b/packages/stem_builder/example/lib/definitions.stem.g.dart index 2f87ebb1..6c8b056b 100644 --- a/packages/stem_builder/example/lib/definitions.stem.g.dart +++ b/packages/stem_builder/example/lib/definitions.stem.g.dart @@ -8,7 +8,7 @@ final List _stemFlows = [ name: "builder.example.flow", build: (flow) { final impl = BuilderExampleFlow(); - flow.step( + flow.step( "greet", (ctx) => impl.greet((_stemRequireArg(ctx.params, "name") as String)), kind: WorkflowStepKind.task, @@ -50,21 +50,18 @@ final List _stemScripts = [ WorkflowScript( name: "builder.example.user_signup", checkpoints: [ - FlowStep( + WorkflowCheckpoint( name: "create-user", - handler: _stemScriptManifestStepNoop, kind: WorkflowStepKind.task, taskNames: [], ), - FlowStep( + WorkflowCheckpoint( name: "send-welcome-email", - handler: _stemScriptManifestStepNoop, kind: WorkflowStepKind.task, taskNames: [], ), - FlowStep( + WorkflowCheckpoint( name: "send-one-week-check-in-email", - handler: _stemScriptManifestStepNoop, kind: WorkflowStepKind.task, taskNames: [], ), @@ -88,8 +85,6 @@ abstract final class StemWorkflowDefinitions { ); } -Future _stemScriptManifestStepNoop(FlowContext context) async => null; - Object? _stemRequireArg(Map args, String name) { if (!args.containsKey(name)) { throw ArgumentError('Missing required argument "$name".'); diff --git a/packages/stem_builder/lib/src/stem_registry_builder.dart b/packages/stem_builder/lib/src/stem_registry_builder.dart index 1b715cbd..383df63c 100644 --- a/packages/stem_builder/lib/src/stem_registry_builder.dart +++ b/packages/stem_builder/lib/src/stem_registry_builder.dart @@ -200,8 +200,10 @@ class StemRegistryBuilder implements Builder { _WorkflowStepInfo( name: stepName, method: method.displayName, - acceptsFlowContext: false, - acceptsScriptStepContext: stepBinding.acceptsContext, + flowContextParameterName: null, + flowContextIsNamed: false, + scriptStepContextParameterName: stepBinding.contextParameterName, + scriptStepContextIsNamed: stepBinding.contextIsNamed, valueParameters: stepBinding.valueParameters, returnTypeCode: stepBinding.returnTypeCode, stepValueTypeCode: stepBinding.stepValueTypeCode, @@ -225,7 +227,7 @@ class StemRegistryBuilder implements Builder { classElement, runMethod, scriptSteps, - runAcceptsScriptContext: runBinding.acceptsContext, + runAcceptsScriptContext: runBinding.contextParameterName != null, ); workflows.add( _WorkflowInfo.script( @@ -234,7 +236,8 @@ class StemRegistryBuilder implements Builder { className: classElement.displayName, steps: scriptSteps, runMethod: runMethod.displayName, - runAcceptsScriptContext: runBinding.acceptsContext, + runContextParameterName: runBinding.contextParameterName, + runContextIsNamed: runBinding.contextIsNamed, runValueParameters: runBinding.valueParameters, resultTypeCode: runBinding.resultTypeCode, resultPayloadCodecTypeCode: runBinding.resultPayloadCodecTypeCode, @@ -288,8 +291,10 @@ class StemRegistryBuilder implements Builder { _WorkflowStepInfo( name: stepName, method: method.displayName, - acceptsFlowContext: stepBinding.acceptsContext, - acceptsScriptStepContext: false, + flowContextParameterName: stepBinding.contextParameterName, + flowContextIsNamed: stepBinding.contextIsNamed, + scriptStepContextParameterName: null, + scriptStepContextIsNamed: false, valueParameters: stepBinding.valueParameters, returnTypeCode: null, stepValueTypeCode: stepBinding.stepValueTypeCode, @@ -368,10 +373,12 @@ class StemRegistryBuilder implements Builder { name: taskName, importAlias: '', function: function.displayName, - adapterName: taskBinding.usesLegacyMapArgs + adapterName: + taskBinding.usesLegacyMapArgs && !taskBinding.contextIsNamed ? null : '_stemTaskAdapter${taskAdapterIndex++}', - acceptsTaskContext: taskBinding.acceptsContext, + taskContextParameterName: taskBinding.contextParameterName, + taskContextIsNamed: taskBinding.contextIsNamed, valueParameters: taskBinding.valueParameters, usesLegacyMapArgs: taskBinding.usesLegacyMapArgs, resultTypeCode: taskBinding.resultTypeCode, @@ -432,16 +439,19 @@ class StemRegistryBuilder implements Builder { } final parameters = method.formalParameters; - var acceptsContext = false; - var startIndex = 0; - if (parameters.isNotEmpty && - scriptContextChecker.isAssignableFromType(parameters.first.type)) { - acceptsContext = true; - startIndex = 1; - } + final contextParameter = _extractInjectedContextParameter( + parameters, + scriptContextChecker, + method, + annotationLabel: '@workflow.run method', + contextTypeLabel: 'WorkflowScriptContext', + ); final valueParameters = <_ValueParameterInfo>[]; - for (final parameter in parameters.skip(startIndex)) { + for (final parameter in parameters) { + if (identical(parameter, contextParameter?.parameter)) { + continue; + } if (!parameter.isRequiredPositional) { throw InvalidGenerationSourceError( '@workflow.run method ${method.displayName} only supports required positional serializable or codec-backed parameters after WorkflowScriptContext.', @@ -459,7 +469,8 @@ class StemRegistryBuilder implements Builder { } return _RunBinding( - acceptsContext: acceptsContext, + contextParameterName: contextParameter?.name, + contextIsNamed: contextParameter?.isNamed ?? false, valueParameters: valueParameters, resultTypeCode: _workflowResultTypeCode(method.returnType), resultPayloadCodecTypeCode: _workflowResultPayloadCodecTypeCode( @@ -479,16 +490,19 @@ class StemRegistryBuilder implements Builder { ); } final parameters = method.formalParameters; - var acceptsContext = false; - var startIndex = 0; - if (parameters.isNotEmpty && - flowContextChecker.isAssignableFromType(parameters.first.type)) { - acceptsContext = true; - startIndex = 1; - } + final contextParameter = _extractInjectedContextParameter( + parameters, + flowContextChecker, + method, + annotationLabel: '@workflow.step method', + contextTypeLabel: 'FlowContext', + ); final valueParameters = <_ValueParameterInfo>[]; - for (final parameter in parameters.skip(startIndex)) { + for (final parameter in parameters) { + if (identical(parameter, contextParameter?.parameter)) { + continue; + } if (!parameter.isRequiredPositional) { throw InvalidGenerationSourceError( '@workflow.step method ${method.displayName} only supports required positional serializable or codec-backed parameters after FlowContext.', @@ -506,7 +520,8 @@ class StemRegistryBuilder implements Builder { } return _FlowStepBinding( - acceptsContext: acceptsContext, + contextParameterName: contextParameter?.name, + contextIsNamed: contextParameter?.isNamed ?? false, valueParameters: valueParameters, stepValueTypeCode: _workflowResultTypeCode(method.returnType), stepValuePayloadCodecTypeCode: _workflowResultPayloadCodecTypeCode( @@ -537,16 +552,19 @@ class StemRegistryBuilder implements Builder { final stepValueType = _extractStepValueType(returnType); final parameters = method.formalParameters; - var acceptsContext = false; - var startIndex = 0; - if (parameters.isNotEmpty && - scriptStepContextChecker.isAssignableFromType(parameters.first.type)) { - acceptsContext = true; - startIndex = 1; - } + final contextParameter = _extractInjectedContextParameter( + parameters, + scriptStepContextChecker, + method, + annotationLabel: '@workflow.step method', + contextTypeLabel: 'WorkflowScriptStepContext', + ); final valueParameters = <_ValueParameterInfo>[]; - for (final parameter in parameters.skip(startIndex)) { + for (final parameter in parameters) { + if (identical(parameter, contextParameter?.parameter)) { + continue; + } if (!parameter.isRequiredPositional) { throw InvalidGenerationSourceError( '@workflow.step method ${method.displayName} only supports required positional serializable or codec-backed parameters after WorkflowScriptStepContext.', @@ -564,7 +582,8 @@ class StemRegistryBuilder implements Builder { } return _ScriptStepBinding( - acceptsContext: acceptsContext, + contextParameterName: contextParameter?.name, + contextIsNamed: contextParameter?.isNamed ?? false, valueParameters: valueParameters, returnTypeCode: _typeCode(returnType), stepValueTypeCode: _typeCode(stepValueType), @@ -578,24 +597,27 @@ class StemRegistryBuilder implements Builder { TypeChecker mapChecker, ) { final parameters = function.formalParameters; - var acceptsContext = false; - var startIndex = 0; - if (parameters.isNotEmpty && - taskContextChecker.isAssignableFromType(parameters.first.type)) { - acceptsContext = true; - startIndex = 1; - } + final contextParameter = _extractInjectedContextParameter( + parameters, + taskContextChecker, + function, + annotationLabel: '@TaskDefn function', + contextTypeLabel: 'TaskInvocationContext', + ); - final remaining = parameters.skip(startIndex).toList(growable: false); + final remaining = parameters + .where((parameter) => !identical(parameter, contextParameter?.parameter)) + .toList(growable: false); final legacyMapSignature = - acceptsContext && + contextParameter != null && remaining.length == 1 && mapChecker.isAssignableFromType(remaining.first.type) && _isStringObjectMap(remaining.first.type) && remaining.first.isRequiredPositional; if (legacyMapSignature) { return _TaskBinding( - acceptsContext: true, + contextParameterName: contextParameter.name, + contextIsNamed: contextParameter.isNamed, valueParameters: [], usesLegacyMapArgs: true, resultTypeCode: _taskResultTypeCode(function.returnType), @@ -624,7 +646,8 @@ class StemRegistryBuilder implements Builder { } return _TaskBinding( - acceptsContext: acceptsContext, + contextParameterName: contextParameter?.name, + contextIsNamed: contextParameter?.isNamed ?? false, valueParameters: valueParameters, usesLegacyMapArgs: false, resultTypeCode: _taskResultTypeCode(function.returnType), @@ -649,6 +672,55 @@ class StemRegistryBuilder implements Builder { ); } + static _InjectedContextParameter? _extractInjectedContextParameter( + List parameters, + TypeChecker checker, + Element element, { + required String annotationLabel, + required String contextTypeLabel, + }) { + _InjectedContextParameter? contextParameter; + if (parameters.isNotEmpty && + parameters.first.isRequiredPositional && + checker.isAssignableFromType(parameters.first.type)) { + contextParameter = _InjectedContextParameter( + parameter: parameters.first, + name: parameters.first.displayName, + isNamed: false, + ); + } + + for (final parameter in parameters.skip( + contextParameter == null ? 0 : 1, + )) { + if (!checker.isAssignableFromType(parameter.type)) { + continue; + } + if (contextParameter != null) { + throw InvalidGenerationSourceError( + '$annotationLabel ${element.displayName} may declare at most one ' + '$contextTypeLabel parameter.', + element: element, + ); + } + if (!parameter.isNamed || parameter.isRequiredNamed) { + throw InvalidGenerationSourceError( + '$annotationLabel ${element.displayName} must declare ' + '$contextTypeLabel as the first positional parameter or an ' + 'optional named parameter.', + element: element, + ); + } + contextParameter = _InjectedContextParameter( + parameter: parameter, + name: parameter.displayName, + isNamed: true, + ); + } + + return contextParameter; + } + static String _taskResultTypeCode(DartType returnType) { final valueType = _extractAsyncValueType(returnType); if (valueType is VoidType || valueType is NeverType) { @@ -825,7 +897,8 @@ class _WorkflowInfo { this.metadata, }) : kind = WorkflowKind.flow, runMethod = null, - runAcceptsScriptContext = false, + runContextParameterName = null, + runContextIsNamed = false, runValueParameters = const []; _WorkflowInfo.script({ @@ -834,7 +907,8 @@ class _WorkflowInfo { required this.className, required this.steps, required this.runMethod, - required this.runAcceptsScriptContext, + required this.runContextParameterName, + required this.runContextIsNamed, required this.runValueParameters, required this.resultTypeCode, required this.resultPayloadCodecTypeCode, @@ -853,7 +927,8 @@ class _WorkflowInfo { final String resultTypeCode; final String? resultPayloadCodecTypeCode; final String? runMethod; - final bool runAcceptsScriptContext; + final String? runContextParameterName; + final bool runContextIsNamed; final List<_ValueParameterInfo> runValueParameters; final String? starterNameOverride; final String? nameFieldOverride; @@ -866,8 +941,10 @@ class _WorkflowStepInfo { const _WorkflowStepInfo({ required this.name, required this.method, - required this.acceptsFlowContext, - required this.acceptsScriptStepContext, + required this.flowContextParameterName, + required this.flowContextIsNamed, + required this.scriptStepContextParameterName, + required this.scriptStepContextIsNamed, required this.valueParameters, required this.returnTypeCode, required this.stepValueTypeCode, @@ -881,8 +958,10 @@ class _WorkflowStepInfo { final String name; final String method; - final bool acceptsFlowContext; - final bool acceptsScriptStepContext; + final String? flowContextParameterName; + final bool flowContextIsNamed; + final String? scriptStepContextParameterName; + final bool scriptStepContextIsNamed; final List<_ValueParameterInfo> valueParameters; final String? returnTypeCode; final String? stepValueTypeCode; @@ -892,6 +971,10 @@ class _WorkflowStepInfo { final DartObject? kind; final DartObject? taskNames; final DartObject? metadata; + + bool get acceptsFlowContext => flowContextParameterName != null; + + bool get acceptsScriptStepContext => scriptStepContextParameterName != null; } void _ensureUniqueWorkflowStepNames( @@ -1047,7 +1130,8 @@ class _TaskInfo { required this.importAlias, required this.function, required this.adapterName, - required this.acceptsTaskContext, + required this.taskContextParameterName, + required this.taskContextIsNamed, required this.valueParameters, required this.usesLegacyMapArgs, required this.resultTypeCode, @@ -1061,7 +1145,8 @@ class _TaskInfo { final String importAlias; final String function; final String? adapterName; - final bool acceptsTaskContext; + final String? taskContextParameterName; + final bool taskContextIsNamed; final List<_ValueParameterInfo> valueParameters; final bool usesLegacyMapArgs; final String resultTypeCode; @@ -1069,17 +1154,21 @@ class _TaskInfo { final DartObject? options; final DartObject? metadata; final bool runInIsolate; + + bool get acceptsTaskContext => taskContextParameterName != null; } class _FlowStepBinding { const _FlowStepBinding({ - required this.acceptsContext, + required this.contextParameterName, + required this.contextIsNamed, required this.valueParameters, required this.stepValueTypeCode, required this.stepValuePayloadCodecTypeCode, }); - final bool acceptsContext; + final String? contextParameterName; + final bool contextIsNamed; final List<_ValueParameterInfo> valueParameters; final String stepValueTypeCode; final String? stepValuePayloadCodecTypeCode; @@ -1087,13 +1176,15 @@ class _FlowStepBinding { class _RunBinding { const _RunBinding({ - required this.acceptsContext, + required this.contextParameterName, + required this.contextIsNamed, required this.valueParameters, required this.resultTypeCode, required this.resultPayloadCodecTypeCode, }); - final bool acceptsContext; + final String? contextParameterName; + final bool contextIsNamed; final List<_ValueParameterInfo> valueParameters; final String resultTypeCode; final String? resultPayloadCodecTypeCode; @@ -1101,14 +1192,16 @@ class _RunBinding { class _ScriptStepBinding { const _ScriptStepBinding({ - required this.acceptsContext, + required this.contextParameterName, + required this.contextIsNamed, required this.valueParameters, required this.returnTypeCode, required this.stepValueTypeCode, required this.stepValuePayloadCodecTypeCode, }); - final bool acceptsContext; + final String? contextParameterName; + final bool contextIsNamed; final List<_ValueParameterInfo> valueParameters; final String returnTypeCode; final String stepValueTypeCode; @@ -1117,14 +1210,16 @@ class _ScriptStepBinding { class _TaskBinding { const _TaskBinding({ - required this.acceptsContext, + required this.contextParameterName, + required this.contextIsNamed, required this.valueParameters, required this.usesLegacyMapArgs, required this.resultTypeCode, required this.resultPayloadCodecTypeCode, }); - final bool acceptsContext; + final String? contextParameterName; + final bool contextIsNamed; final List<_ValueParameterInfo> valueParameters; final bool usesLegacyMapArgs; final String resultTypeCode; @@ -1143,6 +1238,18 @@ class _ValueParameterInfo { final String? payloadCodecTypeCode; } +class _InjectedContextParameter { + const _InjectedContextParameter({ + required this.parameter, + required this.name, + required this.isNamed, + }); + + final FormalParameterElement parameter; + final String name; + final bool isNamed; +} + class _RegistryEmitter { _RegistryEmitter({ required this.workflows, @@ -1313,11 +1420,17 @@ class _RegistryEmitter { for (final step in workflow.steps) { final stepArgs = step.valueParameters .map((param) => _decodeArg('ctx.params', param)) - .join(', '); - final invocationArgs = [ - if (step.acceptsFlowContext) 'ctx', - if (stepArgs.isNotEmpty) stepArgs, - ].join(', '); + .toList(growable: false); + final invocationArgs = _invocationArgs( + positional: [ + if (step.acceptsFlowContext && !step.flowContextIsNamed) 'ctx', + ...stepArgs, + ], + named: { + if (step.acceptsFlowContext && step.flowContextIsNamed) + step.flowContextParameterName!: 'ctx', + }, + ); buffer.writeln(' flow.step<${step.stepValueTypeCode}>('); buffer.writeln(' ${_string(step.name)},'); buffer.writeln( @@ -1372,25 +1485,38 @@ class _RegistryEmitter { buffer.writeln(' $proxyClassName(this._script);'); buffer.writeln(' final WorkflowScriptContext _script;'); for (final step in workflow.steps) { - final signatureParts = [ - if (step.acceptsScriptStepContext) - 'WorkflowScriptStepContext context', - ...step.valueParameters.map( - (parameter) => '${parameter.typeCode} ${parameter.name}', - ), - ]; - final invocationArgs = [ - if (step.acceptsScriptStepContext) 'context', - ...step.valueParameters.map((parameter) => parameter.name), - ]; + final signature = _methodSignature( + positional: [ + if (step.acceptsScriptStepContext && !step.scriptStepContextIsNamed) + 'WorkflowScriptStepContext ${step.scriptStepContextParameterName!}', + ...step.valueParameters.map( + (parameter) => '${parameter.typeCode} ${parameter.name}', + ), + ], + named: [ + if (step.acceptsScriptStepContext && step.scriptStepContextIsNamed) + 'WorkflowScriptStepContext? ${step.scriptStepContextParameterName!}', + ], + ); + final invocationArgs = _invocationArgs( + positional: [ + if (step.acceptsScriptStepContext && !step.scriptStepContextIsNamed) + 'context', + ...step.valueParameters.map((parameter) => parameter.name), + ], + named: { + if (step.acceptsScriptStepContext && step.scriptStepContextIsNamed) + step.scriptStepContextParameterName!: 'context', + }, + ); buffer.writeln(' @override'); buffer.writeln( - ' ${step.returnTypeCode} ${step.method}(${signatureParts.join(', ')}) {', + ' ${step.returnTypeCode} ${step.method}($signature) {', ); buffer.writeln(' return _script.step<${step.stepValueTypeCode}>('); buffer.writeln(' ${_string(step.name)},'); buffer.writeln( - ' (context) => super.${step.method}(${invocationArgs.join(', ')}),', + ' (context) => super.${step.method}($invocationArgs),', ); if (step.autoVersion) { buffer.writeln(' autoVersion: true,'); @@ -1413,14 +1539,13 @@ class _RegistryEmitter { buffer.writeln(' checkpoints: ['); for (final step in workflow.steps) { if (step.stepValuePayloadCodecTypeCode != null) { - buffer.writeln(' FlowStep.typed<${step.stepValueTypeCode}>('); + buffer.writeln( + ' WorkflowCheckpoint.typed<${step.stepValueTypeCode}>(', + ); } else { - buffer.writeln(' FlowStep('); + buffer.writeln(' WorkflowCheckpoint('); } buffer.writeln(' name: ${_string(step.name)},'); - buffer.writeln( - ' handler: _stemScriptManifestStepNoop,', - ); if (step.autoVersion) { buffer.writeln(' autoVersion: true,'); } @@ -1468,22 +1593,40 @@ class _RegistryEmitter { buffer.writeln(' resultCodec: StemPayloadCodecs.$codecField,'); } if (proxyClass != null) { - final runArgs = [ - if (workflow.runAcceptsScriptContext) 'script', - ...workflow.runValueParameters.map( - (parameter) => _decodeArg('script.params', parameter), - ), - ].join(', '); + final runArgs = _invocationArgs( + positional: [ + if (workflow.runContextParameterName != null && + !workflow.runContextIsNamed) + 'script', + ...workflow.runValueParameters.map( + (parameter) => _decodeArg('script.params', parameter), + ), + ], + named: { + if (workflow.runContextParameterName != null && + workflow.runContextIsNamed) + workflow.runContextParameterName!: 'script', + }, + ); buffer.writeln( ' run: (script) => $proxyClass(script).${workflow.runMethod}($runArgs),', ); } else { - final runArgs = [ - if (workflow.runAcceptsScriptContext) 'script', - ...workflow.runValueParameters.map( - (parameter) => _decodeArg('script.params', parameter), - ), - ].join(', '); + final runArgs = _invocationArgs( + positional: [ + if (workflow.runContextParameterName != null && + !workflow.runContextIsNamed) + 'script', + ...workflow.runValueParameters.map( + (parameter) => _decodeArg('script.params', parameter), + ), + ], + named: { + if (workflow.runContextParameterName != null && + workflow.runContextIsNamed) + workflow.runContextParameterName!: 'script', + }, + ); buffer.writeln( ' run: (script) => ${_qualify(workflow.importAlias, workflow.className)}().${workflow.runMethod}($runArgs),', ); @@ -1714,14 +1857,38 @@ class _RegistryEmitter { return _lowerCamel(pascal); } + String _invocationArgs({ + List positional = const [], + Map named = const {}, + }) { + final parts = [ + ...positional.where((part) => part.isNotEmpty), + ...named.entries + .where((entry) => entry.value.isNotEmpty) + .map((entry) => '${entry.key}: ${entry.value}'), + ]; + return parts.join(', '); + } + + String _methodSignature({ + List positional = const [], + List named = const [], + }) { + final parts = [ + ...positional.where((part) => part.isNotEmpty), + ]; + if (named.isNotEmpty) { + parts.add('{${named.join(', ')}}'); + } + return parts.join(', '); + } + void _emitTasks(StringBuffer buffer) { buffer.writeln( 'final List> _stemTasks = >[', ); for (final task in tasks) { - final entrypoint = task.usesLegacyMapArgs - ? _qualify(task.importAlias, task.function) - : task.adapterName!; + final entrypoint = task.adapterName ?? _qualify(task.importAlias, task.function); final metadataCode = _taskMetadataCode(task); buffer.writeln(' FunctionTaskHandler('); buffer.writeln(' name: ${_string(task.name)},'); @@ -1850,16 +2017,27 @@ class _RegistryEmitter { } void _emitTaskAdapters(StringBuffer buffer) { - final typedTasks = tasks.where((task) => !task.usesLegacyMapArgs).toList(); - if (typedTasks.isEmpty) { + final adaptedTasks = tasks + .where((task) => task.adapterName != null) + .toList(growable: false); + if (adaptedTasks.isEmpty) { return; } - for (final task in typedTasks) { + for (final task in adaptedTasks) { final adapterName = task.adapterName!; - final callArgs = [ - if (task.acceptsTaskContext) 'context', - ...task.valueParameters.map((param) => _decodeArg('args', param)), - ].join(', '); + final callArgs = _invocationArgs( + positional: [ + if (task.acceptsTaskContext && !task.taskContextIsNamed) 'context', + if (task.usesLegacyMapArgs) + 'args' + else + ...task.valueParameters.map((param) => _decodeArg('args', param)), + ], + named: { + if (task.acceptsTaskContext && task.taskContextIsNamed) + task.taskContextParameterName!: 'context', + }, + ); buffer.writeln( 'Future $adapterName(TaskInvocationContext context, Map args) async {', ); @@ -1872,17 +2050,6 @@ class _RegistryEmitter { } void _emitGeneratedHelpers(StringBuffer buffer) { - final needsScriptStepNoop = workflows.any( - (workflow) => - workflow.kind == WorkflowKind.script && workflow.steps.isNotEmpty, - ); - if (needsScriptStepNoop) { - buffer.writeln( - 'Future _stemScriptManifestStepNoop(FlowContext context) async => null;', - ); - buffer.writeln(); - } - final needsArgHelper = tasks.any((task) => !task.usesLegacyMapArgs) || workflows.any( diff --git a/packages/stem_builder/test/stem_registry_builder_test.dart b/packages/stem_builder/test/stem_registry_builder_test.dart index 4ec8967f..3202f098 100644 --- a/packages/stem_builder/test/stem_registry_builder_test.dart +++ b/packages/stem_builder/test/stem_registry_builder_test.dart @@ -37,6 +37,24 @@ class FlowStep { final List taskNames; final Map? metadata; } +class WorkflowCheckpoint { + WorkflowCheckpoint({ + required this.name, + this.autoVersion = false, + this.valueCodec, + this.title, + this.kind = WorkflowStepKind.task, + this.taskNames = const [], + this.metadata, + }); + final String name; + final bool autoVersion; + final PayloadCodec? valueCodec; + final String? title; + final WorkflowStepKind kind; + final List taskNames; + final Map? metadata; +} class WorkflowScriptContext { Future step( String name, @@ -107,8 +125,7 @@ class WorkflowScript { WorkflowScript({ required String name, required dynamic run, - List steps = const [], - List checkpoints = const [], + List checkpoints = const [], PayloadCodec? resultCodec, }); } @@ -573,6 +590,155 @@ class SignupWorkflow { }, ); + test( + 'supports optional named WorkflowScriptContext injection', + () async { + const input = ''' +import 'package:stem/stem.dart'; + +part 'workflows.stem.g.dart'; + +@WorkflowDefn(kind: WorkflowKind.script) +class SignupWorkflow { + Future run(String email, {WorkflowScriptContext? context}) async { + await sendWelcomeEmail(email); + } + + @WorkflowStep() + Future sendWelcomeEmail(String email) async {} +} +'''; + + await testBuilder( + stemRegistryBuilder(BuilderOptions.empty), + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + outputs: { + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( + allOf([ + contains( + ').run((' + '_stemRequireArg(script.params, "email") as String), ' + 'context: script)', + ), + ]), + ), + }, + ); + }, + ); + + test( + 'supports optional named WorkflowScriptStepContext injection', + () async { + const input = ''' +import 'package:stem/stem.dart'; + +part 'workflows.stem.g.dart'; + +@WorkflowDefn(kind: WorkflowKind.script) +class SignupWorkflow { + Future run(String email) async => sendWelcomeEmail(email); + + @WorkflowStep() + Future sendWelcomeEmail( + String email, { + WorkflowScriptStepContext? context, + }) async => email; +} +'''; + + await testBuilder( + stemRegistryBuilder(BuilderOptions.empty), + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + outputs: { + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( + allOf([ + contains( + '(context) => super.sendWelcomeEmail(email, context: context)', + ), + contains('WorkflowScriptStepContext? context'), + ]), + ), + }, + ); + }, + ); + + test('supports optional named FlowContext injection', () async { + const input = ''' +import 'package:stem/stem.dart'; + +part 'workflows.stem.g.dart'; + +@WorkflowDefn(name: 'hello.flow') +class HelloWorkflow { + @WorkflowStep(name: 'step-1') + Future stepOne({FlowContext? context}) async => 'ok'; +} +'''; + + await testBuilder( + stemRegistryBuilder(BuilderOptions.empty), + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + outputs: { + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( + contains('(ctx) => impl.stepOne(context: ctx)'), + ), + }, + ); + }); + + test('supports optional named TaskInvocationContext injection', () async { + const input = ''' +import 'package:stem/stem.dart'; + +part 'workflows.stem.g.dart'; + +@TaskDefn(name: 'typed.task') +Future typedTask( + String email, { + TaskInvocationContext? context, +}) async {} +'''; + + await testBuilder( + stemRegistryBuilder(BuilderOptions.empty), + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + outputs: { + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( + allOf([ + contains('typedTask((_stemRequireArg(args, "email") as String),'), + contains('context: context'), + ]), + ), + }, + ); + }); + test('rejects non-serializable @workflow.run parameter types', () async { const input = ''' import 'package:stem/stem.dart'; diff --git a/packages/stem_cli/lib/src/cli/workflow.dart b/packages/stem_cli/lib/src/cli/workflow.dart index 3c6d08d1..acefcfd1 100644 --- a/packages/stem_cli/lib/src/cli/workflow.dart +++ b/packages/stem_cli/lib/src/cli/workflow.dart @@ -510,39 +510,31 @@ class _WorkflowShowCommand extends Command { dependencies.err.writeln('Workflow run "$runId" not found.'); return 64; } - final steps = await workflowContext.store.listSteps(runId); + final detail = await workflowContext.runtime.viewRunDetail(runId); if (jsonOutput) { dependencies.out.writeln( - jsonEncode({ - 'run': { - 'id': state.id, - 'workflow': state.workflow, - 'status': state.status.name, - 'cursor': state.cursor, - 'params': state.params, - 'result': state.result, - 'waitTopic': state.waitTopic, - 'resumeAt': state.resumeAt?.toIso8601String(), - 'lastError': state.lastError, - 'createdAt': state.createdAt.toIso8601String(), - 'updatedAt': state.updatedAt?.toIso8601String(), - 'cancellationPolicy': state.cancellationPolicy?.toJson(), - 'cancellationData': state.cancellationData, - }, - 'steps': steps - .map( - (step) => { - 'name': step.name, - 'value': step.value, - 'position': step.position, - 'completedAt': step.completedAt?.toIso8601String(), + jsonEncode( + detail?.toJson() ?? + { + 'run': { + 'runId': state.id, + 'workflow': state.workflow, + 'status': state.status.name, + 'cursor': state.cursor, + 'params': state.params, + 'result': state.result, + 'lastError': state.lastError, + 'createdAt': state.createdAt.toIso8601String(), + 'updatedAt': state.updatedAt?.toIso8601String(), + 'runtime': state.runtimeMetadata.toJson(), + 'suspensionData': state.suspensionData, }, - ) - .toList(), - }), + 'checkpoints': const [], + }, + ), ); } else { - _renderRunDetails(state, steps); + _renderRunDetails(state, detail?.checkpoints ?? const []); } return 0; } catch (error, stackTrace) { @@ -557,7 +549,10 @@ class _WorkflowShowCommand extends Command { } } - void _renderRunDetails(RunState state, List steps) { + void _renderRunDetails( + RunState state, + List checkpoints, + ) { final out = dependencies.out; out ..writeln('Run: ${state.id}') @@ -582,13 +577,14 @@ class _WorkflowShowCommand extends Command { if (state.cancellationData != null && state.cancellationData!.isNotEmpty) { out.writeln('Cancellation Data: ${jsonEncode(state.cancellationData)}'); } - if (steps.isEmpty) { + if (checkpoints.isEmpty) { out.writeln('No checkpoints recorded.'); } else { out.writeln('Checkpoints:'); - for (final step in steps) { + for (final checkpoint in checkpoints) { out.writeln( - ' [${step.position}] ${step.name}: ${jsonEncode(step.value)}', + ' [${checkpoint.position}] ${checkpoint.checkpointName}: ' + '${jsonEncode(checkpoint.value)}', ); } } diff --git a/packages/stem_cli/lib/src/cli/workflow_agent_help.dart b/packages/stem_cli/lib/src/cli/workflow_agent_help.dart index 6bfc8396..55687704 100644 --- a/packages/stem_cli/lib/src/cli/workflow_agent_help.dart +++ b/packages/stem_cli/lib/src/cli/workflow_agent_help.dart @@ -6,8 +6,8 @@ String buildWorkflowAgentHelpMarkdown(Iterable> commands) { ..writeln() ..writeln('## Summary') ..writeln( - '- Workflow steps are durable and may replay after sleeps, awaited ' - 'events, or worker restarts.', + '- Flow steps and script checkpoints are durable and may replay after ' + 'sleeps, awaited events, or worker restarts.', ) ..writeln( '- Use FlowContext.idempotencyKey and stored checkpoints to guard side ' diff --git a/packages/stem_cli/test/unit/cli/cli_workflow_test.dart b/packages/stem_cli/test/unit/cli/cli_workflow_test.dart index afee1eb0..6e9bc055 100644 --- a/packages/stem_cli/test/unit/cli/cli_workflow_test.dart +++ b/packages/stem_cli/test/unit/cli/cli_workflow_test.dart @@ -98,7 +98,7 @@ void main() { ); }); - test('show --json displays run details and steps', () async { + test('show --json displays run details and checkpoints', () async { await runStemCli( ['wf', 'start', 'demo.workflow'], contextBuilder: _buildCliContext, @@ -118,8 +118,8 @@ void main() { expect(code, equals(0), reason: err.toString()); final payload = jsonDecode(out.toString()) as Map; - expect(payload['run']['id'], run.id); - expect(payload['steps'], isList); + expect(payload['run']['runId'], run.id); + expect(payload['checkpoints'], isList); }); test('start accepts cancellation policy flags', () async { From 3bfbcf5f9dcf540649dfb3f88ea6ce02c8e5c258 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 16:33:51 -0500 Subject: [PATCH 008/302] Expand client module bootstrap coverage --- .../stem/test/bootstrap/stem_client_test.dart | 156 +++++++++++++++++- 1 file changed, 153 insertions(+), 3 deletions(-) diff --git a/packages/stem/test/bootstrap/stem_client_test.dart b/packages/stem/test/bootstrap/stem_client_test.dart index 6d17758d..93dfe861 100644 --- a/packages/stem/test/bootstrap/stem_client_test.dart +++ b/packages/stem/test/bootstrap/stem_client_test.dart @@ -60,6 +60,154 @@ void main() { await client.close(); }); + test( + 'StemClient remembers its default module for createApp', + () async { + final moduleTask = FunctionTaskHandler( + name: 'client.default-module.app-task', + options: const TaskOptions(queue: 'priority'), + entrypoint: (context, args) async => 'task-ok', + runInIsolate: false, + ); + final client = await StemClient.inMemory( + module: StemModule(tasks: [moduleTask]), + ); + + final app = await client.createApp(); + await app.start(); + + expect( + app.registry.resolve('client.default-module.app-task'), + same(moduleTask), + ); + expect(app.worker.subscription.queues, ['priority']); + + final taskId = await app.stem.enqueue( + 'client.default-module.app-task', + enqueueOptions: const TaskEnqueueOptions(queue: 'priority'), + ); + final result = await app.stem.waitForTask( + taskId, + timeout: const Duration(seconds: 2), + ); + + expect(result?.value, 'task-ok'); + + await app.close(); + await client.close(); + }, + ); + + test( + 'StemClient createApp registers module tasks and infers queues', + () async { + final client = await StemClient.inMemory(); + final moduleTask = FunctionTaskHandler( + name: 'client.module.app-task', + options: const TaskOptions(queue: 'priority'), + entrypoint: (context, args) async => 'task-ok', + runInIsolate: false, + ); + + final app = await client.createApp( + module: StemModule(tasks: [moduleTask]), + ); + await app.start(); + + expect(app.registry.resolve('client.module.app-task'), same(moduleTask)); + expect(app.worker.subscription.queues, ['priority']); + + final taskId = await app.stem.enqueue( + 'client.module.app-task', + enqueueOptions: const TaskEnqueueOptions(queue: 'priority'), + ); + final result = await app.stem.waitForTask( + taskId, + timeout: const Duration(seconds: 2), + ); + + expect(result?.value, 'task-ok'); + + await app.close(); + await client.close(); + }, + ); + + test( + 'StemClient createWorkflowApp infers module task queue subscriptions', + () async { + final client = await StemClient.inMemory(); + final moduleTask = FunctionTaskHandler( + name: 'client.module.queued-task', + entrypoint: (context, args) async => 'task-ok', + runInIsolate: false, + ); + final app = await client.createWorkflowApp( + module: StemModule(tasks: [moduleTask]), + ); + + expect( + app.app.worker.subscription.queues, + unorderedEquals(['workflow', 'default']), + ); + + await app.start(); + final taskId = await app.app.stem.enqueue('client.module.queued-task'); + final result = await app.app.stem.waitForTask( + taskId, + timeout: const Duration(seconds: 2), + ); + + expect(result?.value, 'task-ok'); + + await app.close(); + await client.close(); + }, + ); + + test( + 'StemClient remembers its default module for createWorkflowApp', + () async { + final moduleTask = FunctionTaskHandler( + name: 'client.default-module.workflow-task', + entrypoint: (context, args) async => 'task-ok', + runInIsolate: false, + ); + final moduleFlow = Flow( + name: 'client.default-module.workflow', + build: (builder) { + builder.step('hello', (ctx) async => 'module-ok'); + }, + ); + final client = await StemClient.inMemory( + module: StemModule(flows: [moduleFlow], tasks: [moduleTask]), + ); + + final app = await client.createWorkflowApp(); + await app.start(); + + expect( + app.app.registry.resolve('client.default-module.workflow-task'), + same(moduleTask), + ); + expect( + app.app.worker.subscription.queues, + unorderedEquals(['workflow', 'default']), + ); + + final runId = await app.startWorkflow('client.default-module.workflow'); + final result = await app.waitForCompletion( + runId, + timeout: const Duration(seconds: 2), + ); + + expect(result?.value, 'module-ok'); + + await app.close(); + await client.close(); + }, + ); + test('StemClient workflow app supports typed workflow refs', () async { final client = await StemClient.inMemory(); final flow = Flow( @@ -113,9 +261,11 @@ void main() { final app = await client.createWorkflowApp(flows: [flow]); await app.start(); - final result = await workflowRef.call( - const {'name': 'one-shot'}, - ).startAndWaitWithApp(app, timeout: const Duration(seconds: 2)); + final result = await workflowRef + .call( + const {'name': 'one-shot'}, + ) + .startAndWaitWithApp(app, timeout: const Duration(seconds: 2)); expect(result?.value, 'ok:one-shot'); From 294c6d2263ac2cdcb3c47b302483dfb99ce3c419 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 16:34:25 -0500 Subject: [PATCH 009/302] Refresh workflow docs and examples --- .site/docs/core-concepts/cli-control.md | 2 +- .site/docs/core-concepts/stem-builder.md | 50 ++++-- .site/docs/workflows/annotated-workflows.md | 61 ++++--- .../workflows/context-and-serialization.md | 35 +++- .../errors-retries-and-idempotency.md | 2 + .../docs/workflows/suspensions-and-events.md | 23 ++- packages/stem/README.md | 149 ++++++++++++------ .../example/annotated_workflows/README.md | 36 +++-- .../example/docs_snippets/lib/workflows.dart | 37 +++-- packages/stem/example/ecommerce/README.md | 6 + .../stem/example/ecommerce/lib/src/app.dart | 3 - packages/stem_builder/README.md | 74 ++++++--- packages/stem_builder/example/README.md | 2 +- 13 files changed, 353 insertions(+), 127 deletions(-) diff --git a/.site/docs/core-concepts/cli-control.md b/.site/docs/core-concepts/cli-control.md index e1fb99bb..4084d11b 100644 --- a/.site/docs/core-concepts/cli-control.md +++ b/.site/docs/core-concepts/cli-control.md @@ -119,7 +119,7 @@ ensure the CLI and workers share the same task-definition entrypoint so task names, encoders, and routing rules stay consistent. A common pattern is to build that CLI registry from the same shared task list -or generated `stemModule.tasks` your app uses, so task metadata stays consistent +or generated `stemModule` your app uses, so task metadata stays consistent without teaching registry-first bootstrap for normal services. If a command needs a registry and none is available, it will exit with an error diff --git a/.site/docs/core-concepts/stem-builder.md b/.site/docs/core-concepts/stem-builder.md index 069fdb23..6113326e 100644 --- a/.site/docs/core-concepts/stem-builder.md +++ b/.site/docs/core-concepts/stem-builder.md @@ -53,7 +53,12 @@ class UserSignupWorkflow { } @TaskDefn(name: 'commerce.audit.log', runInIsolate: false) -Future logAudit(TaskInvocationContext ctx, String event, String id) async { +Future logAudit( + String event, + String id, { + TaskInvocationContext? context, +}) async { + final ctx = context!; ctx.progress(1.0, data: {'event': event, 'id': id}); } ``` @@ -86,6 +91,11 @@ final result = await StemWorkflowDefinitions.userSignup .startAndWaitWithApp(workflowApp); ``` +When you pass `module: stemModule`, the workflow app infers the worker +subscription from the workflow queue plus the default queues declared on the +bundled task handlers. Explicit subscriptions are still available for advanced +routing. + If you already manage a `StemApp` for a larger service, reuse it instead of bootstrapping a second app: @@ -93,7 +103,7 @@ bootstrapping a second app: final stemApp = await StemApp.fromUrl( 'redis://localhost:6379', adapters: const [StemRedisAdapter()], - tasks: stemModule.tasks, + module: stemModule, ); final workflowApp = await StemWorkflowApp.create( @@ -102,6 +112,19 @@ final workflowApp = await StemWorkflowApp.create( ); ``` +For task-only services, the same bundle works directly with `StemApp`: + +```dart +final taskApp = await StemApp.fromUrl( + 'redis://localhost:6379', + adapters: const [StemRedisAdapter()], + module: stemModule, +); +``` + +Plain `StemApp` bootstrap infers task queue subscriptions from the bundled +task handlers when `workerConfig.subscription` is omitted. + If you already centralize broker/backend wiring in a `StemClient`, prefer the shared-client path: @@ -109,23 +132,29 @@ shared-client path: final client = await StemClient.fromUrl( 'redis://localhost:6379', adapters: const [StemRedisAdapter()], + module: stemModule, ); -final workflowApp = await client.createWorkflowApp(module: stemModule); +final workflowApp = await client.createWorkflowApp(); ``` +If you reuse an existing `StemApp`, its worker subscription remains your +responsibility. The module-based queue inference only applies when the +workflow app is creating the worker itself. + ## Parameter and Signature Rules -- Parameters after context must be required positional serializable values. -- Parameters after context must be required positional values that are either +- Business parameters must be required positional values that are either serializable or codec-backed DTOs. - Script workflow `run(...)` can be plain (no annotation required). -- `@WorkflowRun` is still supported for explicit run entrypoints. -- Step methods use `@WorkflowStep`. -- Plain `run(...)` is best when called step methods only need serializable +- Checkpoint methods use `@WorkflowStep`. +- Plain `run(...)` is best when called checkpoint methods only need + serializable parameters. -- Use `@WorkflowRun()` plus `WorkflowScriptContext` when you need to enter a - context-aware script checkpoint that consumes `WorkflowScriptStepContext`. +- When you need runtime metadata or an explicit `script.step(...)`, add an + optional named injected context parameter: + - `WorkflowScriptContext? context` on `run(...)` + - `WorkflowScriptStepContext? context` on the checkpoint method - DTO classes are supported when they provide: - `Map toJson()` - `factory Type.fromJson(Map json)` or an equivalent named @@ -134,3 +163,4 @@ final workflowApp = await client.createWorkflowApp(module: stemModule); - Workflow inputs, checkpoint values, and final workflow results can use the same DTO convention. The generated `PayloadCodec` persists the JSON form while workflow code continues to work with typed objects. +- Runtime detail surfaces flow `steps` and script `checkpoints` separately. diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index 64d6b77c..7c38ec5d 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -17,6 +17,10 @@ generated file exposes: - typed enqueue helpers like `enqueueSendEmailTyped(...)` - typed result wait helpers like `waitForSendEmailTyped(...)` +The generated task definitions are producer-safe: `Stem.enqueueCall(...)` can +publish from the definition metadata, so producer processes do not need to +register the worker handler locally just to enqueue typed task calls. + Wire the bundle directly into `StemWorkflowApp`: ```dart @@ -26,6 +30,19 @@ final workflowApp = await StemWorkflowApp.fromUrl( ); ``` +With `module: stemModule`, the workflow app infers the worker subscription +from the workflow queue plus the default queues declared on the bundled task +handlers. Set `workerConfig.subscription` explicitly only when you need extra +queues beyond those defaults. + +If you centralize broker/backend wiring in a `StemClient`, give the client the +bundle once and then create workflow apps without repeating it: + +```dart +final client = await StemClient.fromUrl('memory://', module: stemModule); +final workflowApp = await client.createWorkflowApp(); +``` + Use the generated workflow refs when you want a single typed handle for start and wait operations: @@ -35,9 +52,7 @@ final result = await StemWorkflowDefinitions.userSignup .startAndWaitWithApp(workflowApp); ``` -## Two script entry styles - -### Direct-call style +## Script context injection Use a plain `run(...)` when your annotated checkpoints only need serializable values or codec-backed DTO parameters: @@ -64,30 +79,29 @@ class UserSignupWorkflow { The generator rewrites those calls into durable checkpoint boundaries in the generated proxy class. -### Context-aware style - -Use `@WorkflowRun()` when you need to enter through `WorkflowScriptContext` so -the checkpoint body can receive `WorkflowScriptStepContext`: +When you need runtime metadata or an explicit `script.step(...)`, add an +optional named injected context parameter: ```dart @WorkflowDefn(name: 'annotated.context_script', kind: WorkflowKind.script) class AnnotatedContextScriptWorkflow { - @WorkflowRun() Future> run( - WorkflowScriptContext script, String email, + {WorkflowScriptContext? context} ) async { + final script = context!; return script.step>( 'enter-context-step', - (ctx) => captureContext(ctx, email), + (ctx) => captureContext(email, context: ctx), ); } @WorkflowStep(name: 'capture-context') Future> captureContext( - WorkflowScriptStepContext ctx, String email, + {WorkflowScriptStepContext? context} ) async { + final ctx = context!; return { 'workflow': ctx.workflow, 'runId': ctx.runId, @@ -98,11 +112,18 @@ class AnnotatedContextScriptWorkflow { } ``` -Context-aware checkpoint methods are not meant to be called directly from a -plain `run(String ...)` signature. If a called step needs -`WorkflowScriptStepContext`, enter it through `@WorkflowRun()` plus -`WorkflowScriptContext`; plain direct-call style is for steps that consume only -serializable business parameters. +This keeps one authoring model: + +- plain direct method calls are still the default +- context is added only when you need it +- the injected context is not part of the durable payload shape + +When a workflow needs to start another workflow, do it from a durable boundary: + +- `FlowContext.workflows` inside flow steps +- `WorkflowScriptStepContext.workflows` inside checkpoint methods + +Avoid starting child workflows from the raw `WorkflowScriptContext` body. ## Runnable example @@ -114,14 +135,18 @@ example that demonstrates: - nested annotated checkpoint calls - `WorkflowScriptContext` - `WorkflowScriptStepContext` +- optional named context injection - `TaskInvocationContext` - codec-backed DTO workflow checkpoints and final workflow results - typed task DTO input and result decoding +When you inspect run detail, the runtime now exposes `checkpoints` for script +workflows rather than reusing the flow-step view model. + ## DTO rules -Generated workflow/task entrypoints support required positional parameters that -are either: +Generated workflow/task entrypoints support required positional business +parameters that are either: - serializable values (`String`, numbers, bools, `List`, `Map`) - codec-backed DTO classes that provide: diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index ee508c7d..b13ad94f 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -15,6 +15,14 @@ Everything else that crosses a durable boundary must be serializable. Those context objects are not part of the persisted payload shape. They are injected by the runtime when the handler executes. +For annotated workflows/tasks, the preferred shape is an optional named context +parameter: + +- `Future run(String email, {WorkflowScriptContext? context})` +- `Future checkpoint(String email, {WorkflowScriptStepContext? context})` +- `Future step({FlowContext? context})` +- `Future task(String id, {TaskInvocationContext? context})` + ## What context gives you Depending on the context type, you can access: @@ -25,11 +33,23 @@ Depending on the context type, you can access: - `stepIndex` - `iteration` - workflow params and previous results +- `sleepUntilResumed(...)` for common sleep/retry loops +- `waitForEventValue(...)` for common event waits - `takeResumeData()` for event-driven resumes - `takeResumeValue(codec: ...)` for typed event-driven resumes - `idempotencyKey(...)` +- `workflows` for typed child-workflow starts from durable step/checkpoint + contexts - task metadata like `id`, `attempt`, `meta` +Child workflow starts belong in durable boundaries: + +- `FlowContext.workflows` inside flow steps +- `WorkflowScriptStepContext.workflows` inside script checkpoints + +Do not treat the raw `WorkflowScriptContext` body as a safe place for child +starts or other replay-sensitive side effects. + ## Serializable parameter rules Supported shapes: @@ -83,9 +103,18 @@ map-based today. ## Practical rule -When you need context metadata, add the appropriate context parameter first. -When you need business input, make it a required positional serializable value -after the context parameter. +When you need context metadata, add the appropriate optional named context +parameter. When you need business input, make it a required positional +serializable value. + +Prefer the higher-level helpers first: + +- `sleepUntilResumed(...)` when the step/checkpoint should pause once and + continue on resume +- `waitForEventValue(...)` when the step/checkpoint is waiting on one event + +Drop down to `takeResumeData()` / `takeResumeValue(...)` only when you need +custom branching around resume payloads. The runnable `annotated_workflows` example demonstrates both the context-aware and plain serializable forms. diff --git a/.site/docs/workflows/errors-retries-and-idempotency.md b/.site/docs/workflows/errors-retries-and-idempotency.md index 27bcde75..199731c1 100644 --- a/.site/docs/workflows/errors-retries-and-idempotency.md +++ b/.site/docs/workflows/errors-retries-and-idempotency.md @@ -12,6 +12,8 @@ the runtime after resume, and the step body must tolerate replay. Use: +- `sleepUntilResumed(...)` for simple sleep/replay loops +- `waitForEventValue(...)` for one-event suspension points - `takeResumeData()` to branch on fresh resume payloads - `idempotencyKey(...)` when a step talks to an external side-effecting system - persisted previous results instead of in-memory state diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index f3cd9e70..5046d114 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -12,6 +12,15 @@ different worker. periodically scans due runs and re-enqueues the internal workflow task when the sleep expires. +For the common "sleep once, continue on resume" case, prefer the higher-level +helper: + +```dart +if (!ctx.sleepUntilResumed(const Duration(milliseconds: 200))) { + return null; +} +``` + ## Await external events `awaitEvent(topic, deadline: ...)` records a durable watcher. External code can @@ -25,7 +34,19 @@ Typical flow: `WorkflowRuntime.emitValue(...)` (or an app/service wrapper around it) with a payload 4. the runtime resumes the run and exposes the payload through - `takeResumeData()` or `takeResumeValue(codec: ...)` + `waitForEventValue(...)` or the lower-level + `takeResumeData()` / `takeResumeValue(codec: ...)` + +For the common "wait for one event and continue" case, prefer: + +```dart +final payload = ctx.waitForEventValue>( + 'orders.payment.confirmed', +); +if (payload == null) { + return null; +} +``` ## Emit resume events diff --git a/packages/stem/README.md b/packages/stem/README.md index a8d0fbef..b64114f3 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -203,6 +203,21 @@ Future main() async { } ``` +`Stem.enqueueCall(...)` can publish from the `TaskDefinition` metadata alone, so +producer-only processes do not need to register the worker handler locally just +to enqueue typed calls. + +For typed task calls, the definition and call objects now expose the common +producer operations directly: + +```dart +final taskId = await HelloTask.definition + .call(const HelloArgs(name: 'Stem')) + .enqueueWith(stem); + +final result = await HelloTask.definition.waitFor(stem, taskId); +``` + You can also build requests fluently with the `TaskEnqueueBuilder`: ```dart @@ -291,9 +306,7 @@ final app = await StemWorkflowApp.inMemory( }); await script.step('poll-shipment', (step) async { - final resume = step.takeResumeValue(); - if (resume != true) { - await step.sleep(const Duration(seconds: 30)); + if (!step.sleepUntilResumed(const Duration(seconds: 30))) { return 'waiting'; } final status = await fetchShipment(checkout.id); @@ -316,14 +329,17 @@ final app = await StemWorkflowApp.inMemory( ); ``` -Inside a script step you can access the same metadata as `FlowContext`: +Inside a script checkpoint you can access the same metadata as `FlowContext`: - `step.previousResult` contains the prior step’s persisted value. - `step.iteration` tracks the current auto-version suffix when `autoVersion: true` is set. - `step.idempotencyKey('scope')` builds stable outbound identifiers. +- `step.sleepUntilResumed(...)` handles the common sleep-once, continue-on- + resume path. +- `step.waitForEventValue(...)` handles the common wait-for-one-event path. - `step.takeResumeData()` and `step.takeResumeValue(codec: ...)` surface - payloads from sleeps or awaited events so you can branch on resume paths. + payloads from sleeps or awaited events when you need lower-level control. ### Current workflow model @@ -357,9 +373,10 @@ final approvalsFlow = Flow( }); flow.step('manager-review', (ctx) async { - final resume = ctx.takeResumeValue>(); + final resume = ctx.waitForEventValue>( + 'approvals.manager', + ); if (resume == null) { - await ctx.awaitEvent('approvals.manager'); return null; } return resume['approvedBy'] as String?; @@ -400,9 +417,10 @@ final billingRetryScript = WorkflowScript( name: 'billing.retry-script', run: (script) async { final chargeId = await script.step('charge', (ctx) async { - final resume = ctx.takeResumeValue>(); + final resume = ctx.waitForEventValue>( + 'billing.charge.prepared', + ); if (resume == null) { - await ctx.awaitEvent('billing.charge.prepared'); return 'pending'; } return resume['chargeId'] as String; @@ -464,29 +482,37 @@ class BuilderUserSignupWorkflow { @TaskDefn(name: 'builder.example.task') Future builderExampleTask( - TaskInvocationContext context, Map args, + {TaskInvocationContext? context} ) async {} ``` -There are two supported script entry styles: +Script workflows use one authoring model: -- plain direct-call style: - - `Future run(String email, ...)` - - best when your annotated step methods only take serializable parameters -- context-aware style: - - `@WorkflowRun()` - - `Future run(WorkflowScriptContext script, String email, ...)` - - use this when you need to enter a step explicitly with `script.step(...)` - so the step body can receive `WorkflowScriptStepContext` +- start with a plain `run(String email, ...)` method +- add an optional named injected context when you need runtime metadata or an + explicit `script.step(...)` wrapper: + - `Future run(String email, {WorkflowScriptContext? context})` + - `Future capture(String email, {WorkflowScriptStepContext? context})` +- direct checkpoint method calls still stay the default happy path Context injection works at every runtime layer: - flow steps can take `FlowContext` - script runs can take `WorkflowScriptContext` -- script steps can take `WorkflowScriptStepContext` +- script checkpoints can take `WorkflowScriptStepContext` - tasks can take `TaskInvocationContext` +Child workflows belong in durable execution boundaries: + +- use `FlowContext.workflows` inside flow steps +- use `WorkflowScriptStepContext.workflows` inside script checkpoints +- do not start child workflows from the raw `WorkflowScriptContext` body unless + you are deliberately managing replay/idempotency yourself + +For annotated workflows/tasks, the preferred shape is an optional named context +parameter. The runtime injects it, and it is not part of the durable payload. + Serializable parameter rules for generated workflows and tasks are strict: - supported: @@ -498,7 +524,7 @@ Serializable parameter rules for generated workflows and tasks are strict: - `factory Type.fromJson(Map json)` or an equivalent named `fromJson` constructor - not supported directly: - - optional/named parameters on generated workflow/task entrypoints + - optional/named business parameters on generated workflow/task entrypoints Typed task results can use the same DTO convention. @@ -508,10 +534,11 @@ workflow code continues to work with typed objects. See the runnable example: -- [example/annotated_workflows](example/annotated_workflows) + - [example/annotated_workflows](example/annotated_workflows) - `FlowContext` metadata - - plain proxy-driven script step calls + - plain proxy-driven script checkpoint calls - `WorkflowScriptContext` + `WorkflowScriptStepContext` + - optional named context injection - codec-backed workflow checkpoint values and workflow results - typed `@TaskDefn` decoding scalar, `Map`, and `List` parameters @@ -536,6 +563,11 @@ print(result?.value); await app.close(); ``` +When you use `module: stemModule`, `StemWorkflowApp` infers the worker +subscription from the workflow queue plus the default queues declared on the +bundled task handlers. You only need to set `workerConfig.subscription` +explicitly when your routing goes beyond those defaults. + Generated output gives you: - `stemModule` @@ -544,19 +576,36 @@ Generated output gives you: - typed enqueue helpers on `TaskEnqueuer` - typed result wait helpers on `Stem` -If your service already owns a `StemApp`, reuse it: +The same bundle also works for plain task apps: ```dart -final client = await StemClient.fromUrl( +final taskApp = await StemApp.fromUrl( 'redis://localhost:6379', adapters: const [StemRedisAdapter()], + module: stemModule, ); +``` + +When you bootstrap a plain `StemApp` with `module: stemModule`, the worker +infers task queue subscriptions from the bundled task handlers. Set +`workerConfig.subscription` explicitly only when you need broader routing. + +If your service already owns a `StemApp`, reuse it: -final workflowApp = await client.createWorkflowApp( +```dart +final client = await StemClient.fromUrl( + 'redis://localhost:6379', + adapters: const [StemRedisAdapter()], module: stemModule, ); + +final workflowApp = await client.createWorkflowApp(); ``` +If you reuse an existing `StemApp`, its worker subscription stays authoritative. +The module-based queue inference only applies when `StemWorkflowApp` is also +creating the worker. + #### Mixing workflows and normal tasks A workflow can orchestrate durable steps and still enqueue ordinary Stem tasks @@ -622,20 +671,19 @@ lowering. ### Typed task completion -Producers can now wait for individual task results using `Stem.waitForTask` -with optional decoders. The helper returns a `TaskResult` containing the -underlying `TaskStatus`, decoded payload, and a timeout flag: +Producers can now wait for individual task results using either +`TaskDefinition.waitFor(...)` or `Stem.waitForTask` with optional decoders. +These helpers return a `TaskResult` containing the underlying `TaskStatus`, +decoded payload, and a timeout flag: ```dart -final taskId = await stem.enqueueCall( - ChargeCustomer.definition.call(ChargeArgs(orderId: '123')), -); +final taskId = await ChargeCustomer.definition + .call(ChargeArgs(orderId: '123')) + .enqueueWith(stem); -final charge = await stem.waitForTask( +final charge = await ChargeCustomer.definition.waitFor( + stem, taskId, - decode: (payload) => ChargeReceipt.fromJson( - payload! as Map, - ), ); if (charge?.isSucceeded == true) { print('Captured ${charge!.value!.total}'); @@ -828,16 +876,29 @@ backend metadata under `stem.unique.duplicates`. - Sleeps persist wake timestamps. When a resumed step calls `sleep` again, the runtime skips re-suspending once the stored `resumeAt` is reached so loop handlers can simply call `sleep` without extra guards. -- Use `ctx.takeResumeData()` or `ctx.takeResumeValue(codec: ...)` to detect - whether a step is resuming. Call it at the start of the handler and branch - accordingly. -- When you suspend, provide a marker in the `data` payload so the resumed step - can distinguish the wake-up path. For example: +- Prefer the higher-level helpers for common cases: + + ```dart + if (!ctx.sleepUntilResumed(const Duration(milliseconds: 200))) { + return null; + } + ``` + + ```dart + final payload = ctx.waitForEventValue>('demo.event'); + if (payload == null) { + return null; + } + ``` + +- Use `ctx.takeResumeData()` or `ctx.takeResumeValue(codec: ...)` when you + need lower-level control over the resume payload or need to distinguish + custom suspension markers yourself. +- When you suspend with the low-level API, provide a marker in the `data` + payload so the resumed step can distinguish the wake-up path. For example: ```dart - final resume = ctx.takeResumeValue(); - if (resume != true) { - ctx.sleep(const Duration(milliseconds: 200)); + if (!ctx.sleepUntilResumed(const Duration(milliseconds: 200))) { return null; } ``` diff --git a/packages/stem/example/annotated_workflows/README.md b/packages/stem/example/annotated_workflows/README.md index 615f0fc4..47361627 100644 --- a/packages/stem/example/annotated_workflows/README.md +++ b/packages/stem/example/annotated_workflows/README.md @@ -5,25 +5,29 @@ with the `stem_builder` bundle generator. It now demonstrates the generated script-proxy behavior explicitly: - a flow step using `FlowContext` -- `run(WelcomeRequest request)` calls annotated step methods directly -- `prepareWelcome(...)` calls other annotated steps -- `deliverWelcome(...)` calls another annotated step from inside an annotated - step -- a second script workflow uses `@WorkflowRun()` plus `WorkflowScriptStepContext` - to expose `runId`, `workflow`, `stepName`, `stepIndex`, and idempotency keys +- a flow step starting a child workflow through `FlowContext.workflows` +- `run(WelcomeRequest request)` calls annotated checkpoint methods directly +- `prepareWelcome(...)` calls other annotated checkpoints +- `deliverWelcome(...)` calls another annotated checkpoint from inside an + checkpoint +- a second script workflow uses optional named context injection + (`WorkflowScriptContext? context` / `WorkflowScriptStepContext? context`) to + expose `runId`, `workflow`, `stepName`, `stepIndex`, and idempotency keys +- a script checkpoint starting a child workflow through + `WorkflowScriptStepContext.workflows` - a plain script workflow that returns a codec-backed DTO result and persists a codec-backed DTO checkpoint value -- a typed `@TaskDefn` using `TaskInvocationContext` plus codec-backed DTO - input/output types +- a typed `@TaskDefn` using optional named `TaskInvocationContext? context` + plus codec-backed DTO input/output types When you run the example, it prints: - the flow result with `FlowContext` metadata - the plain script result -- the persisted step order for the plain script workflow +- the persisted checkpoint order for the plain script workflow - the persisted JSON form of the plain script DTO checkpoint and DTO result - the context-aware script result with workflow metadata - the persisted JSON form of the context-aware DTO result -- the persisted step order for the context-aware workflow +- the persisted checkpoint order for the context-aware workflow - the typed task result showing a decoded DTO result and task invocation metadata @@ -34,11 +38,19 @@ The generated file exposes: - typed workflow refs for `StemWorkflowApp` and `WorkflowRuntime` - typed task definitions, enqueue helpers, and typed result wait helpers +When you pass `module: stemModule` into `StemWorkflowApp`, or create a +`StemClient` with `module: stemModule` and then call +`StemClient.createWorkflowApp()`, the worker automatically subscribes to the +workflow queue plus the default queues declared on the bundled task handlers. +This example no longer needs manual `'workflow'` / `'default'` subscription +wiring. + ## Serializable parameter rules For `stem_builder`, generated workflow/task entrypoints support required -positional parameters that are either serializable values or codec-backed DTO -types: +positional business parameters that are either serializable values or +codec-backed DTO types. Runtime context can be added separately through an +optional named injected context parameter. - `String`, `bool`, `int`, `double`, `num`, `Object?`, `null` - `List` where `T` is serializable diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index 224d68a5..e1af8f3e 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -49,9 +49,10 @@ class ApprovalsFlow { }); flow.step('manager-review', (ctx) async { - final resume = ctx.takeResumeValue>(); + final resume = ctx.waitForEventValue>( + 'approvals.manager', + ); if (resume == null) { - await ctx.awaitEvent('approvals.manager'); return null; } return resume['approvedBy'] as String?; @@ -75,9 +76,10 @@ final retryScript = WorkflowScript( name: 'billing.retry-script', run: (script) async { final chargeId = await script.step('charge', (ctx) async { - final resume = ctx.takeResumeValue>(); + final resume = ctx.waitForEventValue>( + 'billing.charge.prepared', + ); if (resume == null) { - await ctx.awaitEvent('billing.charge.prepared'); return 'pending'; } return resume['chargeId'] as String; @@ -143,23 +145,27 @@ Future configureWorkflowEncoders() async { @WorkflowDefn(name: 'approvals.flow') class ApprovalsAnnotatedWorkflow { @WorkflowStep() - Future draft(FlowContext ctx) async { + Future draft({FlowContext? context}) async { + final ctx = context!; final payload = ctx.params['draft'] as Map; return payload['documentId'] as String; } @WorkflowStep(name: 'manager-review') - Future managerReview(FlowContext ctx) async { - final resume = ctx.takeResumeValue>(); + Future managerReview({FlowContext? context}) async { + final ctx = context!; + final resume = ctx.waitForEventValue>( + 'approvals.manager', + ); if (resume == null) { - await ctx.awaitEvent('approvals.manager'); return null; } return resume['approvedBy'] as String?; } @WorkflowStep() - Future finalize(FlowContext ctx) async { + Future finalize({FlowContext? context}) async { + final ctx = context!; final approvedBy = ctx.previousResult as String?; return 'approved-by:$approvedBy'; } @@ -167,12 +173,13 @@ class ApprovalsAnnotatedWorkflow { @WorkflowDefn(name: 'billing.retry-script', kind: WorkflowKind.script) class BillingRetryAnnotatedWorkflow { - @WorkflowRun() - Future run(WorkflowScriptContext script) async { + Future run({WorkflowScriptContext? context}) async { + final script = context!; final chargeId = await script.step('charge', (ctx) async { - final resume = ctx.takeResumeValue>(); + final resume = ctx.waitForEventValue>( + 'billing.charge.prepared', + ); if (resume == null) { - await ctx.awaitEvent('billing.charge.prepared'); return 'pending'; } return resume['chargeId'] as String; @@ -190,9 +197,11 @@ class BillingRetryAnnotatedWorkflow { options: TaskOptions(maxRetries: 5), ) Future sendEmail( - TaskInvocationContext ctx, Map args, + {TaskInvocationContext? context} ) async { + final ctx = context!; + ctx.heartbeat(); // send email } diff --git a/packages/stem/example/ecommerce/README.md b/packages/stem/example/ecommerce/README.md index 17432061..b56d3d37 100644 --- a/packages/stem/example/ecommerce/README.md +++ b/packages/stem/example/ecommerce/README.md @@ -50,6 +50,12 @@ final workflowApp = await StemWorkflowApp.fromUrl( ); ``` +That bootstrap path auto-subscribes the worker to the workflow queue plus the +default queues declared on the bundled module tasks and +`shipmentReserveTaskHandler`. +You only need an explicit `workerConfig.subscription` if you route work to +additional queues beyond those task defaults. + This is why the run command always includes: ```bash diff --git a/packages/stem/example/ecommerce/lib/src/app.dart b/packages/stem/example/ecommerce/lib/src/app.dart index 4343953a..ef06111b 100644 --- a/packages/stem/example/ecommerce/lib/src/app.dart +++ b/packages/stem/example/ecommerce/lib/src/app.dart @@ -43,9 +43,6 @@ class EcommerceServer { queue: 'workflow', consumerName: 'ecommerce-worker', concurrency: 2, - subscription: RoutingSubscription( - queues: const ['workflow', 'default'], - ), ), ); diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index c4a1797a..70675def 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -54,19 +54,19 @@ class HelloScript { @TaskDefn(name: 'hello.task') Future helloTask( - TaskInvocationContext context, String email, + {TaskInvocationContext? context} ) async { // ... } ``` -Script workflows can use a plain `run(...)` method (no extra annotation -required). `@WorkflowRun` is still supported for backward compatibility. -`run(...)` may optionally take `WorkflowScriptContext` as its first parameter, -followed by required positional serializable parameters. +Script workflows can use a plain `run(...)` method with no extra annotation. +When you need runtime metadata or an explicit `script.step(...)`, add an +optional named `WorkflowScriptContext? context` parameter. -The intended usage is to call annotated step methods directly from `run(...)`: +The intended usage is to call annotated checkpoint methods directly from +`run(...)`: ```dart Future> run(String email) async { @@ -87,24 +87,31 @@ Conceptually: - script workflows: `run(...)` is the execution plan, and declared checkpoints are metadata for manifests/tooling -Choose the entry shape based on whether you need step context: +Script workflows use one entry model: -- plain direct-call style - - `Future run(String email, ...)` - - use when annotated step methods only need serializable parameters -- context-aware style - - `@WorkflowRun()` - - `Future run(WorkflowScriptContext script, String email, ...)` - - use when you need to enter through `script.step(...)` so the step body can - receive `WorkflowScriptStepContext` +- start with a plain direct-call `run(String email, ...)` +- add an optional named injected context when you need runtime metadata or an + explicit `script.step(...)` wrapper + - `Future run(String email, {WorkflowScriptContext? context})` + - `Future checkpoint(String email, {WorkflowScriptStepContext? context})` +- direct annotated checkpoint calls stay the default path Supported context injection points: - flow steps: `FlowContext` - script runs: `WorkflowScriptContext` -- script steps: `WorkflowScriptStepContext` +- script checkpoints: `WorkflowScriptStepContext` - tasks: `TaskInvocationContext` +Child workflows should be started from durable boundaries: + +- `FlowContext.workflows` inside flow steps +- `WorkflowScriptStepContext.workflows` inside script checkpoints + +Avoid starting child workflows directly from the raw +`WorkflowScriptContext` body unless you are explicitly handling replay +semantics yourself. + Serializable parameter rules are enforced by the generator: - supported: @@ -115,7 +122,7 @@ Serializable parameter rules are enforced by the generator: - Dart classes with `toJson()` plus a named `fromJson(...)` constructor taking `Map` - unsupported directly: - - optional/named parameters on generated workflow/task entrypoints + - optional/named business parameters on generated workflow/task entrypoints Typed task results can use the same DTO convention. @@ -167,6 +174,10 @@ Generated output includes: - typed enqueue helpers on `TaskEnqueuer` - typed result wait helpers on `Stem` +Generated task definitions are producer-safe. `Stem.enqueueCall(...)` can use +the definition metadata directly, so a producer can publish typed task calls +without registering the worker handler locally first. + ## Wiring Into StemWorkflowApp For the common case, pass the generated bundle directly to `StemWorkflowApp`: @@ -182,13 +193,18 @@ final result = await StemWorkflowDefinitions.userSignup .startAndWaitWithApp(workflowApp); ``` +When you use `module: stemModule`, the workflow app infers the worker +subscription from the workflow queue plus the default queues declared on the +bundled task handlers. Override `workerConfig.subscription` only when your +routing sends work to additional queues. + If your application already owns a `StemApp`, reuse it: ```dart final stemApp = await StemApp.fromUrl( 'redis://localhost:6379', adapters: const [StemRedisAdapter()], - tasks: stemModule.tasks, + module: stemModule, ); final workflowApp = await StemWorkflowApp.create( @@ -197,6 +213,19 @@ final workflowApp = await StemWorkflowApp.create( ); ``` +For task-only services, use the same bundle directly with `StemApp`: + +```dart +final taskApp = await StemApp.fromUrl( + 'redis://localhost:6379', + adapters: const [StemRedisAdapter()], + module: stemModule, +); +``` + +Plain `StemApp` bootstrap also infers task queue subscriptions from the +bundled task handlers when `workerConfig.subscription` is omitted. + If you already centralize wiring in a `StemClient`, prefer the shared-client path: @@ -204,11 +233,16 @@ path: final client = await StemClient.fromUrl( 'redis://localhost:6379', adapters: const [StemRedisAdapter()], + module: stemModule, ); -final workflowApp = await client.createWorkflowApp(module: stemModule); +final workflowApp = await client.createWorkflowApp(); ``` +If you reuse an existing `StemApp`, its worker subscription stays in charge. +The module-based queue inference only applies when `StemWorkflowApp` is +creating the worker for you. + The generated workflow refs work on `WorkflowRuntime` too: ```dart @@ -233,5 +267,5 @@ See [`example/README.md`](example/README.md) for runnable examples, including: - Generated registration + execution with `StemWorkflowApp` - Runtime manifest + run detail views with `WorkflowRuntime` -- Plain direct-call script steps and context-aware script steps +- Plain direct-call script checkpoints and context-aware script checkpoints - Typed `@TaskDefn` parameters with `TaskInvocationContext` diff --git a/packages/stem_builder/example/README.md b/packages/stem_builder/example/README.md index afca9303..c6686393 100644 --- a/packages/stem_builder/example/README.md +++ b/packages/stem_builder/example/README.md @@ -35,4 +35,4 @@ The generated bundle is the default integration surface: - `StemWorkflowApp.inMemory(module: stemModule)` - `StemWorkflowApp.fromUrl(..., module: stemModule)` - `StemWorkflowApp.create(stemApp: ..., module: stemModule)` -- `StemClient.createWorkflowApp(module: stemModule)` +- `StemClient.fromUrl(..., module: stemModule)` + `createWorkflowApp()` From b3bdc6aee1307f1b4dee1536dccfbd8dbc9d6a6c Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 16:41:41 -0500 Subject: [PATCH 010/302] Collapse generated task helper surfaces --- .site/docs/core-concepts/stem-builder.md | 3 +- .site/docs/workflows/annotated-workflows.md | 24 ++++++- packages/stem/CHANGELOG.md | 4 ++ packages/stem/README.md | 22 ++++++- .../example/annotated_workflows/README.md | 3 +- .../example/annotated_workflows/bin/main.dart | 25 ++++--- .../lib/definitions.stem.g.dart | 66 ------------------- packages/stem/example/ecommerce/README.md | 2 +- .../src/workflows/annotated_defs.stem.g.dart | 37 ----------- packages/stem_builder/CHANGELOG.md | 3 + packages/stem_builder/README.md | 18 ++--- packages/stem_builder/example/README.md | 3 +- .../example/lib/definitions.stem.g.dart | 35 ---------- .../lib/src/stem_registry_builder.dart | 62 ----------------- .../test/stem_registry_builder_test.dart | 6 +- 15 files changed, 83 insertions(+), 230 deletions(-) diff --git a/.site/docs/core-concepts/stem-builder.md b/.site/docs/core-concepts/stem-builder.md index 6113326e..917eac8c 100644 --- a/.site/docs/core-concepts/stem-builder.md +++ b/.site/docs/core-concepts/stem-builder.md @@ -73,7 +73,8 @@ Generated output (`workflow_defs.stem.g.dart`) includes: - `stemModule` - typed workflow refs like `StemWorkflowDefinitions.userSignup` -- typed task definitions, enqueue helpers, and typed result wait helpers +- typed task definitions that use the shared `TaskCall` / + `TaskDefinition.waitFor(...)` APIs ## Wire Into StemWorkflowApp diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index 7c38ec5d..2bd4d84e 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -14,8 +14,8 @@ generated file exposes: - `StemWorkflowDefinitions` - `StemTaskDefinitions` - typed workflow refs like `StemWorkflowDefinitions.userSignup` -- typed enqueue helpers like `enqueueSendEmailTyped(...)` -- typed result wait helpers like `waitForSendEmailTyped(...)` +- typed task definitions that use the shared `TaskCall` / + `TaskDefinition.waitFor(...)` APIs The generated task definitions are producer-safe: `Stem.enqueueCall(...)` can publish from the definition metadata, so producer processes do not need to @@ -52,6 +52,26 @@ final result = await StemWorkflowDefinitions.userSignup .startAndWaitWithApp(workflowApp); ``` +Annotated tasks use the same shared typed task surface: + +```dart +final taskId = await StemTaskDefinitions.sendEmailTyped + .call(( + dispatch: EmailDispatch( + email: 'typed@example.com', + subject: 'Welcome', + body: 'Codec-backed DTO payloads', + tags: ['welcome'], + ), + )) + .enqueueWith(workflowApp.app.stem); + +final result = await StemTaskDefinitions.sendEmailTyped.waitFor( + workflowApp.app.stem, + taskId, +); +``` + ## Script context injection Use a plain `run(...)` when your annotated checkpoints only need serializable diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index bc623da8..f9331859 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -4,6 +4,10 @@ - Added `StemModule`, typed `WorkflowRef`/`WorkflowStartCall` helpers, and bundle-first `StemWorkflowApp`/`StemClient` composition for generated workflow and task definitions. - Added `PayloadCodec`, typed workflow resume helpers, codec-backed workflow checkpoint/result persistence, typed task result waiting, and typed workflow event emit helpers for DTO-shaped payloads. +- Simplified generated annotated task usage so `StemTaskDefinitions.*` is the + canonical surface, reusing shared `TaskCall.enqueueWith(...)` and + `TaskDefinition.waitFor(...)` helpers instead of emitting separate generated + enqueue/wait extension APIs. - Added workflow manifests, runtime metadata views, and run/step drilldown APIs for inspecting workflow definitions and persisted execution state. - Clarified the workflow authoring model by distinguishing flow steps from diff --git a/packages/stem/README.md b/packages/stem/README.md index b64114f3..add2ea9d 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -573,8 +573,8 @@ Generated output gives you: - `stemModule` - `StemWorkflowDefinitions` - `StemTaskDefinitions` -- typed enqueue helpers on `TaskEnqueuer` -- typed result wait helpers on `Stem` +- typed workflow refs and task definitions that use the shared + `WorkflowStartCall`, `TaskCall`, and `TaskDefinition.waitFor(...)` APIs The same bundle also works for plain task apps: @@ -692,6 +692,24 @@ if (charge?.isSucceeded == true) { } ``` +Generated annotated tasks use the same surface: + +```dart +final taskId = await StemTaskDefinitions.sendEmailTyped + .call(( + dispatch: EmailDispatch( + email: 'typed@example.com', + subject: 'Welcome', + body: 'Codec-backed DTO payloads', + tags: ['welcome'], + ), + )) + .enqueueWith(stem); + +final receipt = await StemTaskDefinitions.sendEmailTyped.waitFor(stem, taskId); +print(receipt?.value?.deliveryId); +``` + ### Typed canvas helpers `TaskSignature` (and the `task()` helper) lets you declare the result type diff --git a/packages/stem/example/annotated_workflows/README.md b/packages/stem/example/annotated_workflows/README.md index 47361627..2bbf423f 100644 --- a/packages/stem/example/annotated_workflows/README.md +++ b/packages/stem/example/annotated_workflows/README.md @@ -36,7 +36,8 @@ The generated file exposes: - `stemModule` - `StemWorkflowDefinitions` - typed workflow refs for `StemWorkflowApp` and `WorkflowRuntime` -- typed task definitions, enqueue helpers, and typed result wait helpers +- typed task definitions that use the shared `TaskCall` / + `TaskDefinition.waitFor(...)` APIs When you pass `module: stemModule` into `StemWorkflowApp`, or create a `StemClient` with `module: stemModule` and then call diff --git a/packages/stem/example/annotated_workflows/bin/main.dart b/packages/stem/example/annotated_workflows/bin/main.dart index 69d8a974..f5d4815b 100644 --- a/packages/stem/example/annotated_workflows/bin/main.dart +++ b/packages/stem/example/annotated_workflows/bin/main.dart @@ -81,16 +81,21 @@ Future main() async { '${jsonEncode(contextChildResult?.value?.toJson())}', ); - final typedTaskId = await app.app.stem.enqueueSendEmailTyped( - dispatch: const EmailDispatch( - email: 'typed@example.com', - subject: 'Welcome', - body: 'Codec-backed DTO payloads', - tags: ['welcome', 'transactional', 'annotated'], - ), - meta: const {'origin': 'annotated_workflows_example'}, - ); - final typedTaskResult = await app.app.stem.waitForSendEmailTyped( + final typedTaskId = await StemTaskDefinitions.sendEmailTyped + .call( + ( + dispatch: const EmailDispatch( + email: 'typed@example.com', + subject: 'Welcome', + body: 'Codec-backed DTO payloads', + tags: ['welcome', 'transactional', 'annotated'], + ), + ), + meta: const {'origin': 'annotated_workflows_example'}, + ) + .enqueueWith(app.app.stem); + final typedTaskResult = await StemTaskDefinitions.sendEmailTyped.waitFor( + app.app.stem, typedTaskId, timeout: const Duration(seconds: 2), ); diff --git a/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart b/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart index d8352bea..806a805a 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart @@ -294,72 +294,6 @@ abstract final class StemTaskDefinitions { ); } -extension StemGeneratedTaskEnqueuer on TaskEnqueuer { - Future enqueueSendEmail({ - required Map args, - Map headers = const {}, - TaskOptions? options, - DateTime? notBefore, - Map? meta, - TaskEnqueueOptions? enqueueOptions, - }) { - return enqueueCall( - StemTaskDefinitions.sendEmail.call( - args, - headers: headers, - options: options, - notBefore: notBefore, - meta: meta, - enqueueOptions: enqueueOptions, - ), - ); - } - - Future enqueueSendEmailTyped({ - required EmailDispatch dispatch, - Map headers = const {}, - TaskOptions? options, - DateTime? notBefore, - Map? meta, - TaskEnqueueOptions? enqueueOptions, - }) { - return enqueueCall( - StemTaskDefinitions.sendEmailTyped.call( - (dispatch: dispatch), - headers: headers, - options: options, - notBefore: notBefore, - meta: meta, - enqueueOptions: enqueueOptions, - ), - ); - } -} - -extension StemGeneratedTaskResults on Stem { - Future?> waitForSendEmail( - String taskId, { - Duration? timeout, - }) { - return waitForTaskDefinition( - taskId, - StemTaskDefinitions.sendEmail, - timeout: timeout, - ); - } - - Future?> waitForSendEmailTyped( - String taskId, { - Duration? timeout, - }) { - return waitForTaskDefinition( - taskId, - StemTaskDefinitions.sendEmailTyped, - timeout: timeout, - ); - } -} - final List> _stemTasks = >[ FunctionTaskHandler( name: "send_email", diff --git a/packages/stem/example/ecommerce/README.md b/packages/stem/example/ecommerce/README.md index b56d3d37..fd0eaa0e 100644 --- a/packages/stem/example/ecommerce/README.md +++ b/packages/stem/example/ecommerce/README.md @@ -36,7 +36,7 @@ From those annotations, this example uses generated APIs: - `stemModule` (generated workflow/task bundle) - `StemWorkflowDefinitions.addToCart` - `StemTaskDefinitions.ecommerceAuditLog` -- `TaskEnqueuer.enqueueEcommerceAuditLog(...)` +- `StemTaskDefinitions.ecommerceAuditLog.call(...)` The server wires generated and manual tasks together in one place: diff --git a/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart b/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart index d64e105f..e8ed64bf 100644 --- a/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart +++ b/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart @@ -118,43 +118,6 @@ abstract final class StemTaskDefinitions { ); } -extension StemGeneratedTaskEnqueuer on TaskEnqueuer { - Future enqueueEcommerceAuditLog({ - required String event, - required String entityId, - required String detail, - Map headers = const {}, - TaskOptions? options, - DateTime? notBefore, - Map? meta, - TaskEnqueueOptions? enqueueOptions, - }) { - return enqueueCall( - StemTaskDefinitions.ecommerceAuditLog.call( - (event: event, entityId: entityId, detail: detail), - headers: headers, - options: options, - notBefore: notBefore, - meta: meta, - enqueueOptions: enqueueOptions, - ), - ); - } -} - -extension StemGeneratedTaskResults on Stem { - Future>?> waitForEcommerceAuditLog( - String taskId, { - Duration? timeout, - }) { - return waitForTaskDefinition( - taskId, - StemTaskDefinitions.ecommerceAuditLog, - timeout: timeout, - ); - } -} - final List> _stemTasks = >[ FunctionTaskHandler( name: "ecommerce.audit.log", diff --git a/packages/stem_builder/CHANGELOG.md b/packages/stem_builder/CHANGELOG.md index aab805a1..20d7578e 100644 --- a/packages/stem_builder/CHANGELOG.md +++ b/packages/stem_builder/CHANGELOG.md @@ -3,6 +3,9 @@ ## 0.1.0 - Switched generated output to a bundle-first surface with `stemModule`, `StemWorkflowDefinitions`, `StemTaskDefinitions`, generated typed wait helpers, and payload codec generation for DTO-backed workflow/task APIs. +- Removed generated task-specific enqueue/wait extension APIs in favor of the + shared `TaskCall.enqueueWith(...)` and `TaskDefinition.waitFor(...)` surface + from `stem`, reducing duplicate happy paths in generated task code. - Added builder diagnostics for duplicate or conflicting annotated workflow checkpoint names and refreshed generated examples around typed workflow refs. - Added typed workflow starter generation and app helper output for annotated workflow/task definitions. diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index 70675def..cbe80f29 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -138,8 +138,8 @@ The intended DX is: - pass generated `stemModule` into `StemWorkflowApp` or `StemClient` - start workflows through generated workflow refs instead of raw workflow-name strings -- enqueue annotated tasks through generated `enqueueXxx(...)` helpers instead - of raw task-name strings +- enqueue annotated tasks through `StemTaskDefinitions.*.call(...).enqueueWith(...)` + instead of raw task-name strings You can customize generated workflow ref names via `@WorkflowDefn`: @@ -164,15 +164,15 @@ dart run build_runner build The generated part exports a bundle plus typed helpers so you can avoid raw workflow-name and task-name strings (for example `StemWorkflowDefinitions.userSignup.call((email: 'user@example.com'))` or -`stem.enqueueBuilderExampleTask(args: {...})`). +`StemTaskDefinitions.builderExampleTask.call({...}).enqueueWith(stem)`). Generated output includes: - `stemModule` - `StemWorkflowDefinitions` - `StemTaskDefinitions` -- typed enqueue helpers on `TaskEnqueuer` -- typed result wait helpers on `Stem` +- typed `TaskDefinition` objects that use the shared `TaskCall` / + `TaskDefinition.waitFor(...)` APIs from `stem` Generated task definitions are producer-safe. `Stem.enqueueCall(...)` can use the definition metadata directly, so a producer can publish typed task calls @@ -253,12 +253,12 @@ final runId = await StemWorkflowDefinitions.userSignup await runtime.executeRun(runId); ``` -Annotated tasks also get generated definitions and enqueue helpers: +Annotated tasks also get generated definitions: ```dart -final taskId = await workflowApp.app.stem.enqueueBuilderExampleTask( - args: const {'kind': 'welcome'}, -); +final taskId = await StemTaskDefinitions.builderExampleTask + .call(const {'kind': 'welcome'}) + .enqueueWith(workflowApp.app.stem); ``` ## Examples diff --git a/packages/stem_builder/example/README.md b/packages/stem_builder/example/README.md index c6686393..276ea477 100644 --- a/packages/stem_builder/example/README.md +++ b/packages/stem_builder/example/README.md @@ -7,7 +7,8 @@ This example demonstrates: - Generated typed workflow refs (no manual workflow-name strings): - `StemWorkflowDefinitions.flow.call(...).startWithRuntime(runtime)` - `StemWorkflowDefinitions.userSignup.call(...).startWithRuntime(runtime)` -- Generated typed task definitions, enqueue helpers, and typed result wait helpers +- Generated typed task definitions that use the shared `TaskCall` / + `TaskDefinition.waitFor(...)` APIs - Generated workflow manifest via `stemModule.workflowManifest` - Running generated definitions through `StemWorkflowApp` - Runtime manifest + run/step metadata views via `WorkflowRuntime` diff --git a/packages/stem_builder/example/lib/definitions.stem.g.dart b/packages/stem_builder/example/lib/definitions.stem.g.dart index 6c8b056b..23e8e58b 100644 --- a/packages/stem_builder/example/lib/definitions.stem.g.dart +++ b/packages/stem_builder/example/lib/definitions.stem.g.dart @@ -102,41 +102,6 @@ abstract final class StemTaskDefinitions { ); } -extension StemGeneratedTaskEnqueuer on TaskEnqueuer { - Future enqueueBuilderExampleTask({ - required Map args, - Map headers = const {}, - TaskOptions? options, - DateTime? notBefore, - Map? meta, - TaskEnqueueOptions? enqueueOptions, - }) { - return enqueueCall( - StemTaskDefinitions.builderExampleTask.call( - args, - headers: headers, - options: options, - notBefore: notBefore, - meta: meta, - enqueueOptions: enqueueOptions, - ), - ); - } -} - -extension StemGeneratedTaskResults on Stem { - Future?> waitForBuilderExampleTask( - String taskId, { - Duration? timeout, - }) { - return waitForTaskDefinition( - taskId, - StemTaskDefinitions.builderExampleTask, - timeout: timeout, - ); - } -} - final List> _stemTasks = >[ FunctionTaskHandler( name: "builder.example.task", diff --git a/packages/stem_builder/lib/src/stem_registry_builder.dart b/packages/stem_builder/lib/src/stem_registry_builder.dart index 383df63c..ef0fedbf 100644 --- a/packages/stem_builder/lib/src/stem_registry_builder.dart +++ b/packages/stem_builder/lib/src/stem_registry_builder.dart @@ -1952,68 +1952,6 @@ class _RegistryEmitter { } buffer.writeln('}'); buffer.writeln(); - - buffer.writeln('extension StemGeneratedTaskEnqueuer on TaskEnqueuer {'); - for (final task in tasks) { - final symbol = symbolNames[task]!; - final fieldName = _lowerCamel(symbol); - buffer.writeln(' Future enqueue$symbol({'); - if (task.usesLegacyMapArgs) { - buffer.writeln(' required Map args,'); - } else { - for (final parameter in task.valueParameters) { - buffer.writeln( - ' required ${parameter.typeCode} ${parameter.name},', - ); - } - } - buffer.writeln(' Map headers = const {},'); - buffer.writeln(' TaskOptions? options,'); - buffer.writeln(' DateTime? notBefore,'); - buffer.writeln(' Map? meta,'); - buffer.writeln(' TaskEnqueueOptions? enqueueOptions,'); - buffer.writeln(' }) {'); - final callArgs = task.usesLegacyMapArgs - ? 'args' - : task.valueParameters.isEmpty - ? '()' - : '(${task.valueParameters.map((parameter) => '${parameter.name}: ${parameter.name}').join(', ')})'; - buffer.writeln(' return enqueueCall('); - buffer.writeln(' StemTaskDefinitions.$fieldName.call('); - buffer.writeln(' $callArgs,'); - buffer.writeln(' headers: headers,'); - buffer.writeln(' options: options,'); - buffer.writeln(' notBefore: notBefore,'); - buffer.writeln(' meta: meta,'); - buffer.writeln(' enqueueOptions: enqueueOptions,'); - buffer.writeln(' ),'); - buffer.writeln(' );'); - buffer.writeln(' }'); - buffer.writeln(); - } - buffer.writeln('}'); - buffer.writeln(); - - buffer.writeln('extension StemGeneratedTaskResults on Stem {'); - for (final task in tasks) { - final symbol = symbolNames[task]!; - final fieldName = _lowerCamel(symbol); - buffer.writeln( - ' Future?> waitFor$symbol(', - ); - buffer.writeln(' String taskId, {'); - buffer.writeln(' Duration? timeout,'); - buffer.writeln(' }) {'); - buffer.writeln(' return waitForTaskDefinition('); - buffer.writeln(' taskId,'); - buffer.writeln(' StemTaskDefinitions.$fieldName,'); - buffer.writeln(' timeout: timeout,'); - buffer.writeln(' );'); - buffer.writeln(' }'); - buffer.writeln(); - } - buffer.writeln('}'); - buffer.writeln(); } void _emitTaskAdapters(StringBuffer buffer) { diff --git a/packages/stem_builder/test/stem_registry_builder_test.dart b/packages/stem_builder/test/stem_registry_builder_test.dart index 3202f098..bf3d1aa7 100644 --- a/packages/stem_builder/test/stem_registry_builder_test.dart +++ b/packages/stem_builder/test/stem_registry_builder_test.dart @@ -197,15 +197,15 @@ Future sendEmail( allOf([ contains('StemWorkflowDefinitions'), contains('StemTaskDefinitions'), - contains('StemGeneratedTaskEnqueuer'), - contains('StemGeneratedTaskResults'), - contains('waitForSendEmail('), contains('WorkflowRef, String>'), contains('Flow('), contains('WorkflowScript('), contains('stemModule = StemModule('), contains('FunctionTaskHandler'), contains("part of 'workflows.dart';"), + isNot(contains('StemGeneratedTaskEnqueuer')), + isNot(contains('StemGeneratedTaskResults')), + isNot(contains('waitForSendEmail(')), ]), ), }, From 65286401cd9f86e55e449b3e0669bfe184971efe Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 16:44:38 -0500 Subject: [PATCH 011/302] Unify workflow call dispatch helpers --- .site/docs/workflows/annotated-workflows.md | 6 ++++-- packages/stem/CHANGELOG.md | 3 +++ packages/stem/README.md | 8 ++++++-- .../stem/example/annotated_workflows/README.md | 5 +++-- .../annotated_workflows/lib/definitions.dart | 16 ++++++---------- .../stem/lib/src/bootstrap/workflow_app.dart | 4 ++-- .../stem/lib/src/workflow/core/workflow_ref.dart | 9 +++++++++ .../workflow_runtime_call_extensions_test.dart | 4 ++-- .../test/workflow/workflow_runtime_test.dart | 14 ++++++-------- packages/stem_builder/README.md | 6 ++++-- 10 files changed, 45 insertions(+), 30 deletions(-) diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index 2bd4d84e..d219148e 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -140,8 +140,10 @@ This keeps one authoring model: When a workflow needs to start another workflow, do it from a durable boundary: -- `FlowContext.workflows` inside flow steps -- `WorkflowScriptStepContext.workflows` inside checkpoint methods +- `StemWorkflowDefinitions.someWorkflow.call(...).startWith(context.workflows!)` + inside flow steps +- `StemWorkflowDefinitions.someWorkflow.call(...).startWith(context.workflows!)` + inside checkpoint methods Avoid starting child workflows from the raw `WorkflowScriptContext` body. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index f9331859..35386aa6 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -8,6 +8,9 @@ canonical surface, reusing shared `TaskCall.enqueueWith(...)` and `TaskDefinition.waitFor(...)` helpers instead of emitting separate generated enqueue/wait extension APIs. +- Added `WorkflowStartCall.startWith(...)` so workflow refs can dispatch + uniformly through apps, runtimes, and child-workflow callers instead of + dropping back to `startWorkflowRef(...)` in durable workflow code. - Added workflow manifests, runtime metadata views, and run/step drilldown APIs for inspecting workflow definitions and persisted execution state. - Clarified the workflow authoring model by distinguishing flow steps from diff --git a/packages/stem/README.md b/packages/stem/README.md index add2ea9d..fe0caa2b 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -505,8 +505,12 @@ Context injection works at every runtime layer: Child workflows belong in durable execution boundaries: -- use `FlowContext.workflows` inside flow steps -- use `WorkflowScriptStepContext.workflows` inside script checkpoints +- use + `StemWorkflowDefinitions.someWorkflow.call(...).startWith(context.workflows!)` + inside flow steps +- use + `StemWorkflowDefinitions.someWorkflow.call(...).startWith(context.workflows!)` + inside script checkpoints - do not start child workflows from the raw `WorkflowScriptContext` body unless you are deliberately managing replay/idempotency yourself diff --git a/packages/stem/example/annotated_workflows/README.md b/packages/stem/example/annotated_workflows/README.md index 2bbf423f..c1baf6e8 100644 --- a/packages/stem/example/annotated_workflows/README.md +++ b/packages/stem/example/annotated_workflows/README.md @@ -5,7 +5,8 @@ with the `stem_builder` bundle generator. It now demonstrates the generated script-proxy behavior explicitly: - a flow step using `FlowContext` -- a flow step starting a child workflow through `FlowContext.workflows` +- a flow step starting a child workflow through + `StemWorkflowDefinitions.*.call(...).startWith(context.workflows!)` - `run(WelcomeRequest request)` calls annotated checkpoint methods directly - `prepareWelcome(...)` calls other annotated checkpoints - `deliverWelcome(...)` calls another annotated checkpoint from inside an @@ -14,7 +15,7 @@ It now demonstrates the generated script-proxy behavior explicitly: (`WorkflowScriptContext? context` / `WorkflowScriptStepContext? context`) to expose `runId`, `workflow`, `stepName`, `stepIndex`, and idempotency keys - a script checkpoint starting a child workflow through - `WorkflowScriptStepContext.workflows` + `StemWorkflowDefinitions.*.call(...).startWith(context.workflows!)` - a plain script workflow that returns a codec-backed DTO result and persists a codec-backed DTO checkpoint value - a typed `@TaskDefn` using optional named `TaskInvocationContext? context` diff --git a/packages/stem/example/annotated_workflows/lib/definitions.dart b/packages/stem/example/annotated_workflows/lib/definitions.dart index c9318887..4dc5de8d 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.dart @@ -188,12 +188,9 @@ class AnnotatedFlowWorkflow { if (!ctx.sleepUntilResumed(const Duration(milliseconds: 50))) { return null; } - final childRunId = await ctx.workflows!.startWorkflowRef( - StemWorkflowDefinitions.script, - ( - request: const WelcomeRequest(email: 'flow-child@example.com'), - ), - ); + final childRunId = await StemWorkflowDefinitions.script + .call((request: const WelcomeRequest(email: 'flow-child@example.com'))) + .startWith(ctx.workflows!); return { 'workflow': ctx.workflow, 'runId': ctx.runId, @@ -272,10 +269,9 @@ class AnnotatedContextScriptWorkflow { final ctx = context!; final normalizedEmail = await normalizeEmail(request.email); final subject = await buildWelcomeSubject(normalizedEmail); - final childRunId = await ctx.workflows!.startWorkflowRef( - StemWorkflowDefinitions.script, - (request: WelcomeRequest(email: normalizedEmail)), - ); + final childRunId = await StemWorkflowDefinitions.script + .call((request: WelcomeRequest(email: normalizedEmail))) + .startWith(ctx.workflows!); return ContextCaptureResult( workflow: ctx.workflow, runId: ctx.runId, diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index 54698376..828358e0 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -605,7 +605,7 @@ extension WorkflowStartCallAppExtension on WorkflowStartCall { /// Starts this workflow call with [app]. Future startWithApp(StemWorkflowApp app) { - return app.startWorkflowCall(this); + return startWith(app); } /// Starts this workflow call with [app] and waits for the typed result. @@ -625,7 +625,7 @@ extension WorkflowStartCallAppExtension /// Starts this workflow call with [runtime]. Future startWithRuntime(WorkflowRuntime runtime) { - return runtime.startWorkflowCall(this); + return startWith(runtime); } /// Starts this workflow call with [runtime] and waits for the typed result. diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index df700838..358fde04 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -114,3 +114,12 @@ class WorkflowStartCall { ); } } + +/// Convenience helpers for dispatching prebuilt [WorkflowStartCall] instances. +extension WorkflowStartCallExtension + on WorkflowStartCall { + /// Starts this typed workflow call with the provided [caller]. + Future startWith(WorkflowCaller caller) { + return caller.startWorkflowCall(this); + } +} diff --git a/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart b/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart index eb9b985a..1d9ce436 100644 --- a/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart @@ -4,7 +4,7 @@ import 'package:test/test.dart'; void main() { group('runtime workflow call extensions', () { test( - 'startAndWaitWithRuntime and waitForWithRuntime use typed workflow refs', + 'startWith/startAndWaitWithRuntime and waitForWithRuntime use typed workflow refs', () async { final flow = Flow( name: 'runtime.extension.flow', @@ -26,7 +26,7 @@ void main() { final runId = await workflowRef .call(const {'name': 'runtime'}) - .startWithRuntime(workflowApp.runtime); + .startWith(workflowApp.runtime); final waited = await workflowRef.waitForWithRuntime( workflowApp.runtime, runId, diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index 74f3e5ce..94373e00 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -153,10 +153,9 @@ void main() { name: 'parent.runtime.flow', build: (flow) { flow.step('spawn', (context) async { - return context.workflows!.startWorkflowRef( - childRef, - const {'value': 'spawned'}, - ); + return childRef + .call(const {'value': 'spawned'}) + .startWith(context.workflows!); }); }, ).definition, @@ -201,10 +200,9 @@ void main() { ], run: (script) async { return script.step('spawn', (context) async { - return context.workflows!.startWorkflowRef( - childRef, - const {'value': 'script-child'}, - ); + return childRef + .call(const {'value': 'script-child'}) + .startWith(context.workflows!); }); }, ).definition, diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index cbe80f29..d1a4c5fe 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -105,8 +105,10 @@ Supported context injection points: Child workflows should be started from durable boundaries: -- `FlowContext.workflows` inside flow steps -- `WorkflowScriptStepContext.workflows` inside script checkpoints +- `StemWorkflowDefinitions.someWorkflow.call(...).startWith(context.workflows!)` + inside flow steps +- `StemWorkflowDefinitions.someWorkflow.call(...).startWith(context.workflows!)` + inside script checkpoints Avoid starting child workflows directly from the raw `WorkflowScriptContext` body unless you are explicitly handling replay From b043032a59422153e7e59cc4f00ac62f15b5f50f Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 16:52:13 -0500 Subject: [PATCH 012/302] Add typed refs for manual workflows --- .site/docs/workflows/flows-and-scripts.md | 16 +++++++++ .site/docs/workflows/starting-and-waiting.md | 25 +++++++++++++- packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 15 ++++---- .../example/docs_snippets/lib/workflows.dart | 30 +++++++++------- packages/stem/example/ecommerce/README.md | 4 +++ .../stem/example/ecommerce/lib/src/app.dart | 34 +++++++------------ .../lib/src/workflows/checkout_flow.dart | 25 +++++++++----- packages/stem/lib/src/workflow/core/flow.dart | 9 +++++ .../workflow/core/workflow_definition.dart | 15 +++++++- .../src/workflow/core/workflow_script.dart | 9 +++++ .../workflow/workflow_runtime_ref_test.dart | 34 +++++++++++++++++-- 12 files changed, 165 insertions(+), 54 deletions(-) diff --git a/.site/docs/workflows/flows-and-scripts.md b/.site/docs/workflows/flows-and-scripts.md index 018be993..5af31fa2 100644 --- a/.site/docs/workflows/flows-and-scripts.md +++ b/.site/docs/workflows/flows-and-scripts.md @@ -27,6 +27,14 @@ is that for script workflows those are **checkpoints**, not the plan itself. ``` +Manual flows can also derive a typed workflow ref from the definition: + +```dart +final approvalsRef = approvalsFlow.ref<({Map draft})>( + encodeParams: (params) => {'draft': params.draft}, +); +``` + Use `Flow` when: - the sequence of durable actions should be obvious from the definition @@ -39,6 +47,14 @@ Use `Flow` when: ``` +Manual scripts support the same pattern: + +```dart +final retryRef = retryScript.ref>( + encodeParams: (params) => params, +); +``` + Use `WorkflowScript` when: - you want normal Dart control flow to define the run diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 67ec8862..bf742cba 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -3,7 +3,7 @@ title: Starting and Waiting --- Workflow runs are started through the runtime, through `StemWorkflowApp`, or -through generated workflow refs. +through typed workflow refs. ## Start by workflow name @@ -15,6 +15,29 @@ Use `params:` to supply workflow input and `WorkflowCancellationPolicy` to cap wall-clock runtime or maximum suspension time. +That low-level API is useful when workflow names come from config or external +input. For workflows you define in code, prefer a typed workflow ref instead. + +## Start through manual workflow refs + +Manual `Flow(...)` and `WorkflowScript(...)` definitions can derive a typed ref +without repeating the workflow-name string: + +```dart +final approvalsRef = approvalsFlow.ref<({Map draft})>( + encodeParams: (params) => {'draft': params.draft}, +); + +final runId = await approvalsRef + .call((draft: const {'documentId': 'doc-42'})) + .startWithApp(workflowApp); + +final result = await approvalsRef.waitFor(workflowApp, runId); +``` + +Use this path when you want the same typed start/wait surface as generated +workflow refs, but the workflow itself is still hand-written. + ## Wait for completion `waitForCompletion` polls the store until the run finishes or the caller diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 35386aa6..20a96280 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -11,6 +11,9 @@ - Added `WorkflowStartCall.startWith(...)` so workflow refs can dispatch uniformly through apps, runtimes, and child-workflow callers instead of dropping back to `startWorkflowRef(...)` in durable workflow code. +- Added `Flow.ref(...)` / `WorkflowScript.ref(...)` helpers so manual workflow + definitions can derive typed workflow refs without repeating workflow-name + strings or manual result decoder wiring. - Added workflow manifests, runtime metadata views, and run/step drilldown APIs for inspecting workflow definitions and persisted execution state. - Clarified the workflow authoring model by distinguishing flow steps from diff --git a/packages/stem/README.md b/packages/stem/README.md index fe0caa2b..aecc564d 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -389,20 +389,21 @@ final approvalsFlow = Flow( }, ); +final approvalsRef = approvalsFlow.ref<({Map draft})>( + encodeParams: (params) => {'draft': params.draft}, +); + final app = await StemWorkflowApp.fromUrl( 'memory://', flows: [approvalsFlow], tasks: const [], ); -final runId = await app.startWorkflow( - 'approvals.flow', - params: { - 'draft': {'documentId': 'doc-42'}, - }, -); +final runId = await approvalsRef + .call((draft: const {'documentId': 'doc-42'})) + .startWithApp(app); -final result = await app.waitForCompletion(runId); +final result = await approvalsRef.waitFor(app, runId); print(result?.value); await app.close(); ``` diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index e1af8f3e..5c641d83 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -64,6 +64,10 @@ class ApprovalsFlow { }); }, ); + + static final ref = flow.ref<({Map draft})>( + encodeParams: (params) => {'draft': params.draft}, + ); } Future registerFlow(StemWorkflowApp workflowApp) async { @@ -99,18 +103,20 @@ final retryDefinition = retryScript.definition; // #region workflows-run Future runWorkflow(StemWorkflowApp workflowApp) async { - final runId = await workflowApp.startWorkflow( - 'approvals.flow', - params: { - 'draft': {'documentId': 'doc-42'}, - }, - cancellationPolicy: const WorkflowCancellationPolicy( - maxRunDuration: Duration(hours: 2), - maxSuspendDuration: Duration(minutes: 30), - ), - ); - - final result = await workflowApp.waitForCompletion( + final runId = await ApprovalsFlow.ref + .call( + ( + draft: const {'documentId': 'doc-42'}, + ), + cancellationPolicy: const WorkflowCancellationPolicy( + maxRunDuration: Duration(hours: 2), + maxSuspendDuration: Duration(minutes: 30), + ), + ) + .startWithApp(workflowApp); + + final result = await ApprovalsFlow.ref.waitFor( + workflowApp, runId, timeout: const Duration(minutes: 5), ); diff --git a/packages/stem/example/ecommerce/README.md b/packages/stem/example/ecommerce/README.md index fd0eaa0e..c4775d66 100644 --- a/packages/stem/example/ecommerce/README.md +++ b/packages/stem/example/ecommerce/README.md @@ -38,6 +38,10 @@ From those annotations, this example uses generated APIs: - `StemTaskDefinitions.ecommerceAuditLog` - `StemTaskDefinitions.ecommerceAuditLog.call(...)` +The manual checkout flow also derives a typed ref from its `Flow` definition: + +- `checkoutWorkflowRef(checkoutFlow)` + The server wires generated and manual tasks together in one place: ```dart diff --git a/packages/stem/example/ecommerce/lib/src/app.dart b/packages/stem/example/ecommerce/lib/src/app.dart index ef06111b..d82aa7c9 100644 --- a/packages/stem/example/ecommerce/lib/src/app.dart +++ b/packages/stem/example/ecommerce/lib/src/app.dart @@ -32,12 +32,14 @@ class EcommerceServer { ); final repository = await EcommerceRepository.open(commerceDatabasePath); bindAddToCartWorkflowRepository(repository); + final checkoutFlow = buildCheckoutFlow(repository); + final checkoutWorkflow = checkoutWorkflowRef(checkoutFlow); final workflowApp = await StemWorkflowApp.fromUrl( 'sqlite://$stemDatabasePath', adapters: const [StemSqliteAdapter()], module: stemModule, - flows: [buildCheckoutFlow(repository)], + flows: [checkoutFlow], tasks: [shipmentReserveTaskHandler], workerConfig: StemWorkerConfig( queue: 'workflow', @@ -56,7 +58,7 @@ class EcommerceServer { 'stemDatabasePath': stemDatabasePath, 'workflows': [ StemWorkflowDefinitions.addToCart.name, - checkoutWorkflowName, + checkoutWorkflow.name, ], }); }) @@ -132,17 +134,15 @@ class EcommerceServer { }) ..post('/checkout/', (Request request, String cartId) async { try { - final runId = await workflowApp.startWorkflow( - checkoutWorkflowName, - params: {'cartId': cartId}, - ); + final runId = await checkoutWorkflow + .call((cartId: cartId)) + .startWithApp(workflowApp); - final result = await workflowApp - .waitForCompletion>( - runId, - timeout: const Duration(seconds: 6), - decode: _toMap, - ); + final result = await checkoutWorkflow.waitFor( + workflowApp, + runId, + timeout: const Duration(seconds: 6), + ); if (result == null) { return _error(500, 'Checkout workflow run not found.', { @@ -239,16 +239,6 @@ Response _error(int status, String message, Object? error) { return _json(status, {'error': message, 'details': _normalizeError(error)}); } -Map _toMap(Object? value) { - if (value is Map) { - return value; - } - if (value is Map) { - return value.cast(); - } - return {}; -} - Object? _normalizeError(Object? error) { if (error == null) { return null; diff --git a/packages/stem/example/ecommerce/lib/src/workflows/checkout_flow.dart b/packages/stem/example/ecommerce/lib/src/workflows/checkout_flow.dart index 1c342b4f..fe979430 100644 --- a/packages/stem/example/ecommerce/lib/src/workflows/checkout_flow.dart +++ b/packages/stem/example/ecommerce/lib/src/workflows/checkout_flow.dart @@ -4,6 +4,14 @@ import '../domain/repository.dart'; const checkoutWorkflowName = 'ecommerce.checkout'; +WorkflowRef<({String cartId}), Map> checkoutWorkflowRef( + Flow> flow, +) { + return flow.ref<({String cartId})>( + encodeParams: (params) => {'cartId': params.cartId}, + ); +} + Flow> buildCheckoutFlow(EcommerceRepository repository) { return Flow>( name: checkoutWorkflowName, @@ -24,15 +32,14 @@ Flow> buildCheckoutFlow(EcommerceRepository repository) { }); flow.step('capture-payment', (ctx) async { - final resume = ctx.takeResumeValue>(); - if (resume == null) { - ctx.sleep( - const Duration(milliseconds: 100), - data: { - 'phase': 'payment-authorization', - 'cartId': ctx.params['cartId'], - }, - ); + if ( + !ctx.sleepUntilResumed( + const Duration(milliseconds: 100), + data: { + 'phase': 'payment-authorization', + 'cartId': ctx.params['cartId'], + }, + )) { return null; } diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index 5a5cdd97..5908e0b2 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -1,5 +1,6 @@ import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/workflow/core/workflow_definition.dart'; +import 'package:stem/src/workflow/core/workflow_ref.dart'; /// Convenience wrapper that builds a [WorkflowDefinition] using the declarative /// [FlowBuilder] DSL. @@ -28,4 +29,12 @@ class Flow { /// The constructed workflow definition. final WorkflowDefinition definition; + + /// Builds a typed [WorkflowRef] using this flow's registered workflow name + /// and result decoder. + WorkflowRef ref({ + required Map Function(TParams params) encodeParams, + }) { + return definition.ref(encodeParams: encodeParams); + } } diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index 3364a6c2..93dd79d8 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -61,6 +61,7 @@ import 'package:stem/src/workflow/core/flow.dart' show Flow; import 'package:stem/src/workflow/core/flow_context.dart'; import 'package:stem/src/workflow/core/flow_step.dart'; import 'package:stem/src/workflow/core/workflow_checkpoint.dart'; +import 'package:stem/src/workflow/core/workflow_ref.dart'; import 'package:stem/src/workflow/core/workflow_script_context.dart'; import 'package:stem/src/workflow/workflow.dart' show Flow; import 'package:stem/stem.dart' show Flow; @@ -114,7 +115,7 @@ class WorkflowDefinition { .map(FlowStep.fromJson) .toList() : []; - final checkpointsJson = ((json['checkpoints'] as List?) ?? stepsJson); + final checkpointsJson = (json['checkpoints'] as List?) ?? stepsJson; final checkpoints = kind == WorkflowDefinitionKind.script ? checkpointsJson .whereType>() @@ -292,6 +293,18 @@ class WorkflowDefinition { return decoder(payload); } + /// Builds a typed [WorkflowRef] from this definition without repeating the + /// registered workflow name. + WorkflowRef ref({ + required Map Function(TParams params) encodeParams, + }) { + return WorkflowRef( + name: name, + encodeParams: encodeParams, + decodeResult: (payload) => decodeResult(payload) as T, + ); + } + /// Stable identifier derived from immutable workflow definition fields. String get stableId { final basis = StringBuffer() diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index afe204d3..efdb8e6b 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -1,6 +1,7 @@ import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/workflow/core/workflow_checkpoint.dart'; import 'package:stem/src/workflow/core/workflow_definition.dart'; +import 'package:stem/src/workflow/core/workflow_ref.dart'; /// High-level workflow facade that allows scripts to be authored as a single /// async function using `step`, `sleep`, and `awaitEvent` helpers. @@ -30,4 +31,12 @@ class WorkflowScript { /// The constructed workflow definition. final WorkflowDefinition definition; + + /// Builds a typed [WorkflowRef] using this script's registered workflow name + /// and result decoder. + WorkflowRef ref({ + required Map Function(TParams params) encodeParams, + }) { + return definition.ref(encodeParams: encodeParams); + } } diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index cb820948..344456d3 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -13,8 +13,7 @@ void main() { }); }, ); - final workflowRef = WorkflowRef, String>( - name: 'runtime.ref.flow', + final workflowRef = flow.ref>( encodeParams: (params) => params, ); @@ -48,5 +47,36 @@ void main() { await workflowApp.shutdown(); } }); + + test('manual workflow scripts can derive typed refs', () async { + final script = WorkflowScript( + name: 'runtime.ref.script', + run: (context) async { + final name = context.params['name'] as String? ?? 'world'; + return 'script $name'; + }, + ); + final workflowRef = script.ref>( + encodeParams: (params) => params, + ); + + final workflowApp = await StemWorkflowApp.inMemory(scripts: [script]); + try { + await workflowApp.start(); + + final runId = await workflowRef + .call(const {'name': 'runtime'}) + .startWith(workflowApp.runtime); + final waited = await workflowRef.waitForWithRuntime( + workflowApp.runtime, + runId, + timeout: const Duration(seconds: 2), + ); + + expect(waited?.value, 'script runtime'); + } finally { + await workflowApp.shutdown(); + } + }); }); } From 827b6995fdbd02afb30f5382a9d6828233abf235 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 16:56:41 -0500 Subject: [PATCH 013/302] Use typed refs in manual workflow examples --- packages/stem/CHANGELOG.md | 2 + packages/stem/README.md | 24 ++++--- .../example/docs_snippets/lib/workflows.dart | 8 ++- packages/stem/example/durable_watchers.dart | 69 +++++++++---------- packages/stem/example/persistent_sleep.dart | 38 +++++----- .../example/workflows/basic_in_memory.dart | 23 ++++--- .../workflows/cancellation_policy.dart | 53 +++++++------- .../example/workflows/custom_factories.dart | 22 +++--- .../example/workflows/sleep_and_event.dart | 53 +++++++------- .../stem/example/workflows/sqlite_store.dart | 22 +++--- .../example/workflows/versioned_rewind.dart | 32 +++++---- 11 files changed, 188 insertions(+), 158 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 20a96280..8e410480 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -14,6 +14,8 @@ - Added `Flow.ref(...)` / `WorkflowScript.ref(...)` helpers so manual workflow definitions can derive typed workflow refs without repeating workflow-name strings or manual result decoder wiring. +- Updated the manual workflow examples and docs to start runs through typed + refs instead of repeating raw workflow-name strings in the happy path. - Added workflow manifests, runtime metadata views, and run/step drilldown APIs for inspecting workflow definitions and persisted execution state. - Clarified the workflow authoring model by distinguishing flow steps from diff --git a/packages/stem/README.md b/packages/stem/README.md index aecc564d..dbc80be8 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -268,19 +268,23 @@ class ParentTask implements TaskHandler { Spin up a full runtime in one call using the bootstrap APIs: ```dart +final demoWorkflow = Flow( + name: 'demo.workflow', + build: (flow) { + flow.step('hello', (ctx) async => 'done'); + }, +); + +final demoWorkflowRef = demoWorkflow.ref>( + encodeParams: (params) => params, +); + final app = await StemWorkflowApp.inMemory( - flows: [ - Flow( - name: 'demo.workflow', - build: (flow) { - flow.step('hello', (ctx) async => 'done'); - }, - ), - ], + flows: [demoWorkflow], ); -final runId = await app.startWorkflow('demo.workflow'); -final result = await app.waitForCompletion(runId); +final runId = await demoWorkflowRef.call(const {}).startWithApp(app); +final result = await demoWorkflowRef.waitFor(app, runId); print(result?.value); // 'hello world' print(result?.state.status); // WorkflowStatus.completed diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index 5c641d83..fab84e0a 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -250,12 +250,16 @@ Future main() async { flow.step('hello', (ctx) async => 'done'); }, ); + final demoFlowRef = demoFlow.ref>( + encodeParams: (params) => params, + ); final app = await StemWorkflowApp.inMemory(flows: [demoFlow]); await app.start(); - final runId = await app.startWorkflow('demo.flow'); - final result = await app.waitForCompletion( + final runId = await demoFlowRef.call(const {}).startWithApp(app); + final result = await demoFlowRef.waitFor( + app, runId, timeout: const Duration(seconds: 5), ); diff --git a/packages/stem/example/durable_watchers.dart b/packages/stem/example/durable_watchers.dart index 76114fe7..2170d625 100644 --- a/packages/stem/example/durable_watchers.dart +++ b/packages/stem/example/durable_watchers.dart @@ -8,43 +8,42 @@ final shipmentReadyEventCodec = PayloadCodec<_ShipmentReadyEvent>( /// Runs a workflow that suspends on `awaitEvent` and resumes once a payload is /// emitted. The example also inspects watcher metadata before the resume. Future main() async { - final app = await StemWorkflowApp.inMemory( - scripts: [ - WorkflowScript( - name: 'shipment.workflow', - run: (script) async { - await script.step('prepare', (step) async { - final orderId = step.params['orderId']; - return 'prepared-$orderId'; - }); + final shipmentWorkflow = WorkflowScript( + name: 'shipment.workflow', + run: (script) async { + await script.step('prepare', (step) async { + final orderId = step.params['orderId']; + return 'prepared-$orderId'; + }); - final trackingId = await script.step('wait-for-shipment', ( - step, - ) async { - final payload = step.takeResumeValue<_ShipmentReadyEvent>( - codec: shipmentReadyEventCodec, - ); - if (payload == null) { - await step.awaitEvent( - 'shipment.ready', - deadline: DateTime.now().add(const Duration(minutes: 5)), - data: const {'reason': 'waiting-for-carrier'}, - ); - return 'waiting'; - } - return payload.trackingId; - }); + final trackingId = await script.step('wait-for-shipment', (step) async { + final payload = step.takeResumeValue<_ShipmentReadyEvent>( + codec: shipmentReadyEventCodec, + ); + if (payload == null) { + await step.awaitEvent( + 'shipment.ready', + deadline: DateTime.now().add(const Duration(minutes: 5)), + data: const {'reason': 'waiting-for-carrier'}, + ); + return 'waiting'; + } + return payload.trackingId; + }); - return trackingId; - }, - ), - ], + return trackingId; + }, ); - - final runId = await app.startWorkflow( - 'shipment.workflow', - params: const {'orderId': 'A-123'}, + final shipmentWorkflowRef = shipmentWorkflow.ref>( + encodeParams: (params) => params, ); + final app = await StemWorkflowApp.inMemory( + scripts: [shipmentWorkflow], + ); + + final runId = await shipmentWorkflowRef + .call(const {'orderId': 'A-123'}) + .startWithApp(app); // Drive the run until it suspends on the watcher. await app.runtime.executeRun(runId); @@ -65,8 +64,8 @@ Future main() async { await app.runtime.executeRun(runId); - final completed = await app.store.get(runId); - print('Workflow completed with result: ${completed?.result}'); + final completed = await shipmentWorkflowRef.waitFor(app, runId); + print('Workflow completed with result: ${completed?.value}'); await app.close(); } diff --git a/packages/stem/example/persistent_sleep.dart b/packages/stem/example/persistent_sleep.dart index cf5555d2..ddad8bc3 100644 --- a/packages/stem/example/persistent_sleep.dart +++ b/packages/stem/example/persistent_sleep.dart @@ -7,26 +7,28 @@ import 'package:stem/stem.dart'; /// completes on the next invocation. Future main() async { var iterations = 0; + final sleepLoop = Flow( + name: 'sleep.loop.workflow', + build: (flow) { + flow.step('loop', (ctx) async { + iterations += 1; + if (iterations == 1) { + ctx.sleep(const Duration(milliseconds: 100)); + return 'waiting'; + } + return 'resumed'; + }); + }, + ); + final sleepLoopRef = sleepLoop.ref>( + encodeParams: (params) => params, + ); final app = await StemWorkflowApp.inMemory( - flows: [ - Flow( - name: 'sleep.loop.workflow', - build: (flow) { - flow.step('loop', (ctx) async { - iterations += 1; - if (iterations == 1) { - ctx.sleep(const Duration(milliseconds: 100)); - return 'waiting'; - } - return 'resumed'; - }); - }, - ), - ], + flows: [sleepLoop], ); - final runId = await app.startWorkflow('sleep.loop.workflow'); + final runId = await sleepLoopRef.call(const {}).startWithApp(app); await app.runtime.executeRun(runId); // After the delay elapses, the runtime should resume without the step @@ -39,7 +41,7 @@ Future main() async { await app.runtime.executeRun(id); } - final completed = await app.store.get(runId); - print('Workflow completed with result: ${completed?.result}'); + final completed = await sleepLoopRef.waitFor(app, runId); + print('Workflow completed with result: ${completed?.value}'); await app.close(); } diff --git a/packages/stem/example/workflows/basic_in_memory.dart b/packages/stem/example/workflows/basic_in_memory.dart index d81bf08b..aafcb3ba 100644 --- a/packages/stem/example/workflows/basic_in_memory.dart +++ b/packages/stem/example/workflows/basic_in_memory.dart @@ -4,19 +4,22 @@ import 'package:stem/stem.dart'; Future main() async { + final basicHello = Flow( + name: 'basic.hello', + build: (flow) { + flow.step('greet', (ctx) async => 'Hello Stem'); + }, + ); + final basicHelloRef = basicHello.ref>( + encodeParams: (params) => params, + ); + final app = await StemWorkflowApp.inMemory( - flows: [ - Flow( - name: 'basic.hello', - build: (flow) { - flow.step('greet', (ctx) async => 'Hello Stem'); - }, - ), - ], + flows: [basicHello], ); - final runId = await app.startWorkflow('basic.hello'); - final result = await app.waitForCompletion(runId); + final runId = await basicHelloRef.call(const {}).startWithApp(app); + final result = await basicHelloRef.waitFor(app, runId); print('Workflow $runId finished with result: ${result?.value}'); await app.close(); diff --git a/packages/stem/example/workflows/cancellation_policy.dart b/packages/stem/example/workflows/cancellation_policy.dart index e5de70a3..f6ac44a0 100644 --- a/packages/stem/example/workflows/cancellation_policy.dart +++ b/packages/stem/example/workflows/cancellation_policy.dart @@ -9,34 +9,39 @@ import 'package:stem/stem.dart'; /// seconds, the runtime automatically cancels the run once the policy is /// exceeded. Operators can introspect the reason via `StemWorkflowApp`. Future main() async { - final app = await StemWorkflowApp.inMemory( - flows: [ - Flow( - name: 'reports.generate', - build: (flow) { - flow.step('poll-status', (ctx) async { - if (!ctx.sleepUntilResumed(const Duration(seconds: 5))) { - print('[workflow] polling external system…'); - // Simulate a slow external service; the cancellation policy will - // cap this suspension to 2 seconds. - return null; - } - print('[workflow] resumed after sleep'); - return 'finished'; - }); - }, - ), - ], + final reportsGenerate = Flow( + name: 'reports.generate', + build: (flow) { + flow.step('poll-status', (ctx) async { + if (!ctx.sleepUntilResumed(const Duration(seconds: 5))) { + print('[workflow] polling external system…'); + // Simulate a slow external service; the cancellation policy will + // cap this suspension to 2 seconds. + return null; + } + print('[workflow] resumed after sleep'); + return 'finished'; + }); + }, + ); + final reportsGenerateRef = reportsGenerate.ref>( + encodeParams: (params) => params, ); - final runId = await app.startWorkflow( - 'reports.generate', - cancellationPolicy: const WorkflowCancellationPolicy( - maxRunDuration: Duration(minutes: 10), - maxSuspendDuration: Duration(seconds: 2), - ), + final app = await StemWorkflowApp.inMemory( + flows: [reportsGenerate], ); + final runId = await reportsGenerateRef + .call( + const {}, + cancellationPolicy: const WorkflowCancellationPolicy( + maxRunDuration: Duration(minutes: 10), + maxSuspendDuration: Duration(seconds: 2), + ), + ) + .startWithApp(app); + // Wait a bit longer than the policy allows so the auto-cancel can trigger. await Future.delayed(const Duration(seconds: 4)); diff --git a/packages/stem/example/workflows/custom_factories.dart b/packages/stem/example/workflows/custom_factories.dart index ee7a47b8..05ac4414 100644 --- a/packages/stem/example/workflows/custom_factories.dart +++ b/packages/stem/example/workflows/custom_factories.dart @@ -5,6 +5,15 @@ import 'package:stem/stem.dart'; import 'package:stem_redis/stem_redis.dart'; Future main() async { + final redisWorkflow = Flow( + name: 'redis.workflow', + build: (flow) { + flow.step('greet', (ctx) async => 'Redis-backed workflow'); + }, + ); + final redisWorkflowRef = redisWorkflow.ref>( + encodeParams: (params) => params, + ); final app = await StemWorkflowApp.fromUrl( 'redis://localhost:6379', adapters: const [StemRedisAdapter()], @@ -12,19 +21,12 @@ Future main() async { backend: 'redis://localhost:6379/1', workflow: 'redis://localhost:6379/2', ), - flows: [ - Flow( - name: 'redis.workflow', - build: (flow) { - flow.step('greet', (ctx) async => 'Redis-backed workflow'); - }, - ), - ], + flows: [redisWorkflow], ); try { - final runId = await app.startWorkflow('redis.workflow'); - final result = await app.waitForCompletion(runId); + final runId = await redisWorkflowRef.call(const {}).startWithApp(app); + final result = await redisWorkflowRef.waitFor(app, runId); print('Workflow $runId finished with result: ${result?.value}'); } finally { await app.close(); diff --git a/packages/stem/example/workflows/sleep_and_event.dart b/packages/stem/example/workflows/sleep_and_event.dart index bf076b8d..9e5bfa11 100644 --- a/packages/stem/example/workflows/sleep_and_event.dart +++ b/packages/stem/example/workflows/sleep_and_event.dart @@ -6,33 +6,36 @@ import 'dart:async'; import 'package:stem/stem.dart'; Future main() async { + final sleepAndEvent = Flow( + name: 'durable.sleep.event', + build: (flow) { + flow.step('initial', (ctx) async { + if (!ctx.sleepUntilResumed(const Duration(milliseconds: 200))) { + return null; + } + return 'awake'; + }); + + flow.step('await-event', (ctx) async { + final payload = ctx.waitForEventValue>( + 'demo.event', + ); + if (payload == null) { + return null; + } + return payload['message']; + }); + }, + ); + final sleepAndEventRef = sleepAndEvent.ref>( + encodeParams: (params) => params, + ); + final app = await StemWorkflowApp.inMemory( - flows: [ - Flow( - name: 'durable.sleep.event', - build: (flow) { - flow.step('initial', (ctx) async { - if (!ctx.sleepUntilResumed(const Duration(milliseconds: 200))) { - return null; - } - return 'awake'; - }); - - flow.step('await-event', (ctx) async { - final payload = ctx.waitForEventValue>( - 'demo.event', - ); - if (payload == null) { - return null; - } - return payload['message']; - }); - }, - ), - ], + flows: [sleepAndEvent], ); - final runId = await app.startWorkflow('durable.sleep.event'); + final runId = await sleepAndEventRef.call(const {}).startWithApp(app); // Wait until the workflow is suspended before emitting the event to avoid // losing the signal. @@ -46,7 +49,7 @@ Future main() async { await app.runtime.emit('demo.event', {'message': 'event received'}); - final result = await app.waitForCompletion(runId); + final result = await sleepAndEventRef.waitFor(app, runId); print('Workflow $runId resumed and completed with: ${result?.value}'); await app.close(); diff --git a/packages/stem/example/workflows/sqlite_store.dart b/packages/stem/example/workflows/sqlite_store.dart index f3cda604..d5112de1 100644 --- a/packages/stem/example/workflows/sqlite_store.dart +++ b/packages/stem/example/workflows/sqlite_store.dart @@ -8,22 +8,24 @@ import 'package:stem_sqlite/stem_sqlite.dart'; Future main() async { final databaseFile = File('workflow.sqlite'); + final sqliteExample = Flow( + name: 'sqlite.example', + build: (flow) { + flow.step('greet', (ctx) async => 'Persisted to SQLite'); + }, + ); + final sqliteExampleRef = sqliteExample.ref>( + encodeParams: (params) => params, + ); final app = await StemWorkflowApp.fromUrl( 'sqlite://${databaseFile.path}', adapters: const [StemSqliteAdapter()], - flows: [ - Flow( - name: 'sqlite.example', - build: (flow) { - flow.step('greet', (ctx) async => 'Persisted to SQLite'); - }, - ), - ], + flows: [sqliteExample], ); try { - final runId = await app.startWorkflow('sqlite.example'); - final result = await app.waitForCompletion(runId); + final runId = await sqliteExampleRef.call(const {}).startWithApp(app); + final result = await sqliteExampleRef.waitFor(app, runId); print('Workflow $runId finished with result: ${result?.value}'); } finally { await app.close(); diff --git a/packages/stem/example/workflows/versioned_rewind.dart b/packages/stem/example/workflows/versioned_rewind.dart index 2f5c3e40..2bd58c74 100644 --- a/packages/stem/example/workflows/versioned_rewind.dart +++ b/packages/stem/example/workflows/versioned_rewind.dart @@ -2,24 +2,26 @@ import 'package:stem/stem.dart'; Future main() async { final iterations = []; + final versionedWorkflow = Flow( + name: 'demo.versioned', + build: (flow) { + flow.step('repeat', (ctx) async { + iterations.add(ctx.iteration); + return 'iteration-${ctx.iteration}'; + }, autoVersion: true); - final app = await StemWorkflowApp.inMemory( - flows: [ - Flow( - name: 'demo.versioned', - build: (flow) { - flow.step('repeat', (ctx) async { - iterations.add(ctx.iteration); - return 'iteration-${ctx.iteration}'; - }, autoVersion: true); + flow.step('tail', (ctx) async => ctx.previousResult); + }, + ); + final versionedWorkflowRef = versionedWorkflow.ref>( + encodeParams: (params) => params, + ); - flow.step('tail', (ctx) async => ctx.previousResult); - }, - ), - ], + final app = await StemWorkflowApp.inMemory( + flows: [versionedWorkflow], ); - final runId = await app.startWorkflow('demo.versioned'); + final runId = await versionedWorkflowRef.call(const {}).startWithApp(app); await app.runtime.executeRun(runId); // Rewind and execute again to append a new iteration checkpoint. @@ -32,6 +34,8 @@ Future main() async { print('${entry.name}: ${entry.value}'); } print('Iterations executed: $iterations'); + final completed = await versionedWorkflowRef.waitFor(app, runId); + print('Final result: ${completed?.value}'); await app.close(); } From d8f0643b0de64a3b07d6ee6fdcd605be8e34d15c Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 17:04:16 -0500 Subject: [PATCH 014/302] Add no-args workflow refs --- .site/docs/workflows/flows-and-scripts.md | 4 ++ .site/docs/workflows/starting-and-waiting.md | 3 ++ packages/stem/CHANGELOG.md | 2 + packages/stem/README.md | 13 ++++-- .../example/docs_snippets/lib/workflows.dart | 6 +-- packages/stem/example/persistent_sleep.dart | 6 +-- .../example/workflows/basic_in_memory.dart | 6 +-- .../workflows/cancellation_policy.dart | 5 +-- .../example/workflows/custom_factories.dart | 6 +-- .../example/workflows/sleep_and_event.dart | 6 +-- .../stem/example/workflows/sqlite_store.dart | 6 +-- .../example/workflows/versioned_rewind.dart | 6 +-- .../stem/lib/src/bootstrap/workflow_app.dart | 34 +++++++++++++++ packages/stem/lib/src/workflow/core/flow.dart | 5 +++ .../workflow/core/workflow_definition.dart | 8 ++++ .../lib/src/workflow/core/workflow_ref.dart | 40 ++++++++++++++++++ .../src/workflow/core/workflow_script.dart | 5 +++ .../stem/test/bootstrap/stem_app_test.dart | 24 ++--------- .../workflow/workflow_runtime_ref_test.dart | 42 +++++++++++++++++++ .../lib/src/stem_registry_builder.dart | 22 ++++++---- .../test/stem_registry_builder_test.dart | 34 +++++++++++++++ 21 files changed, 218 insertions(+), 65 deletions(-) diff --git a/.site/docs/workflows/flows-and-scripts.md b/.site/docs/workflows/flows-and-scripts.md index 5af31fa2..87f40f30 100644 --- a/.site/docs/workflows/flows-and-scripts.md +++ b/.site/docs/workflows/flows-and-scripts.md @@ -35,6 +35,8 @@ final approvalsRef = approvalsFlow.ref<({Map draft})>( ); ``` +When a flow has no start params, prefer `flow.ref0()`. + Use `Flow` when: - the sequence of durable actions should be obvious from the definition @@ -55,6 +57,8 @@ final retryRef = retryScript.ref>( ); ``` +When a script has no start params, prefer `retryScript.ref0()`. + Use `WorkflowScript` when: - you want normal Dart control flow to define the run diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index bf742cba..ec56d5ae 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -38,6 +38,9 @@ final result = await approvalsRef.waitFor(workflowApp, runId); Use this path when you want the same typed start/wait surface as generated workflow refs, but the workflow itself is still hand-written. +For workflows without start params, derive `ref0()` instead and start them with +`.call()`. + ## Wait for completion `waitForCompletion` polls the store until the run finishes or the caller diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 8e410480..f18e6d89 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -16,6 +16,8 @@ strings or manual result decoder wiring. - Updated the manual workflow examples and docs to start runs through typed refs instead of repeating raw workflow-name strings in the happy path. +- Added `NoArgsWorkflowRef` plus `Flow.ref0()` / `WorkflowScript.ref0()` so + zero-input workflows can use `.call()` instead of passing `const {}`. - Added workflow manifests, runtime metadata views, and run/step drilldown APIs for inspecting workflow definitions and persisted execution state. - Clarified the workflow authoring model by distinguishing flow steps from diff --git a/packages/stem/README.md b/packages/stem/README.md index dbc80be8..c1fdb7cd 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -275,15 +275,13 @@ final demoWorkflow = Flow( }, ); -final demoWorkflowRef = demoWorkflow.ref>( - encodeParams: (params) => params, -); +final demoWorkflowRef = demoWorkflow.ref0(); final app = await StemWorkflowApp.inMemory( flows: [demoWorkflow], ); -final runId = await demoWorkflowRef.call(const {}).startWithApp(app); +final runId = await demoWorkflowRef.call().startWithApp(app); final result = await demoWorkflowRef.waitFor(app, runId); print(result?.value); // 'hello world' print(result?.state.status); // WorkflowStatus.completed @@ -412,6 +410,13 @@ print(result?.value); await app.close(); ``` +For workflows without start parameters, use `ref0()` and `call()`: + +```dart +final healthcheckRef = healthcheckFlow.ref0(); +final runId = await healthcheckRef.call().startWithApp(app); +``` + #### Manual `WorkflowScript` Use `WorkflowScript` when you want your workflow to read like a normal async diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index fab84e0a..d528fc06 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -250,14 +250,12 @@ Future main() async { flow.step('hello', (ctx) async => 'done'); }, ); - final demoFlowRef = demoFlow.ref>( - encodeParams: (params) => params, - ); + final demoFlowRef = demoFlow.ref0(); final app = await StemWorkflowApp.inMemory(flows: [demoFlow]); await app.start(); - final runId = await demoFlowRef.call(const {}).startWithApp(app); + final runId = await demoFlowRef.call().startWithApp(app); final result = await demoFlowRef.waitFor( app, runId, diff --git a/packages/stem/example/persistent_sleep.dart b/packages/stem/example/persistent_sleep.dart index ddad8bc3..1bdd245e 100644 --- a/packages/stem/example/persistent_sleep.dart +++ b/packages/stem/example/persistent_sleep.dart @@ -20,15 +20,13 @@ Future main() async { }); }, ); - final sleepLoopRef = sleepLoop.ref>( - encodeParams: (params) => params, - ); + final sleepLoopRef = sleepLoop.ref0(); final app = await StemWorkflowApp.inMemory( flows: [sleepLoop], ); - final runId = await sleepLoopRef.call(const {}).startWithApp(app); + final runId = await sleepLoopRef.call().startWithApp(app); await app.runtime.executeRun(runId); // After the delay elapses, the runtime should resume without the step diff --git a/packages/stem/example/workflows/basic_in_memory.dart b/packages/stem/example/workflows/basic_in_memory.dart index aafcb3ba..d3e19538 100644 --- a/packages/stem/example/workflows/basic_in_memory.dart +++ b/packages/stem/example/workflows/basic_in_memory.dart @@ -10,15 +10,13 @@ Future main() async { flow.step('greet', (ctx) async => 'Hello Stem'); }, ); - final basicHelloRef = basicHello.ref>( - encodeParams: (params) => params, - ); + final basicHelloRef = basicHello.ref0(); final app = await StemWorkflowApp.inMemory( flows: [basicHello], ); - final runId = await basicHelloRef.call(const {}).startWithApp(app); + final runId = await basicHelloRef.call().startWithApp(app); final result = await basicHelloRef.waitFor(app, runId); print('Workflow $runId finished with result: ${result?.value}'); diff --git a/packages/stem/example/workflows/cancellation_policy.dart b/packages/stem/example/workflows/cancellation_policy.dart index f6ac44a0..edd55916 100644 --- a/packages/stem/example/workflows/cancellation_policy.dart +++ b/packages/stem/example/workflows/cancellation_policy.dart @@ -24,9 +24,7 @@ Future main() async { }); }, ); - final reportsGenerateRef = reportsGenerate.ref>( - encodeParams: (params) => params, - ); + final reportsGenerateRef = reportsGenerate.ref0(); final app = await StemWorkflowApp.inMemory( flows: [reportsGenerate], @@ -34,7 +32,6 @@ Future main() async { final runId = await reportsGenerateRef .call( - const {}, cancellationPolicy: const WorkflowCancellationPolicy( maxRunDuration: Duration(minutes: 10), maxSuspendDuration: Duration(seconds: 2), diff --git a/packages/stem/example/workflows/custom_factories.dart b/packages/stem/example/workflows/custom_factories.dart index 05ac4414..91bbe60b 100644 --- a/packages/stem/example/workflows/custom_factories.dart +++ b/packages/stem/example/workflows/custom_factories.dart @@ -11,9 +11,7 @@ Future main() async { flow.step('greet', (ctx) async => 'Redis-backed workflow'); }, ); - final redisWorkflowRef = redisWorkflow.ref>( - encodeParams: (params) => params, - ); + final redisWorkflowRef = redisWorkflow.ref0(); final app = await StemWorkflowApp.fromUrl( 'redis://localhost:6379', adapters: const [StemRedisAdapter()], @@ -25,7 +23,7 @@ Future main() async { ); try { - final runId = await redisWorkflowRef.call(const {}).startWithApp(app); + final runId = await redisWorkflowRef.call().startWithApp(app); final result = await redisWorkflowRef.waitFor(app, runId); print('Workflow $runId finished with result: ${result?.value}'); } finally { diff --git a/packages/stem/example/workflows/sleep_and_event.dart b/packages/stem/example/workflows/sleep_and_event.dart index 9e5bfa11..36c0eaf8 100644 --- a/packages/stem/example/workflows/sleep_and_event.dart +++ b/packages/stem/example/workflows/sleep_and_event.dart @@ -27,15 +27,13 @@ Future main() async { }); }, ); - final sleepAndEventRef = sleepAndEvent.ref>( - encodeParams: (params) => params, - ); + final sleepAndEventRef = sleepAndEvent.ref0(); final app = await StemWorkflowApp.inMemory( flows: [sleepAndEvent], ); - final runId = await sleepAndEventRef.call(const {}).startWithApp(app); + final runId = await sleepAndEventRef.call().startWithApp(app); // Wait until the workflow is suspended before emitting the event to avoid // losing the signal. diff --git a/packages/stem/example/workflows/sqlite_store.dart b/packages/stem/example/workflows/sqlite_store.dart index d5112de1..74fabd51 100644 --- a/packages/stem/example/workflows/sqlite_store.dart +++ b/packages/stem/example/workflows/sqlite_store.dart @@ -14,9 +14,7 @@ Future main() async { flow.step('greet', (ctx) async => 'Persisted to SQLite'); }, ); - final sqliteExampleRef = sqliteExample.ref>( - encodeParams: (params) => params, - ); + final sqliteExampleRef = sqliteExample.ref0(); final app = await StemWorkflowApp.fromUrl( 'sqlite://${databaseFile.path}', adapters: const [StemSqliteAdapter()], @@ -24,7 +22,7 @@ Future main() async { ); try { - final runId = await sqliteExampleRef.call(const {}).startWithApp(app); + final runId = await sqliteExampleRef.call().startWithApp(app); final result = await sqliteExampleRef.waitFor(app, runId); print('Workflow $runId finished with result: ${result?.value}'); } finally { diff --git a/packages/stem/example/workflows/versioned_rewind.dart b/packages/stem/example/workflows/versioned_rewind.dart index 2bd58c74..d2d1238f 100644 --- a/packages/stem/example/workflows/versioned_rewind.dart +++ b/packages/stem/example/workflows/versioned_rewind.dart @@ -13,15 +13,13 @@ Future main() async { flow.step('tail', (ctx) async => ctx.previousResult); }, ); - final versionedWorkflowRef = versionedWorkflow.ref>( - encodeParams: (params) => params, - ); + final versionedWorkflowRef = versionedWorkflow.ref0(); final app = await StemWorkflowApp.inMemory( flows: [versionedWorkflow], ); - final runId = await versionedWorkflowRef.call(const {}).startWithApp(app); + final runId = await versionedWorkflowRef.call().startWithApp(app); await app.runtime.executeRun(runId); // Rewind and execute again to append a new iteration checkpoint. diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index 828358e0..b5d06986 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -677,3 +677,37 @@ extension WorkflowRefAppExtension ); } } + +/// Convenience helpers for waiting on workflow results using a no-args ref. +extension NoArgsWorkflowRefAppExtension + on NoArgsWorkflowRef { + /// Waits for [runId] using this workflow reference's decode rules. + Future?> waitFor( + StemWorkflowApp app, + String runId, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return asRef.waitFor( + app, + runId, + pollInterval: pollInterval, + timeout: timeout, + ); + } + + /// Waits for [runId] using this workflow reference and [runtime]. + Future?> waitForWithRuntime( + WorkflowRuntime runtime, + String runId, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return asRef.waitForWithRuntime( + runtime, + runId, + pollInterval: pollInterval, + timeout: timeout, + ); + } +} diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index 5908e0b2..a3301406 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -37,4 +37,9 @@ class Flow { }) { return definition.ref(encodeParams: encodeParams); } + + /// Builds a typed [NoArgsWorkflowRef] for flows without start params. + NoArgsWorkflowRef ref0() { + return definition.ref0(); + } } diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index 93dd79d8..b40a2fec 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -305,6 +305,14 @@ class WorkflowDefinition { ); } + /// Builds a typed [NoArgsWorkflowRef] from this definition. + NoArgsWorkflowRef ref0() { + return NoArgsWorkflowRef( + name: name, + decodeResult: (payload) => decodeResult(payload) as T, + ); + } + /// Stable identifier derived from immutable workflow definition fields. String get stableId { final basis = StringBuffer() diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 358fde04..e6f1010a 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -51,6 +51,46 @@ class WorkflowRef { } } +/// Typed producer-facing reference for workflows that take no input params. +class NoArgsWorkflowRef { + /// Creates a typed workflow reference for workflows without input params. + const NoArgsWorkflowRef({required this.name, this.decodeResult}); + + /// Registered workflow name. + final String name; + + /// Optional decoder for the final workflow result payload. + final TResult Function(Object? payload)? decodeResult; + + WorkflowRef<(), TResult> get _inner => WorkflowRef<(), TResult>( + name: name, + encodeParams: _encodeParams, + decodeResult: decodeResult, + ); + + /// Returns the underlying typed workflow ref used for waiting and dispatch. + WorkflowRef<(), TResult> get asRef => _inner; + + static Map _encodeParams(() _) => const {}; + + /// Builds a workflow start call without requiring an explicit empty payload. + WorkflowStartCall<(), TResult> call({ + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return asRef.call( + (), + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + + /// Decodes a final workflow result payload. + TResult decode(Object? payload) => asRef.decode(payload); +} + /// Shared typed workflow-start surface used by apps, runtimes, and contexts. abstract interface class WorkflowCaller { /// Starts a workflow from a typed [WorkflowRef]. diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index efdb8e6b..b694f2f3 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -39,4 +39,9 @@ class WorkflowScript { }) { return definition.ref(encodeParams: encodeParams); } + + /// Builds a typed [NoArgsWorkflowRef] for scripts without start params. + NoArgsWorkflowRef ref0() { + return definition.ref0(); + } } diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index bb4805ca..bcc17b40 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -610,19 +610,11 @@ void main() { ); }, ); - final workflowRef = WorkflowRef, _DemoPayload>( - name: 'workflow.codec.flow', - encodeParams: (params) => params, - decodeResult: _demoPayloadCodec.decode, - ); + final workflowRef = flow.ref0(); final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); try { - final runId = await workflowRef - .call(const {}) - .startWithApp( - workflowApp, - ); + final runId = await workflowRef.call().startWithApp(workflowApp); final result = await workflowRef.waitFor( workflowApp, runId, @@ -680,19 +672,11 @@ void main() { ); }, ); - final workflowRef = WorkflowRef, _DemoPayload>( - name: 'workflow.codec.script', - encodeParams: (params) => params, - decodeResult: _demoPayloadCodec.decode, - ); + final workflowRef = script.ref0(); final workflowApp = await StemWorkflowApp.inMemory(scripts: [script]); try { - final runId = await workflowRef - .call(const {}) - .startWithApp( - workflowApp, - ); + final runId = await workflowRef.call().startWithApp(workflowApp); final result = await workflowRef.waitFor( workflowApp, runId, diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 344456d3..3cd4ca75 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -78,5 +78,47 @@ void main() { await workflowApp.shutdown(); } }); + + test('manual workflows can derive no-args refs', () async { + final flow = Flow( + name: 'runtime.ref.no-args.flow', + build: (builder) { + builder.step('hello', (ctx) async => 'hello flow'); + }, + ); + final script = WorkflowScript( + name: 'runtime.ref.no-args.script', + run: (context) async => 'hello script', + ); + + final flowRef = flow.ref0(); + final scriptRef = script.ref0(); + + final workflowApp = await StemWorkflowApp.inMemory( + flows: [flow], + scripts: [script], + ); + try { + await workflowApp.start(); + + final flowResult = await flowRef + .call() + .startAndWaitWithRuntime( + workflowApp.runtime, + timeout: const Duration(seconds: 2), + ); + final scriptResult = await scriptRef + .call() + .startAndWaitWithRuntime( + workflowApp.runtime, + timeout: const Duration(seconds: 2), + ); + + expect(flowResult?.value, 'hello flow'); + expect(scriptResult?.value, 'hello script'); + } finally { + await workflowApp.shutdown(); + } + }); }); } diff --git a/packages/stem_builder/lib/src/stem_registry_builder.dart b/packages/stem_builder/lib/src/stem_registry_builder.dart index ef0fedbf..01d32132 100644 --- a/packages/stem_builder/lib/src/stem_registry_builder.dart +++ b/packages/stem_builder/lib/src/stem_registry_builder.dart @@ -1650,17 +1650,21 @@ class _RegistryEmitter { for (final workflow in workflows) { final fieldName = fieldNames[workflow]!; final argsTypeCode = _workflowArgsTypeCode(workflow); - buffer.writeln( - ' static final WorkflowRef<$argsTypeCode, ${workflow.resultTypeCode}> ' - '$fieldName = WorkflowRef<$argsTypeCode, ${workflow.resultTypeCode}>(', - ); + final isNoArgsScript = + workflow.kind == WorkflowKind.script && + workflow.runValueParameters.isEmpty; + final refType = + isNoArgsScript + ? 'NoArgsWorkflowRef<${workflow.resultTypeCode}>' + : 'WorkflowRef<$argsTypeCode, ${workflow.resultTypeCode}>'; + final constructorType = + isNoArgsScript + ? 'NoArgsWorkflowRef<${workflow.resultTypeCode}>' + : 'WorkflowRef<$argsTypeCode, ${workflow.resultTypeCode}>'; + buffer.writeln(' static final $refType $fieldName = $constructorType('); buffer.writeln(' name: ${_string(workflow.name)},'); if (workflow.kind == WorkflowKind.script) { - if (workflow.runValueParameters.isEmpty) { - buffer.writeln( - ' encodeParams: (_) => const {},', - ); - } else { + if (workflow.runValueParameters.isNotEmpty) { buffer.writeln(' encodeParams: (params) => {'); for (final parameter in workflow.runValueParameters) { buffer.writeln( diff --git a/packages/stem_builder/test/stem_registry_builder_test.dart b/packages/stem_builder/test/stem_registry_builder_test.dart index bf3d1aa7..82bfe6bc 100644 --- a/packages/stem_builder/test/stem_registry_builder_test.dart +++ b/packages/stem_builder/test/stem_registry_builder_test.dart @@ -198,6 +198,7 @@ Future sendEmail( contains('StemWorkflowDefinitions'), contains('StemTaskDefinitions'), contains('WorkflowRef, String>'), + contains('NoArgsWorkflowRef'), contains('Flow('), contains('WorkflowScript('), contains('stemModule = StemModule('), @@ -296,6 +297,39 @@ class DailyBillingWorkflow { }, ); + test('uses NoArgsWorkflowRef for zero-argument script workflows', () async { + const input = ''' +import 'package:stem/stem.dart'; + +part 'workflows.stem.g.dart'; + +@WorkflowDefn(kind: WorkflowKind.script) +class HelloScriptWorkflow { + @WorkflowRun() + Future run() async => 'done'; +} +'''; + + await testBuilder( + stemRegistryBuilder(BuilderOptions.empty), + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + outputs: { + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( + allOf([ + contains('static final NoArgsWorkflowRef helloScriptWorkflow ='), + contains('NoArgsWorkflowRef('), + ]), + ), + }, + ); + }); + test( 'generates script workflow step proxies for direct method calls', () async { From fb5caf49a59c88fb9e03d14dc51f8dde2eb186c1 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 17:12:43 -0500 Subject: [PATCH 015/302] Add no-args task definitions --- .site/docs/core-concepts/tasks.md | 4 ++ packages/stem/README.md | 11 +++ packages/stem/lib/src/core/contracts.dart | 68 +++++++++++++++++++ packages/stem/lib/src/core/stem.dart | 13 ++++ .../stem/test/unit/core/stem_core_test.dart | 39 +++++++++++ .../unit/core/task_enqueue_builder_test.dart | 14 ++++ .../lib/src/stem_registry_builder.dart | 18 +++-- .../test/stem_registry_builder_test.dart | 50 +++++++++++++- 8 files changed, 210 insertions(+), 7 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index eafaaf01..ec72223c 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -49,6 +49,10 @@ Typed results flow through `TaskResult` when you call `Canvas.chord`. Supplying a custom `decode` callback on the task signature lets you deserialize complex objects before they reach application code. +For tasks with no producer inputs, use `TaskDefinition.noArgs(...)` +instead. That gives you a `.call()` helper without passing a fake empty map and +the same `waitFor(...)` decoding surface as normal typed definitions. + ## Configuring Retries Workers apply an `ExponentialJitterRetryStrategy` by default. Each retry is diff --git a/packages/stem/README.md b/packages/stem/README.md index c1fdb7cd..52c84cca 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -218,6 +218,17 @@ final taskId = await HelloTask.definition final result = await HelloTask.definition.waitFor(stem, taskId); ``` +For tasks without producer inputs, use `TaskDefinition.noArgs(...)` so callers +can publish with `.call()` instead of passing a fake empty map: + +```dart +final healthcheckDefinition = TaskDefinition.noArgs( + name: 'demo.healthcheck', +); + +await stem.enqueueCall(healthcheckDefinition.call()); +``` + You can also build requests fluently with the `TaskEnqueueBuilder`: ```dart diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 4975f2a4..def32151 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2000,6 +2000,21 @@ class TaskDefinition { }) : _encodeArgs = encodeArgs, _encodeMeta = encodeMeta; + /// Creates a typed task definition for handlers with no producer args. + static NoArgsTaskDefinition noArgs({ + required String name, + TaskOptions defaultOptions = const TaskOptions(), + TaskMetadata metadata = const TaskMetadata(), + TaskResultDecoder? decodeResult, + }) { + return NoArgsTaskDefinition( + name: name, + defaultOptions: defaultOptions, + metadata: metadata, + decodeResult: decodeResult, + ); + } + /// The logical task name registered in the registry. final String name; @@ -2058,6 +2073,59 @@ class TaskDefinition { } } +/// Typed producer-facing definition for tasks that take no input args. +class NoArgsTaskDefinition { + /// Creates a typed task definition for handlers with no producer args. + const NoArgsTaskDefinition({ + required this.name, + this.defaultOptions = const TaskOptions(), + this.metadata = const TaskMetadata(), + this.decodeResult, + }); + + /// The logical task name registered in the registry. + final String name; + + /// Default options applied to every call unless overridden. + final TaskOptions defaultOptions; + + /// Metadata associated with this task for documentation/tooling. + final TaskMetadata metadata; + + /// Optional decoder for converting persisted payloads into a typed result. + final TaskResultDecoder? decodeResult; + + /// The underlying task definition for generic enqueue/wait surfaces. + TaskDefinition<(), TResult> get asDefinition => TaskDefinition<(), TResult>( + name: name, + encodeArgs: (_) => const {}, + defaultOptions: defaultOptions, + metadata: metadata, + decodeResult: decodeResult, + ); + + /// Builds a typed call without requiring an explicit empty payload. + TaskCall<(), TResult> call({ + Map headers = const {}, + TaskOptions? options, + DateTime? notBefore, + Map? meta, + TaskEnqueueOptions? enqueueOptions, + }) { + return asDefinition.call( + (), + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ); + } + + /// Decodes a persisted payload into a typed result. + TResult? decode(Object? payload) => asDefinition.decode(payload); +} + /// Represents a pending enqueue operation built from a [TaskDefinition]. class TaskCall { const TaskCall._({ diff --git a/packages/stem/lib/src/core/stem.dart b/packages/stem/lib/src/core/stem.dart index 715f8b4a..4cd1d243 100644 --- a/packages/stem/lib/src/core/stem.dart +++ b/packages/stem/lib/src/core/stem.dart @@ -1067,3 +1067,16 @@ extension TaskDefinitionExtension return stem.waitForTaskDefinition(taskId, this, timeout: timeout); } } + +/// Convenience helpers for waiting on typed no-arg task definitions. +extension NoArgsTaskDefinitionExtension + on NoArgsTaskDefinition { + /// Waits for [taskId] using this definition's decoding rules. + Future?> waitFor( + Stem stem, + String taskId, { + Duration? timeout, + }) { + return stem.waitForTaskDefinition(taskId, asDefinition, timeout: timeout); + } +} diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index 3c00a4f5..1c681877 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -137,6 +137,28 @@ void main() { expect(backend.records.single.id, id); }, ); + + test( + 'enqueueCall publishes no-arg definitions without fake empty maps', + () async { + final broker = _RecordingBroker(); + final backend = _RecordingBackend(); + final stem = Stem(broker: broker, backend: backend); + final definition = TaskDefinition.noArgs( + name: 'sample.no_args', + defaultOptions: const TaskOptions(queue: 'typed'), + ); + + final id = await stem.enqueueCall(definition.call()); + + expect(id, isNotEmpty); + expect(broker.published.single.envelope.name, 'sample.no_args'); + expect(broker.published.single.envelope.queue, 'typed'); + expect(broker.published.single.envelope.args, isEmpty); + expect(backend.records.single.id, id); + expect(backend.records.single.state, TaskState.queued); + }, + ); }); group('TaskCall helpers', () { @@ -211,6 +233,23 @@ void main() { expect(result?.value?.id, 'receipt-definition'); expect(result?.rawPayload, isA<_CodecReceipt>()); }); + + test('supports no-arg task definitions', () async { + final backend = InMemoryResultBackend(); + final stem = Stem(broker: _RecordingBroker(), backend: backend); + final definition = TaskDefinition.noArgs(name: 'no-args.wait'); + + await backend.set( + 'task-no-args-wait', + TaskState.succeeded, + payload: 'done', + ); + + final result = await definition.waitFor(stem, 'task-no-args-wait'); + + expect(result?.value, 'done'); + expect(result?.rawPayload, 'done'); + }); }); group('Stem.waitForTaskDefinition', () { diff --git a/packages/stem/test/unit/core/task_enqueue_builder_test.dart b/packages/stem/test/unit/core/task_enqueue_builder_test.dart index 9760d079..09348754 100644 --- a/packages/stem/test/unit/core/task_enqueue_builder_test.dart +++ b/packages/stem/test/unit/core/task_enqueue_builder_test.dart @@ -82,4 +82,18 @@ void main() { expect(updated.headers['h2'], 'v2'); expect(updated.meta['m2'], 2); }); + + test('NoArgsTaskDefinition.call encodes an empty payload', () { + final definition = TaskDefinition.noArgs(name: 'demo.no_args'); + + final call = definition.call( + headers: const {'h': 'v'}, + meta: const {'m': 1}, + ); + + expect(call.name, 'demo.no_args'); + expect(call.encodeArgs(), isEmpty); + expect(call.headers, containsPair('h', 'v')); + expect(call.meta, containsPair('m', 1)); + }); } diff --git a/packages/stem_builder/lib/src/stem_registry_builder.dart b/packages/stem_builder/lib/src/stem_registry_builder.dart index 01d32132..f4e58d42 100644 --- a/packages/stem_builder/lib/src/stem_registry_builder.dart +++ b/packages/stem_builder/lib/src/stem_registry_builder.dart @@ -1921,15 +1921,21 @@ class _RegistryEmitter { for (final task in tasks) { final symbol = _lowerCamel(symbolNames[task]!); final argsTypeCode = _taskArgsTypeCode(task); - buffer.writeln( - ' static final TaskDefinition<$argsTypeCode, ${task.resultTypeCode}> $symbol = TaskDefinition<$argsTypeCode, ${task.resultTypeCode}>(', - ); + final usesNoArgsDefinition = + !task.usesLegacyMapArgs && task.valueParameters.isEmpty; + if (usesNoArgsDefinition) { + buffer.writeln( + ' static final NoArgsTaskDefinition<${task.resultTypeCode}> $symbol = NoArgsTaskDefinition<${task.resultTypeCode}>(', + ); + } else { + buffer.writeln( + ' static final TaskDefinition<$argsTypeCode, ${task.resultTypeCode}> $symbol = TaskDefinition<$argsTypeCode, ${task.resultTypeCode}>(', + ); + } buffer.writeln(' name: ${_string(task.name)},'); if (task.usesLegacyMapArgs) { buffer.writeln(' encodeArgs: (args) => args,'); - } else if (task.valueParameters.isEmpty) { - buffer.writeln(' encodeArgs: (args) => const {},'); - } else { + } else if (task.valueParameters.isNotEmpty) { buffer.writeln(' encodeArgs: (args) => {'); for (final parameter in task.valueParameters) { buffer.writeln( diff --git a/packages/stem_builder/test/stem_registry_builder_test.dart b/packages/stem_builder/test/stem_registry_builder_test.dart index 82bfe6bc..658c58c7 100644 --- a/packages/stem_builder/test/stem_registry_builder_test.dart +++ b/packages/stem_builder/test/stem_registry_builder_test.dart @@ -65,6 +65,20 @@ class WorkflowScriptContext { class WorkflowScriptStepContext {} class TaskInvocationContext {} +class NoArgsTaskDefinition { + const NoArgsTaskDefinition({ + required this.name, + this.defaultOptions = const TaskOptions(), + this.metadata = const TaskMetadata(), + this.decodeResult, + }); + + final String name; + final TaskOptions defaultOptions; + final TaskMetadata metadata; + final T Function(Object? payload)? decodeResult; +} + class TaskOptions { const TaskOptions({this.maxRetries = 0}); final int maxRetries; @@ -322,7 +336,10 @@ class HelloScriptWorkflow { outputs: { 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( allOf([ - contains('static final NoArgsWorkflowRef helloScriptWorkflow ='), + contains( + 'static final NoArgsWorkflowRef ' + 'helloScriptWorkflow =', + ), contains('NoArgsWorkflowRef('), ]), ), @@ -330,6 +347,37 @@ class HelloScriptWorkflow { ); }); + test('uses NoArgsTaskDefinition for zero-argument tasks', () async { + const input = ''' +import 'package:stem/stem.dart'; + +part 'workflows.stem.g.dart'; + +@TaskDefn(name: 'ping.task') +Future pingTask() async => 'pong'; +'''; + + await testBuilder( + stemRegistryBuilder(BuilderOptions.empty), + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + outputs: { + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( + allOf([ + contains('static final NoArgsTaskDefinition pingTask ='), + contains('NoArgsTaskDefinition('), + isNot(contains('encodeArgs: (args) => const {}')), + ]), + ), + }, + ); + }); + test( 'generates script workflow step proxies for direct method calls', () async { From 5ef925adefd6611c16db8ff847fe337683aed60b Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 17:18:31 -0500 Subject: [PATCH 016/302] Add workflow child-call context helpers --- .site/docs/workflows/annotated-workflows.md | 4 ++-- packages/stem/README.md | 4 ++-- .../example/annotated_workflows/README.md | 4 ++-- .../annotated_workflows/lib/definitions.dart | 4 ++-- .../lib/src/workflow/core/flow_context.dart | 3 ++- .../lib/src/workflow/core/workflow_ref.dart | 17 +++++++++++++ .../core/workflow_script_context.dart | 3 ++- .../test/unit/workflow/flow_context_test.dart | 24 +++++++++++++++++++ .../test/workflow/workflow_runtime_test.dart | 8 +++---- 9 files changed, 57 insertions(+), 14 deletions(-) diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index d219148e..7a9a7f93 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -140,9 +140,9 @@ This keeps one authoring model: When a workflow needs to start another workflow, do it from a durable boundary: -- `StemWorkflowDefinitions.someWorkflow.call(...).startWith(context.workflows!)` +- `StemWorkflowDefinitions.someWorkflow.call(...).startWithContext(context)` inside flow steps -- `StemWorkflowDefinitions.someWorkflow.call(...).startWith(context.workflows!)` +- `StemWorkflowDefinitions.someWorkflow.call(...).startWithContext(context)` inside checkpoint methods Avoid starting child workflows from the raw `WorkflowScriptContext` body. diff --git a/packages/stem/README.md b/packages/stem/README.md index 52c84cca..b31b18c9 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -527,10 +527,10 @@ Context injection works at every runtime layer: Child workflows belong in durable execution boundaries: - use - `StemWorkflowDefinitions.someWorkflow.call(...).startWith(context.workflows!)` + `StemWorkflowDefinitions.someWorkflow.call(...).startWithContext(context)` inside flow steps - use - `StemWorkflowDefinitions.someWorkflow.call(...).startWith(context.workflows!)` + `StemWorkflowDefinitions.someWorkflow.call(...).startWithContext(context)` inside script checkpoints - do not start child workflows from the raw `WorkflowScriptContext` body unless you are deliberately managing replay/idempotency yourself diff --git a/packages/stem/example/annotated_workflows/README.md b/packages/stem/example/annotated_workflows/README.md index c1baf6e8..75554d4f 100644 --- a/packages/stem/example/annotated_workflows/README.md +++ b/packages/stem/example/annotated_workflows/README.md @@ -6,7 +6,7 @@ with the `stem_builder` bundle generator. It now demonstrates the generated script-proxy behavior explicitly: - a flow step using `FlowContext` - a flow step starting a child workflow through - `StemWorkflowDefinitions.*.call(...).startWith(context.workflows!)` + `StemWorkflowDefinitions.*.call(...).startWithContext(context)` - `run(WelcomeRequest request)` calls annotated checkpoint methods directly - `prepareWelcome(...)` calls other annotated checkpoints - `deliverWelcome(...)` calls another annotated checkpoint from inside an @@ -15,7 +15,7 @@ It now demonstrates the generated script-proxy behavior explicitly: (`WorkflowScriptContext? context` / `WorkflowScriptStepContext? context`) to expose `runId`, `workflow`, `stepName`, `stepIndex`, and idempotency keys - a script checkpoint starting a child workflow through - `StemWorkflowDefinitions.*.call(...).startWith(context.workflows!)` + `StemWorkflowDefinitions.*.call(...).startWithContext(context)` - a plain script workflow that returns a codec-backed DTO result and persists a codec-backed DTO checkpoint value - a typed `@TaskDefn` using optional named `TaskInvocationContext? context` diff --git a/packages/stem/example/annotated_workflows/lib/definitions.dart b/packages/stem/example/annotated_workflows/lib/definitions.dart index 4dc5de8d..60bedaec 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.dart @@ -190,7 +190,7 @@ class AnnotatedFlowWorkflow { } final childRunId = await StemWorkflowDefinitions.script .call((request: const WelcomeRequest(email: 'flow-child@example.com'))) - .startWith(ctx.workflows!); + .startWithContext(ctx); return { 'workflow': ctx.workflow, 'runId': ctx.runId, @@ -271,7 +271,7 @@ class AnnotatedContextScriptWorkflow { final subject = await buildWelcomeSubject(normalizedEmail); final childRunId = await StemWorkflowDefinitions.script .call((request: WelcomeRequest(email: normalizedEmail))) - .startWith(ctx.workflows!); + .startWithContext(ctx); return ContextCaptureResult( workflow: ctx.workflow, runId: ctx.runId, diff --git a/packages/stem/lib/src/workflow/core/flow_context.dart b/packages/stem/lib/src/workflow/core/flow_context.dart index e985327e..b11d4c11 100644 --- a/packages/stem/lib/src/workflow/core/flow_context.dart +++ b/packages/stem/lib/src/workflow/core/flow_context.dart @@ -14,7 +14,7 @@ import 'package:stem/src/workflow/core/workflow_ref.dart'; /// [iteration] indicates how many times the step has already completed when /// `autoVersion` is enabled, allowing handlers to branch per loop iteration or /// derive unique identifiers. -class FlowContext { +class FlowContext implements WorkflowChildCallerContext { /// Creates a workflow step context. FlowContext({ required this.workflow, @@ -56,6 +56,7 @@ class FlowContext { final TaskEnqueuer? enqueuer; /// Optional typed workflow caller for spawning child workflows. + @override final WorkflowCaller? workflows; final WorkflowClock _clock; diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index e6f1010a..994fea18 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -108,6 +108,12 @@ abstract interface class WorkflowCaller { ); } +/// Shared contract for contexts that can spawn child workflows. +abstract interface class WorkflowChildCallerContext { + /// Optional typed workflow caller for spawning child workflows. + WorkflowCaller? get workflows; +} + /// Typed start request built from a [WorkflowRef]. class WorkflowStartCall { const WorkflowStartCall._({ @@ -162,4 +168,15 @@ extension WorkflowStartCallExtension Future startWith(WorkflowCaller caller) { return caller.startWorkflowCall(this); } + + /// Starts this typed workflow call with a workflow child-caller [context]. + Future startWithContext(WorkflowChildCallerContext context) { + final caller = context.workflows; + if (caller == null) { + throw StateError( + 'This workflow context does not support starting child workflows.', + ); + } + return startWith(caller); + } } diff --git a/packages/stem/lib/src/workflow/core/workflow_script_context.dart b/packages/stem/lib/src/workflow/core/workflow_script_context.dart index ef4cfb0a..58842f7b 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script_context.dart @@ -29,7 +29,7 @@ abstract class WorkflowScriptContext { /// Context provided to each script checkpoint invocation. Mirrors /// [FlowContext] but tailored for the facade helpers. -abstract class WorkflowScriptStepContext { +abstract class WorkflowScriptStepContext implements WorkflowChildCallerContext { /// Name of the workflow currently executing. String get workflow; @@ -73,5 +73,6 @@ abstract class WorkflowScriptStepContext { TaskEnqueuer? get enqueuer; /// Optional typed workflow caller for spawning child workflows. + @override WorkflowCaller? get workflows; } diff --git a/packages/stem/test/unit/workflow/flow_context_test.dart b/packages/stem/test/unit/workflow/flow_context_test.dart index cbee779e..545145bb 100644 --- a/packages/stem/test/unit/workflow/flow_context_test.dart +++ b/packages/stem/test/unit/workflow/flow_context_test.dart @@ -1,6 +1,7 @@ import 'package:stem/src/workflow/core/flow_context.dart'; import 'package:stem/src/workflow/core/flow_step.dart'; import 'package:stem/src/workflow/core/workflow_clock.dart'; +import 'package:stem/src/workflow/core/workflow_ref.dart'; import 'package:test/test.dart'; void main() { @@ -72,4 +73,27 @@ void main() { expect(context.idempotencyKey('custom'), 'demo/run-3/custom'); }, ); + + test( + 'startWithContext throws when child workflow support is unavailable', + () { + final context = FlowContext( + workflow: 'demo', + runId: 'run-4', + stepName: 'spawn', + params: const {}, + previousResult: null, + stepIndex: 0, + ); + final childRef = WorkflowRef, String>( + name: 'child.flow', + encodeParams: (params) => params, + ); + + expect( + () => childRef.call(const {'value': 'x'}).startWithContext(context), + throwsStateError, + ); + }, + ); } diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index 94373e00..6f641c25 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -155,7 +155,7 @@ void main() { flow.step('spawn', (context) async { return childRef .call(const {'value': 'spawned'}) - .startWith(context.workflows!); + .startWithContext(context); }); }, ).definition, @@ -165,7 +165,7 @@ void main() { await runtime.executeRun(parentRunId); final parentState = await store.get(parentRunId); - final childRunId = parentState!.result as String; + final childRunId = parentState!.result! as String; final childState = await store.get(childRunId); expect(childState, isNotNull); @@ -202,7 +202,7 @@ void main() { return script.step('spawn', (context) async { return childRef .call(const {'value': 'script-child'}) - .startWith(context.workflows!); + .startWithContext(context); }); }, ).definition, @@ -212,7 +212,7 @@ void main() { await runtime.executeRun(parentRunId); final parentState = await store.get(parentRunId); - final childRunId = parentState!.result as String; + final childRunId = parentState!.result! as String; final childState = await store.get(childRunId); expect(childState, isNotNull); From caf1f894574f489d9b1d20dffebe647067d6717a Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 17:21:26 -0500 Subject: [PATCH 017/302] Add direct no-args workflow start helpers --- .site/docs/workflows/flows-and-scripts.md | 6 +- .site/docs/workflows/starting-and-waiting.md | 4 +- packages/stem/README.md | 7 +- .../example/docs_snippets/lib/workflows.dart | 2 +- packages/stem/example/persistent_sleep.dart | 2 +- .../example/workflows/basic_in_memory.dart | 2 +- .../example/workflows/custom_factories.dart | 2 +- .../example/workflows/sleep_and_event.dart | 2 +- .../stem/example/workflows/sqlite_store.dart | 2 +- .../example/workflows/versioned_rewind.dart | 2 +- .../stem/lib/src/bootstrap/workflow_app.dart | 76 +++++++++++++++++++ .../lib/src/workflow/core/workflow_ref.dart | 28 +++++++ .../workflow/workflow_runtime_ref_test.dart | 20 ++--- 13 files changed, 129 insertions(+), 26 deletions(-) diff --git a/.site/docs/workflows/flows-and-scripts.md b/.site/docs/workflows/flows-and-scripts.md index 87f40f30..6774d008 100644 --- a/.site/docs/workflows/flows-and-scripts.md +++ b/.site/docs/workflows/flows-and-scripts.md @@ -35,7 +35,8 @@ final approvalsRef = approvalsFlow.ref<({Map draft})>( ); ``` -When a flow has no start params, prefer `flow.ref0()`. +When a flow has no start params, prefer `flow.ref0()` and start directly from +that no-args ref. Use `Flow` when: @@ -57,7 +58,8 @@ final retryRef = retryScript.ref>( ); ``` -When a script has no start params, prefer `retryScript.ref0()`. +When a script has no start params, prefer `retryScript.ref0()` and start +directly from that no-args ref. Use `WorkflowScript` when: diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index ec56d5ae..32a58631 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -38,8 +38,8 @@ final result = await approvalsRef.waitFor(workflowApp, runId); Use this path when you want the same typed start/wait surface as generated workflow refs, but the workflow itself is still hand-written. -For workflows without start params, derive `ref0()` instead and start them with -`.call()`. +For workflows without start params, derive `ref0()` instead and start them +directly from the no-args ref. ## Wait for completion diff --git a/packages/stem/README.md b/packages/stem/README.md index b31b18c9..6e4e1cf8 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -292,7 +292,7 @@ final app = await StemWorkflowApp.inMemory( flows: [demoWorkflow], ); -final runId = await demoWorkflowRef.call().startWithApp(app); +final runId = await demoWorkflowRef.startWithApp(app); final result = await demoWorkflowRef.waitFor(app, runId); print(result?.value); // 'hello world' print(result?.state.status); // WorkflowStatus.completed @@ -421,11 +421,12 @@ print(result?.value); await app.close(); ``` -For workflows without start parameters, use `ref0()` and `call()`: +For workflows without start parameters, use `ref0()` and start directly from +the no-args ref: ```dart final healthcheckRef = healthcheckFlow.ref0(); -final runId = await healthcheckRef.call().startWithApp(app); +final runId = await healthcheckRef.startWithApp(app); ``` #### Manual `WorkflowScript` diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index d528fc06..3ef6fcb1 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -255,7 +255,7 @@ Future main() async { final app = await StemWorkflowApp.inMemory(flows: [demoFlow]); await app.start(); - final runId = await demoFlowRef.call().startWithApp(app); + final runId = await demoFlowRef.startWithApp(app); final result = await demoFlowRef.waitFor( app, runId, diff --git a/packages/stem/example/persistent_sleep.dart b/packages/stem/example/persistent_sleep.dart index 1bdd245e..bae3eda8 100644 --- a/packages/stem/example/persistent_sleep.dart +++ b/packages/stem/example/persistent_sleep.dart @@ -26,7 +26,7 @@ Future main() async { flows: [sleepLoop], ); - final runId = await sleepLoopRef.call().startWithApp(app); + final runId = await sleepLoopRef.startWithApp(app); await app.runtime.executeRun(runId); // After the delay elapses, the runtime should resume without the step diff --git a/packages/stem/example/workflows/basic_in_memory.dart b/packages/stem/example/workflows/basic_in_memory.dart index d3e19538..52adfb4e 100644 --- a/packages/stem/example/workflows/basic_in_memory.dart +++ b/packages/stem/example/workflows/basic_in_memory.dart @@ -16,7 +16,7 @@ Future main() async { flows: [basicHello], ); - final runId = await basicHelloRef.call().startWithApp(app); + final runId = await basicHelloRef.startWithApp(app); final result = await basicHelloRef.waitFor(app, runId); print('Workflow $runId finished with result: ${result?.value}'); diff --git a/packages/stem/example/workflows/custom_factories.dart b/packages/stem/example/workflows/custom_factories.dart index 91bbe60b..339af6b7 100644 --- a/packages/stem/example/workflows/custom_factories.dart +++ b/packages/stem/example/workflows/custom_factories.dart @@ -23,7 +23,7 @@ Future main() async { ); try { - final runId = await redisWorkflowRef.call().startWithApp(app); + final runId = await redisWorkflowRef.startWithApp(app); final result = await redisWorkflowRef.waitFor(app, runId); print('Workflow $runId finished with result: ${result?.value}'); } finally { diff --git a/packages/stem/example/workflows/sleep_and_event.dart b/packages/stem/example/workflows/sleep_and_event.dart index 36c0eaf8..fafa5f2a 100644 --- a/packages/stem/example/workflows/sleep_and_event.dart +++ b/packages/stem/example/workflows/sleep_and_event.dart @@ -33,7 +33,7 @@ Future main() async { flows: [sleepAndEvent], ); - final runId = await sleepAndEventRef.call().startWithApp(app); + final runId = await sleepAndEventRef.startWithApp(app); // Wait until the workflow is suspended before emitting the event to avoid // losing the signal. diff --git a/packages/stem/example/workflows/sqlite_store.dart b/packages/stem/example/workflows/sqlite_store.dart index 74fabd51..cccfaf56 100644 --- a/packages/stem/example/workflows/sqlite_store.dart +++ b/packages/stem/example/workflows/sqlite_store.dart @@ -22,7 +22,7 @@ Future main() async { ); try { - final runId = await sqliteExampleRef.call().startWithApp(app); + final runId = await sqliteExampleRef.startWithApp(app); final result = await sqliteExampleRef.waitFor(app, runId); print('Workflow $runId finished with result: ${result?.value}'); } finally { diff --git a/packages/stem/example/workflows/versioned_rewind.dart b/packages/stem/example/workflows/versioned_rewind.dart index d2d1238f..8310ebdb 100644 --- a/packages/stem/example/workflows/versioned_rewind.dart +++ b/packages/stem/example/workflows/versioned_rewind.dart @@ -19,7 +19,7 @@ Future main() async { flows: [versionedWorkflow], ); - final runId = await versionedWorkflowRef.call().startWithApp(app); + final runId = await versionedWorkflowRef.startWithApp(app); await app.runtime.executeRun(runId); // Rewind and execute again to append a new iteration checkpoint. diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index b5d06986..6ad8b246 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -681,6 +681,82 @@ extension WorkflowRefAppExtension /// Convenience helpers for waiting on workflow results using a no-args ref. extension NoArgsWorkflowRefAppExtension on NoArgsWorkflowRef { + /// Starts this no-args workflow ref with [app]. + Future startWithApp( + StemWorkflowApp app, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return startWith( + app, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + + /// Starts this no-args workflow ref with [app] and waits for the result. + Future?> startAndWaitWithApp( + StemWorkflowApp app, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) async { + final runId = await startWithApp( + app, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + return waitFor( + app, + runId, + pollInterval: pollInterval, + timeout: timeout, + ); + } + + /// Starts this no-args workflow ref with [runtime]. + Future startWithRuntime( + WorkflowRuntime runtime, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return startWith( + runtime, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + + /// Starts this no-args workflow ref with [runtime] and waits for the result. + Future?> startAndWaitWithRuntime( + WorkflowRuntime runtime, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) async { + final runId = await startWithRuntime( + runtime, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + return waitForWithRuntime( + runtime, + runId, + pollInterval: pollInterval, + timeout: timeout, + ); + } + /// Waits for [runId] using this workflow reference's decode rules. Future?> waitFor( StemWorkflowApp app, diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 994fea18..70b2c784 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -87,6 +87,34 @@ class NoArgsWorkflowRef { ); } + /// Starts this workflow ref directly with [caller]. + Future startWith( + WorkflowCaller caller, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return call( + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ).startWith(caller); + } + + /// Starts this workflow ref directly with a workflow child-caller [context]. + Future startWithContext( + WorkflowChildCallerContext context, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return call( + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ).startWithContext(context); + } + /// Decodes a final workflow result payload. TResult decode(Object? payload) => asRef.decode(payload); } diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 3cd4ca75..4678c40b 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -101,18 +101,14 @@ void main() { try { await workflowApp.start(); - final flowResult = await flowRef - .call() - .startAndWaitWithRuntime( - workflowApp.runtime, - timeout: const Duration(seconds: 2), - ); - final scriptResult = await scriptRef - .call() - .startAndWaitWithRuntime( - workflowApp.runtime, - timeout: const Duration(seconds: 2), - ); + final flowResult = await flowRef.startAndWaitWithApp( + workflowApp, + timeout: const Duration(seconds: 2), + ); + final scriptResult = await scriptRef.startAndWaitWithRuntime( + workflowApp.runtime, + timeout: const Duration(seconds: 2), + ); expect(flowResult?.value, 'hello flow'); expect(scriptResult?.value, 'hello script'); From 8eb254fdd84e1add57eaa9d56c0299787adeaa75 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 17:23:08 -0500 Subject: [PATCH 018/302] Add direct no-args task enqueue helpers --- .site/docs/core-concepts/tasks.md | 5 ++- packages/stem/README.md | 4 +- packages/stem/lib/src/core/stem.dart | 39 +++++++++++++++++++ .../stem/test/unit/core/stem_core_test.dart | 27 ++++++++++++- 4 files changed, 70 insertions(+), 5 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index ec72223c..57e162b2 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -50,8 +50,9 @@ Typed results flow through `TaskResult` when you call lets you deserialize complex objects before they reach application code. For tasks with no producer inputs, use `TaskDefinition.noArgs(...)` -instead. That gives you a `.call()` helper without passing a fake empty map and -the same `waitFor(...)` decoding surface as normal typed definitions. +instead. That gives you direct `enqueueWith(...)` / +`enqueueAndWaitWith(...)` helpers without passing a fake empty map and the same +`waitFor(...)` decoding surface as normal typed definitions. ## Configuring Retries diff --git a/packages/stem/README.md b/packages/stem/README.md index 6e4e1cf8..56c9766e 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -219,14 +219,14 @@ final result = await HelloTask.definition.waitFor(stem, taskId); ``` For tasks without producer inputs, use `TaskDefinition.noArgs(...)` so callers -can publish with `.call()` instead of passing a fake empty map: +can publish directly instead of passing a fake empty map: ```dart final healthcheckDefinition = TaskDefinition.noArgs( name: 'demo.healthcheck', ); -await stem.enqueueCall(healthcheckDefinition.call()); +await healthcheckDefinition.enqueueWith(stem); ``` You can also build requests fluently with the `TaskEnqueueBuilder`: diff --git a/packages/stem/lib/src/core/stem.dart b/packages/stem/lib/src/core/stem.dart index 4cd1d243..2cc07bc3 100644 --- a/packages/stem/lib/src/core/stem.dart +++ b/packages/stem/lib/src/core/stem.dart @@ -1071,6 +1071,24 @@ extension TaskDefinitionExtension /// Convenience helpers for waiting on typed no-arg task definitions. extension NoArgsTaskDefinitionExtension on NoArgsTaskDefinition { + /// Enqueues this no-arg task definition with [stem]. + Future enqueueWith( + Stem stem, { + Map headers = const {}, + TaskOptions? options, + DateTime? notBefore, + Map? meta, + TaskEnqueueOptions? enqueueOptions, + }) { + return call( + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ).enqueueWith(stem, enqueueOptions: enqueueOptions); + } + /// Waits for [taskId] using this definition's decoding rules. Future?> waitFor( Stem stem, @@ -1079,4 +1097,25 @@ extension NoArgsTaskDefinitionExtension }) { return stem.waitForTaskDefinition(taskId, asDefinition, timeout: timeout); } + + /// Enqueues this no-arg task definition and waits for the typed result. + Future?> enqueueAndWaitWith( + Stem stem, { + Map headers = const {}, + TaskOptions? options, + DateTime? notBefore, + Map? meta, + TaskEnqueueOptions? enqueueOptions, + Duration? timeout, + }) async { + final taskId = await enqueueWith( + stem, + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ); + return waitFor(stem, taskId, timeout: timeout); + } } diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index 1c681877..b8b9ba16 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -149,7 +149,7 @@ void main() { defaultOptions: const TaskOptions(queue: 'typed'), ); - final id = await stem.enqueueCall(definition.call()); + final id = await definition.enqueueWith(stem); expect(id, isNotEmpty); expect(broker.published.single.envelope.name, 'sample.no_args'); @@ -250,6 +250,31 @@ void main() { expect(result?.value, 'done'); expect(result?.rawPayload, 'done'); }); + + test('enqueueAndWaitWith supports no-arg task definitions', () async { + final broker = _RecordingBroker(); + final backend = _RecordingBackend(); + final stem = Stem(broker: broker, backend: backend); + final definition = TaskDefinition.noArgs(name: 'no-args.enqueue'); + + unawaited( + Future(() async { + while (broker.published.isEmpty) { + await Future.delayed(Duration.zero); + } + final taskId = broker.published.single.envelope.id; + await backend.set(taskId, TaskState.succeeded, payload: 'done'); + }), + ); + + final result = await definition.enqueueAndWaitWith( + stem, + timeout: const Duration(seconds: 1), + ); + + expect(result?.value, 'done'); + expect(result?.rawPayload, 'done'); + }); }); group('Stem.waitForTaskDefinition', () { From 71e798c170db93ebe499bf6f63ba3736ae2b9c2c Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 17:26:20 -0500 Subject: [PATCH 019/302] Use TaskEnqueuer for no-args task helpers --- packages/stem/lib/src/core/stem.dart | 6 +-- .../unit/core/task_enqueue_builder_test.dart | 48 ++++++++++++++++++- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/packages/stem/lib/src/core/stem.dart b/packages/stem/lib/src/core/stem.dart index 2cc07bc3..77dc9c59 100644 --- a/packages/stem/lib/src/core/stem.dart +++ b/packages/stem/lib/src/core/stem.dart @@ -1071,9 +1071,9 @@ extension TaskDefinitionExtension /// Convenience helpers for waiting on typed no-arg task definitions. extension NoArgsTaskDefinitionExtension on NoArgsTaskDefinition { - /// Enqueues this no-arg task definition with [stem]. + /// Enqueues this no-arg task definition with [enqueuer]. Future enqueueWith( - Stem stem, { + TaskEnqueuer enqueuer, { Map headers = const {}, TaskOptions? options, DateTime? notBefore, @@ -1086,7 +1086,7 @@ extension NoArgsTaskDefinitionExtension notBefore: notBefore, meta: meta, enqueueOptions: enqueueOptions, - ).enqueueWith(stem, enqueueOptions: enqueueOptions); + ).enqueueWith(enqueuer, enqueueOptions: enqueueOptions); } /// Waits for [taskId] using this definition's decoding rules. diff --git a/packages/stem/test/unit/core/task_enqueue_builder_test.dart b/packages/stem/test/unit/core/task_enqueue_builder_test.dart index 09348754..fc936223 100644 --- a/packages/stem/test/unit/core/task_enqueue_builder_test.dart +++ b/packages/stem/test/unit/core/task_enqueue_builder_test.dart @@ -1,4 +1,4 @@ -import 'package:stem/src/core/contracts.dart'; +import 'package:stem/stem.dart'; import 'package:test/test.dart'; void main() { @@ -96,4 +96,50 @@ void main() { expect(call.headers, containsPair('h', 'v')); expect(call.meta, containsPair('m', 1)); }); + + test( + 'NoArgsTaskDefinition.enqueueWith uses the TaskEnqueuer surface', + () async { + final definition = TaskDefinition.noArgs(name: 'demo.no_args'); + final enqueuer = _RecordingTaskEnqueuer(); + + final taskId = await definition.enqueueWith( + enqueuer, + headers: const {'h': 'v'}, + meta: const {'m': 1}, + ); + + expect(taskId, 'task-1'); + expect(enqueuer.lastCall, isNotNull); + expect(enqueuer.lastCall!.name, 'demo.no_args'); + expect(enqueuer.lastCall!.encodeArgs(), isEmpty); + expect(enqueuer.lastCall!.headers, containsPair('h', 'v')); + expect(enqueuer.lastCall!.meta, containsPair('m', 1)); + }, + ); +} + +class _RecordingTaskEnqueuer implements TaskEnqueuer { + TaskCall? lastCall; + + @override + Future enqueue( + String name, { + Map args = const {}, + Map headers = const {}, + TaskOptions options = const TaskOptions(), + Map meta = const {}, + TaskEnqueueOptions? enqueueOptions, + }) { + throw UnimplementedError('enqueue is not used in this test'); + } + + @override + Future enqueueCall( + TaskCall call, { + TaskEnqueueOptions? enqueueOptions, + }) async { + lastCall = call; + return 'task-1'; + } } From cd44f1e7856c876444efb1eb40fb74556a7ad4d0 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 17:29:06 -0500 Subject: [PATCH 020/302] Add no-args task to stem_builder example --- packages/stem_builder/example/README.md | 2 ++ packages/stem_builder/example/bin/main.dart | 10 ++++++++++ .../stem_builder/example/lib/definitions.dart | 3 +++ .../example/lib/definitions.stem.g.dart | 19 +++++++++++++++++++ 4 files changed, 34 insertions(+) diff --git a/packages/stem_builder/example/README.md b/packages/stem_builder/example/README.md index 276ea477..075bfbc2 100644 --- a/packages/stem_builder/example/README.md +++ b/packages/stem_builder/example/README.md @@ -9,6 +9,8 @@ This example demonstrates: - `StemWorkflowDefinitions.userSignup.call(...).startWithRuntime(runtime)` - Generated typed task definitions that use the shared `TaskCall` / `TaskDefinition.waitFor(...)` APIs +- Generated zero-arg task definitions with direct helpers: + - `StemTaskDefinitions.builderExamplePing.enqueueAndWaitWith(stem)` - Generated workflow manifest via `stemModule.workflowManifest` - Running generated definitions through `StemWorkflowApp` - Runtime manifest + run/step metadata views via `WorkflowRuntime` diff --git a/packages/stem_builder/example/bin/main.dart b/packages/stem_builder/example/bin/main.dart index 0d14ada8..baa036ba 100644 --- a/packages/stem_builder/example/bin/main.dart +++ b/packages/stem_builder/example/bin/main.dart @@ -39,4 +39,14 @@ Future main() async { } finally { await app.close(); } + + final taskApp = await StemApp.inMemory(module: stemModule); + try { + await taskApp.start(); + final taskResult = await StemTaskDefinitions.builderExamplePing + .enqueueAndWaitWith(taskApp.stem, timeout: const Duration(seconds: 2)); + print('\nNo-arg task result: ${taskResult?.value}'); + } finally { + await taskApp.shutdown(); + } } diff --git a/packages/stem_builder/example/lib/definitions.dart b/packages/stem_builder/example/lib/definitions.dart index b8980ede..ac0f464e 100644 --- a/packages/stem_builder/example/lib/definitions.dart +++ b/packages/stem_builder/example/lib/definitions.dart @@ -36,3 +36,6 @@ Future builderExampleTask( TaskInvocationContext context, Map args, ) async {} + +@TaskDefn(name: 'builder.example.ping') +Future builderPingTask() async => 'pong'; diff --git a/packages/stem_builder/example/lib/definitions.stem.g.dart b/packages/stem_builder/example/lib/definitions.stem.g.dart index 23e8e58b..46e86744 100644 --- a/packages/stem_builder/example/lib/definitions.stem.g.dart +++ b/packages/stem_builder/example/lib/definitions.stem.g.dart @@ -92,6 +92,13 @@ Object? _stemRequireArg(Map args, String name) { return args[name]; } +Future _stemTaskAdapter0( + TaskInvocationContext context, + Map args, +) async { + return await Future.value(builderPingTask()); +} + abstract final class StemTaskDefinitions { static final TaskDefinition, Object?> builderExampleTask = TaskDefinition, Object?>( @@ -100,6 +107,12 @@ abstract final class StemTaskDefinitions { defaultOptions: const TaskOptions(), metadata: const TaskMetadata(), ); + static final NoArgsTaskDefinition builderExamplePing = + NoArgsTaskDefinition( + name: "builder.example.ping", + defaultOptions: const TaskOptions(), + metadata: const TaskMetadata(), + ); } final List> _stemTasks = >[ @@ -109,6 +122,12 @@ final List> _stemTasks = >[ options: const TaskOptions(), metadata: const TaskMetadata(), ), + FunctionTaskHandler( + name: "builder.example.ping", + entrypoint: _stemTaskAdapter0, + options: const TaskOptions(), + metadata: const TaskMetadata(), + ), ]; final List _stemWorkflowManifest = From 6fb3dbe0a2eeedb37a73dd842fa05fe3ba70b7ae Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 17:35:54 -0500 Subject: [PATCH 021/302] Add child workflow start-and-wait helpers --- .site/docs/workflows/annotated-workflows.md | 4 +- packages/stem/README.md | 4 +- .../example/annotated_workflows/README.md | 10 +- .../example/annotated_workflows/bin/main.dart | 23 +--- .../annotated_workflows/lib/definitions.dart | 20 ++- .../lib/src/workflow/core/workflow_ref.dart | 121 ++++++++++++++++++ .../workflow/runtime/workflow_runtime.dart | 53 ++++++++ .../test/unit/workflow/flow_context_test.dart | 55 +++++--- .../test/workflow/workflow_runtime_test.dart | 100 +++++++++++++++ packages/stem_builder/README.md | 4 +- 10 files changed, 344 insertions(+), 50 deletions(-) diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index 7a9a7f93..7e0c1d3a 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -140,9 +140,9 @@ This keeps one authoring model: When a workflow needs to start another workflow, do it from a durable boundary: -- `StemWorkflowDefinitions.someWorkflow.call(...).startWithContext(context)` +- `StemWorkflowDefinitions.someWorkflow.call(...).startAndWaitWithContext(context)` inside flow steps -- `StemWorkflowDefinitions.someWorkflow.call(...).startWithContext(context)` +- `StemWorkflowDefinitions.someWorkflow.call(...).startAndWaitWithContext(context)` inside checkpoint methods Avoid starting child workflows from the raw `WorkflowScriptContext` body. diff --git a/packages/stem/README.md b/packages/stem/README.md index 56c9766e..0233286c 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -528,10 +528,10 @@ Context injection works at every runtime layer: Child workflows belong in durable execution boundaries: - use - `StemWorkflowDefinitions.someWorkflow.call(...).startWithContext(context)` + `StemWorkflowDefinitions.someWorkflow.call(...).startAndWaitWithContext(context)` inside flow steps - use - `StemWorkflowDefinitions.someWorkflow.call(...).startWithContext(context)` + `StemWorkflowDefinitions.someWorkflow.call(...).startAndWaitWithContext(context)` inside script checkpoints - do not start child workflows from the raw `WorkflowScriptContext` body unless you are deliberately managing replay/idempotency yourself diff --git a/packages/stem/example/annotated_workflows/README.md b/packages/stem/example/annotated_workflows/README.md index 75554d4f..8a716809 100644 --- a/packages/stem/example/annotated_workflows/README.md +++ b/packages/stem/example/annotated_workflows/README.md @@ -5,8 +5,8 @@ with the `stem_builder` bundle generator. It now demonstrates the generated script-proxy behavior explicitly: - a flow step using `FlowContext` -- a flow step starting a child workflow through - `StemWorkflowDefinitions.*.call(...).startWithContext(context)` +- a flow step starting and waiting on a child workflow through + `StemWorkflowDefinitions.*.call(...).startAndWaitWithContext(context)` - `run(WelcomeRequest request)` calls annotated checkpoint methods directly - `prepareWelcome(...)` calls other annotated checkpoints - `deliverWelcome(...)` calls another annotated checkpoint from inside an @@ -14,8 +14,8 @@ It now demonstrates the generated script-proxy behavior explicitly: - a second script workflow uses optional named context injection (`WorkflowScriptContext? context` / `WorkflowScriptStepContext? context`) to expose `runId`, `workflow`, `stepName`, `stepIndex`, and idempotency keys -- a script checkpoint starting a child workflow through - `StemWorkflowDefinitions.*.call(...).startWithContext(context)` +- a script checkpoint starting and waiting on a child workflow through + `StemWorkflowDefinitions.*.call(...).startAndWaitWithContext(context)` - a plain script workflow that returns a codec-backed DTO result and persists a codec-backed DTO checkpoint value - a typed `@TaskDefn` using optional named `TaskInvocationContext? context` @@ -23,12 +23,14 @@ It now demonstrates the generated script-proxy behavior explicitly: When you run the example, it prints: - the flow result with `FlowContext` metadata +- the flow child workflow result without a separate `waitFor(...)` call - the plain script result - the persisted checkpoint order for the plain script workflow - the persisted JSON form of the plain script DTO checkpoint and DTO result - the context-aware script result with workflow metadata - the persisted JSON form of the context-aware DTO result - the persisted checkpoint order for the context-aware workflow +- the context child workflow result without a separate `waitFor(...)` call - the typed task result showing a decoded DTO result and task invocation metadata diff --git a/packages/stem/example/annotated_workflows/bin/main.dart b/packages/stem/example/annotated_workflows/bin/main.dart index f5d4815b..b795478e 100644 --- a/packages/stem/example/annotated_workflows/bin/main.dart +++ b/packages/stem/example/annotated_workflows/bin/main.dart @@ -17,18 +17,10 @@ Future main() async { timeout: const Duration(seconds: 2), ); print('Flow result: ${jsonEncode(flowResult?.value)}'); - final flowChildRunId = flowResult?.value?['childRunId'] as String?; - if (flowChildRunId != null) { - final flowChildResult = await StemWorkflowDefinitions.script.waitFor( - app, - flowChildRunId, - timeout: const Duration(seconds: 2), - ); - print( - 'Flow child workflow result: ' - '${jsonEncode(flowChildResult?.value?.toJson())}', - ); - } + print( + 'Flow child workflow result: ' + '${jsonEncode(flowResult?.value?['childResult'])}', + ); final scriptCall = StemWorkflowDefinitions.script.call(( request: const WelcomeRequest(email: ' SomeEmail@Example.com '), @@ -71,14 +63,9 @@ Future main() async { print('Context script checkpoints: $contextCheckpoints'); print('Persisted context result: ${jsonEncode(contextDetail?.run.result)}'); print('Context script detail: ${jsonEncode(contextDetail?.toJson())}'); - final contextChildResult = await StemWorkflowDefinitions.script.waitFor( - app, - contextResult.value!.childRunId, - timeout: const Duration(seconds: 2), - ); print( 'Context child workflow result: ' - '${jsonEncode(contextChildResult?.value?.toJson())}', + '${jsonEncode(contextResult.value!.childResult.toJson())}', ); final typedTaskId = await StemTaskDefinitions.sendEmailTyped diff --git a/packages/stem/example/annotated_workflows/lib/definitions.dart b/packages/stem/example/annotated_workflows/lib/definitions.dart index 60bedaec..dd80527a 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.dart @@ -141,6 +141,7 @@ class ContextCaptureResult { required this.normalizedEmail, required this.subject, required this.childRunId, + required this.childResult, }); final String workflow; @@ -152,6 +153,7 @@ class ContextCaptureResult { final String normalizedEmail; final String subject; final String childRunId; + final WelcomeWorkflowResult childResult; Map toJson() => { 'workflow': workflow, @@ -163,6 +165,7 @@ class ContextCaptureResult { 'normalizedEmail': normalizedEmail, 'subject': subject, 'childRunId': childRunId, + 'childResult': childResult.toJson(), }; factory ContextCaptureResult.fromJson(Map json) { @@ -176,6 +179,9 @@ class ContextCaptureResult { normalizedEmail: json['normalizedEmail'] as String, subject: json['subject'] as String, childRunId: json['childRunId'] as String, + childResult: WelcomeWorkflowResult.fromJson( + Map.from(json['childResult'] as Map), + ), ); } } @@ -188,9 +194,9 @@ class AnnotatedFlowWorkflow { if (!ctx.sleepUntilResumed(const Duration(milliseconds: 50))) { return null; } - final childRunId = await StemWorkflowDefinitions.script + final childResult = await StemWorkflowDefinitions.script .call((request: const WelcomeRequest(email: 'flow-child@example.com'))) - .startWithContext(ctx); + .startAndWaitWithContext(ctx, timeout: const Duration(seconds: 2)); return { 'workflow': ctx.workflow, 'runId': ctx.runId, @@ -198,7 +204,8 @@ class AnnotatedFlowWorkflow { 'stepIndex': ctx.stepIndex, 'iteration': ctx.iteration, 'idempotencyKey': ctx.idempotencyKey(), - 'childRunId': childRunId, + 'childRunId': childResult?.runId, + 'childResult': childResult?.value?.toJson(), }; } } @@ -269,9 +276,9 @@ class AnnotatedContextScriptWorkflow { final ctx = context!; final normalizedEmail = await normalizeEmail(request.email); final subject = await buildWelcomeSubject(normalizedEmail); - final childRunId = await StemWorkflowDefinitions.script + final childResult = await StemWorkflowDefinitions.script .call((request: WelcomeRequest(email: normalizedEmail))) - .startWithContext(ctx); + .startAndWaitWithContext(ctx, timeout: const Duration(seconds: 2)); return ContextCaptureResult( workflow: ctx.workflow, runId: ctx.runId, @@ -281,7 +288,8 @@ class AnnotatedContextScriptWorkflow { idempotencyKey: ctx.idempotencyKey('welcome'), normalizedEmail: normalizedEmail, subject: subject, - childRunId: childRunId, + childRunId: childResult!.runId, + childResult: childResult.value!, ); } diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 70b2c784..1df901b8 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -1,4 +1,5 @@ import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; +import 'package:stem/src/workflow/core/workflow_result.dart'; /// Typed producer-facing reference to a registered workflow. /// @@ -115,8 +116,64 @@ class NoArgsWorkflowRef { ).startWithContext(context); } + /// Starts this workflow ref with [caller] and waits for the result. + Future?> startAndWaitWith( + WorkflowCaller caller, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return call( + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ).startAndWaitWith( + caller, + pollInterval: pollInterval, + timeout: timeout, + ); + } + + /// Starts this workflow ref with a workflow child-caller [context] and waits + /// for the result. + Future?> startAndWaitWithContext( + WorkflowChildCallerContext context, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return call( + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ).startAndWaitWithContext( + context, + pollInterval: pollInterval, + timeout: timeout, + ); + } + /// Decodes a final workflow result payload. TResult decode(Object? payload) => asRef.decode(payload); + + /// Waits for [runId] using this workflow reference's decode rules. + Future?> waitForWith( + WorkflowCaller caller, + String runId, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return asRef.waitForWith( + caller, + runId, + pollInterval: pollInterval, + timeout: timeout, + ); + } } /// Shared typed workflow-start surface used by apps, runtimes, and contexts. @@ -134,6 +191,15 @@ abstract interface class WorkflowCaller { Future startWorkflowCall( WorkflowStartCall call, ); + + /// Waits for [runId] using the decoding rules from a [WorkflowRef]. + Future?> + waitForWorkflowRef( + String runId, + WorkflowRef definition, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }); } /// Shared contract for contexts that can spawn child workflows. @@ -207,4 +273,59 @@ extension WorkflowStartCallExtension } return startWith(caller); } + + /// Starts this typed workflow call with [caller] and waits for the result. + Future?> startAndWaitWith( + WorkflowCaller caller, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) async { + final runId = await startWith(caller); + return definition.waitForWith( + caller, + runId, + pollInterval: pollInterval, + timeout: timeout, + ); + } + + /// Starts this typed workflow call with a workflow child-caller [context] + /// and waits for the result. + Future?> startAndWaitWithContext( + WorkflowChildCallerContext context, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + final caller = context.workflows; + if (caller == null) { + throw StateError( + 'This workflow context does not support starting child workflows.', + ); + } + return startAndWaitWith( + caller, + pollInterval: pollInterval, + timeout: timeout, + ); + } +} + +/// Convenience helpers for waiting on typed workflow refs using a generic +/// [WorkflowCaller]. +extension WorkflowRefExtension + on WorkflowRef { + /// Waits for [runId] using this workflow reference's decode rules. + Future?> waitForWith( + WorkflowCaller caller, + String runId, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return caller.waitForWorkflowRef( + runId, + this, + pollInterval: pollInterval, + timeout: timeout, + ); + } } diff --git a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart index 057aac88..7f495adc 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart @@ -1944,6 +1944,59 @@ class _ChildWorkflowCaller implements WorkflowCaller { call.copyWith(parentRunId: parentRunId), ); } + + @override + Future?> + waitForWorkflowRef( + String runId, + WorkflowRef definition, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return _waitForChildWorkflow( + runId, + definition, + pollInterval: pollInterval, + timeout: timeout, + ); + } + + Future?> _waitForChildWorkflow< + TParams, + TResult extends Object? + >( + String runId, + WorkflowRef definition, { + required Duration pollInterval, + required Duration? timeout, + }) async { + final startedAt = runtime.clock.now(); + while (true) { + final state = await runtime._store.get(runId); + if (state == null) { + return null; + } + if (state.isTerminal) { + return runtime._buildResult( + state, + definition.decode, + timedOut: false, + ); + } + if (timeout != null && + runtime.clock.now().difference(startedAt) >= timeout) { + return runtime._buildResult( + state, + definition.decode, + timedOut: true, + ); + } + await runtime.executeRun(runId); + if (pollInterval > Duration.zero) { + await Future.delayed(pollInterval); + } + } + } } Map _coerceEventPayload(String topic, Object? payload) { diff --git a/packages/stem/test/unit/workflow/flow_context_test.dart b/packages/stem/test/unit/workflow/flow_context_test.dart index 545145bb..bc92d28e 100644 --- a/packages/stem/test/unit/workflow/flow_context_test.dart +++ b/packages/stem/test/unit/workflow/flow_context_test.dart @@ -77,23 +77,46 @@ void main() { test( 'startWithContext throws when child workflow support is unavailable', () { - final context = FlowContext( - workflow: 'demo', - runId: 'run-4', - stepName: 'spawn', - params: const {}, - previousResult: null, - stepIndex: 0, - ); - final childRef = WorkflowRef, String>( - name: 'child.flow', - encodeParams: (params) => params, - ); + final context = FlowContext( + workflow: 'demo', + runId: 'run-4', + stepName: 'spawn', + params: const {}, + previousResult: null, + stepIndex: 0, + ); + final childRef = WorkflowRef, String>( + name: 'child.flow', + encodeParams: (params) => params, + ); - expect( - () => childRef.call(const {'value': 'x'}).startWithContext(context), - throwsStateError, - ); + expect( + () => childRef.call(const {'value': 'x'}).startWithContext(context), + throwsStateError, + ); + }, + ); + + test( + 'startAndWaitWithContext throws when child workflow support is unavailable', + () { + final context = FlowContext( + workflow: 'demo', + runId: 'run-5', + stepName: 'spawn', + params: const {}, + previousResult: null, + stepIndex: 0, + ); + final childRef = WorkflowRef, String>( + name: 'child.flow', + encodeParams: (params) => params, + ); + + expect( + () => childRef.call(const {'value': 'x'}).startAndWaitWithContext(context), + throwsStateError, + ); }, ); } diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index 6f641c25..053a3133 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -221,6 +221,106 @@ void main() { expect(childState.workflowParams, equals(const {'value': 'script-child'})); }); + test('flow context workflows startAndWaitWithContext waits for child result', () async { + final childRef = WorkflowRef, String>( + name: 'child.runtime.wait.flow', + encodeParams: (params) => params, + ); + + runtime + ..registerWorkflow( + Flow( + name: 'child.runtime.wait.flow', + build: (flow) { + flow.step('hello', (context) async { + final value = context.params['value'] as String? ?? 'child'; + return 'ok:$value'; + }); + }, + ).definition, + ) + ..registerWorkflow( + Flow( + name: 'parent.runtime.wait.flow', + build: (flow) { + flow.step('spawn', (context) async { + final childResult = await childRef + .call(const {'value': 'spawned'}) + .startAndWaitWithContext( + context, + timeout: const Duration(seconds: 2), + ); + return { + 'childRunId': childResult?.runId, + 'childValue': childResult?.value, + }; + }); + }, + ).definition, + ); + + final parentRunId = await runtime.startWorkflow('parent.runtime.wait.flow'); + await runtime.executeRun(parentRunId); + + final parentState = await store.get(parentRunId); + final result = Map.from(parentState!.result! as Map); + expect(result['childRunId'], isA()); + expect(result['childValue'], 'ok:spawned'); + }); + + test( + 'script checkpoint workflows startAndWaitWithContext waits for child result', + () async { + final childRef = WorkflowRef, String>( + name: 'child.runtime.wait.script', + encodeParams: (params) => params, + ); + + runtime + ..registerWorkflow( + Flow( + name: 'child.runtime.wait.script', + build: (flow) { + flow.step('hello', (context) async { + final value = context.params['value'] as String? ?? 'child'; + return 'ok:$value'; + }); + }, + ).definition, + ) + ..registerWorkflow( + WorkflowScript>( + name: 'parent.runtime.wait.script', + checkpoints: [WorkflowCheckpoint(name: 'spawn')], + run: (script) async { + return script.step>('spawn', (context) async { + final childResult = await childRef + .call(const {'value': 'script-child'}) + .startAndWaitWithContext( + context, + timeout: const Duration(seconds: 2), + ); + return { + 'childRunId': childResult?.runId, + 'childValue': childResult?.value, + }; + }); + }, + ).definition, + ); + + final parentRunId = await runtime.startWorkflow( + 'parent.runtime.wait.script', + ); + await runtime.executeRun(parentRunId); + + final parentState = await store.get(parentRunId); + final result = Map.from(parentState!.result! as Map); + expect(result['childRunId'], isA()); + expect(result['childValue'], 'ok:script-child'); + }, + ); + test('viewRunDetail exposes uniform run and checkpoint views', () async { runtime.registerWorkflow( Flow( diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index d1a4c5fe..dcd52f2a 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -105,9 +105,9 @@ Supported context injection points: Child workflows should be started from durable boundaries: -- `StemWorkflowDefinitions.someWorkflow.call(...).startWith(context.workflows!)` +- `StemWorkflowDefinitions.someWorkflow.call(...).startAndWaitWithContext(context)` inside flow steps -- `StemWorkflowDefinitions.someWorkflow.call(...).startWith(context.workflows!)` +- `StemWorkflowDefinitions.someWorkflow.call(...).startAndWaitWithContext(context)` inside script checkpoints Avoid starting child workflows directly from the raw From 82bf39933516b6dbb801956ea9ff9299397a2d38 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 17:45:51 -0500 Subject: [PATCH 022/302] Add direct generated task enqueue helpers --- .site/docs/workflows/annotated-workflows.md | 20 ++--- packages/stem/README.md | 21 +++-- .../example/annotated_workflows/bin/main.dart | 23 ++---- .../lib/definitions.stem.g.dart | 42 ++++++++++ packages/stem/example/ecommerce/README.md | 2 +- .../src/workflows/annotated_defs.stem.g.dart | 49 ++++++++++++ packages/stem_builder/README.md | 5 +- packages/stem_builder/example/README.md | 2 +- packages/stem_builder/example/bin/main.dart | 6 +- .../example/lib/definitions.stem.g.dart | 37 +++++++++ .../lib/src/stem_registry_builder.dart | 76 +++++++++++++++++++ .../test/stem_registry_builder_test.dart | 44 +++++++++++ 12 files changed, 281 insertions(+), 46 deletions(-) diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index 7e0c1d3a..eae91a45 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -55,20 +55,14 @@ final result = await StemWorkflowDefinitions.userSignup Annotated tasks use the same shared typed task surface: ```dart -final taskId = await StemTaskDefinitions.sendEmailTyped - .call(( - dispatch: EmailDispatch( - email: 'typed@example.com', - subject: 'Welcome', - body: 'Codec-backed DTO payloads', - tags: ['welcome'], - ), - )) - .enqueueWith(workflowApp.app.stem); - -final result = await StemTaskDefinitions.sendEmailTyped.waitFor( +final result = await StemTaskDefinitions.enqueueAndWaitSendEmailTyped( workflowApp.app.stem, - taskId, + dispatch: EmailDispatch( + email: 'typed@example.com', + subject: 'Welcome', + body: 'Codec-backed DTO payloads', + tags: ['welcome'], + ), ); ``` diff --git a/packages/stem/README.md b/packages/stem/README.md index 0233286c..bc39a105 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -721,18 +721,15 @@ if (charge?.isSucceeded == true) { Generated annotated tasks use the same surface: ```dart -final taskId = await StemTaskDefinitions.sendEmailTyped - .call(( - dispatch: EmailDispatch( - email: 'typed@example.com', - subject: 'Welcome', - body: 'Codec-backed DTO payloads', - tags: ['welcome'], - ), - )) - .enqueueWith(stem); - -final receipt = await StemTaskDefinitions.sendEmailTyped.waitFor(stem, taskId); +final receipt = await StemTaskDefinitions.enqueueAndWaitSendEmailTyped( + stem, + dispatch: EmailDispatch( + email: 'typed@example.com', + subject: 'Welcome', + body: 'Codec-backed DTO payloads', + tags: ['welcome'], + ), +); print(receipt?.value?.deliveryId); ``` diff --git a/packages/stem/example/annotated_workflows/bin/main.dart b/packages/stem/example/annotated_workflows/bin/main.dart index b795478e..34e58644 100644 --- a/packages/stem/example/annotated_workflows/bin/main.dart +++ b/packages/stem/example/annotated_workflows/bin/main.dart @@ -68,22 +68,15 @@ Future main() async { '${jsonEncode(contextResult.value!.childResult.toJson())}', ); - final typedTaskId = await StemTaskDefinitions.sendEmailTyped - .call( - ( - dispatch: const EmailDispatch( - email: 'typed@example.com', - subject: 'Welcome', - body: 'Codec-backed DTO payloads', - tags: ['welcome', 'transactional', 'annotated'], - ), - ), - meta: const {'origin': 'annotated_workflows_example'}, - ) - .enqueueWith(app.app.stem); - final typedTaskResult = await StemTaskDefinitions.sendEmailTyped.waitFor( + final typedTaskResult = await StemTaskDefinitions.enqueueAndWaitSendEmailTyped( app.app.stem, - typedTaskId, + dispatch: const EmailDispatch( + email: 'typed@example.com', + subject: 'Welcome', + body: 'Codec-backed DTO payloads', + tags: ['welcome', 'transactional', 'annotated'], + ), + meta: const {'origin': 'annotated_workflows_example'}, timeout: const Duration(seconds: 2), ); print('Typed task result: ${jsonEncode(typedTaskResult?.value?.toJson())}'); diff --git a/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart b/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart index 806a805a..4f7b8383 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart @@ -292,6 +292,48 @@ abstract final class StemTaskDefinitions { metadata: const TaskMetadata(), decodeResult: StemPayloadCodecs.emailDeliveryReceipt.decode, ); + static Future enqueueSendEmailTyped( + TaskEnqueuer enqueuer, { + required EmailDispatch dispatch, + Map headers = const {}, + TaskOptions? options, + DateTime? notBefore, + Map? meta, + TaskEnqueueOptions? enqueueOptions, + }) { + return sendEmailTyped + .call( + (dispatch: dispatch), + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ) + .enqueueWith(enqueuer, enqueueOptions: enqueueOptions); + } + + static Future?> enqueueAndWaitSendEmailTyped( + Stem stem, { + required EmailDispatch dispatch, + Map headers = const {}, + TaskOptions? options, + DateTime? notBefore, + Map? meta, + TaskEnqueueOptions? enqueueOptions, + Duration? timeout, + }) async { + final taskId = await enqueueSendEmailTyped( + stem, + dispatch: dispatch, + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ); + return sendEmailTyped.waitFor(stem, taskId, timeout: timeout); + } } final List> _stemTasks = >[ diff --git a/packages/stem/example/ecommerce/README.md b/packages/stem/example/ecommerce/README.md index c4775d66..f35586cc 100644 --- a/packages/stem/example/ecommerce/README.md +++ b/packages/stem/example/ecommerce/README.md @@ -36,7 +36,7 @@ From those annotations, this example uses generated APIs: - `stemModule` (generated workflow/task bundle) - `StemWorkflowDefinitions.addToCart` - `StemTaskDefinitions.ecommerceAuditLog` -- `StemTaskDefinitions.ecommerceAuditLog.call(...)` +- `StemTaskDefinitions.enqueueEcommerceAuditLog(...)` The manual checkout flow also derives a typed ref from its `Flow` definition: diff --git a/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart b/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart index e8ed64bf..a49c677f 100644 --- a/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart +++ b/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart @@ -116,6 +116,55 @@ abstract final class StemTaskDefinitions { defaultOptions: const TaskOptions(queue: "default"), metadata: const TaskMetadata(), ); + static Future enqueueEcommerceAuditLog( + TaskEnqueuer enqueuer, { + required String event, + required String entityId, + required String detail, + Map headers = const {}, + TaskOptions? options, + DateTime? notBefore, + Map? meta, + TaskEnqueueOptions? enqueueOptions, + }) { + return ecommerceAuditLog + .call( + (event: event, entityId: entityId, detail: detail), + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ) + .enqueueWith(enqueuer, enqueueOptions: enqueueOptions); + } + + static Future>?> + enqueueAndWaitEcommerceAuditLog( + Stem stem, { + required String event, + required String entityId, + required String detail, + Map headers = const {}, + TaskOptions? options, + DateTime? notBefore, + Map? meta, + TaskEnqueueOptions? enqueueOptions, + Duration? timeout, + }) async { + final taskId = await enqueueEcommerceAuditLog( + stem, + event: event, + entityId: entityId, + detail: detail, + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ); + return ecommerceAuditLog.waitFor(stem, taskId, timeout: timeout); + } } final List> _stemTasks = >[ diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index dcd52f2a..2004dc6b 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -140,7 +140,8 @@ The intended DX is: - pass generated `stemModule` into `StemWorkflowApp` or `StemClient` - start workflows through generated workflow refs instead of raw workflow-name strings -- enqueue annotated tasks through `StemTaskDefinitions.*.call(...).enqueueWith(...)` +- enqueue annotated tasks through generated direct helpers like + `StemTaskDefinitions.enqueueSendEmailTyped(...)` instead of raw task-name strings You can customize generated workflow ref names via `@WorkflowDefn`: @@ -166,7 +167,7 @@ dart run build_runner build The generated part exports a bundle plus typed helpers so you can avoid raw workflow-name and task-name strings (for example `StemWorkflowDefinitions.userSignup.call((email: 'user@example.com'))` or -`StemTaskDefinitions.builderExampleTask.call({...}).enqueueWith(stem)`). +`StemTaskDefinitions.enqueueBuilderExampleTask(stem, ...)`). Generated output includes: diff --git a/packages/stem_builder/example/README.md b/packages/stem_builder/example/README.md index 075bfbc2..b6a9963d 100644 --- a/packages/stem_builder/example/README.md +++ b/packages/stem_builder/example/README.md @@ -10,7 +10,7 @@ This example demonstrates: - Generated typed task definitions that use the shared `TaskCall` / `TaskDefinition.waitFor(...)` APIs - Generated zero-arg task definitions with direct helpers: - - `StemTaskDefinitions.builderExamplePing.enqueueAndWaitWith(stem)` + - `StemTaskDefinitions.enqueueAndWaitBuilderExamplePing(stem)` - Generated workflow manifest via `stemModule.workflowManifest` - Running generated definitions through `StemWorkflowApp` - Runtime manifest + run/step metadata views via `WorkflowRuntime` diff --git a/packages/stem_builder/example/bin/main.dart b/packages/stem_builder/example/bin/main.dart index baa036ba..a9d00b6c 100644 --- a/packages/stem_builder/example/bin/main.dart +++ b/packages/stem_builder/example/bin/main.dart @@ -43,8 +43,10 @@ Future main() async { final taskApp = await StemApp.inMemory(module: stemModule); try { await taskApp.start(); - final taskResult = await StemTaskDefinitions.builderExamplePing - .enqueueAndWaitWith(taskApp.stem, timeout: const Duration(seconds: 2)); + final taskResult = await StemTaskDefinitions.enqueueAndWaitBuilderExamplePing( + taskApp.stem, + timeout: const Duration(seconds: 2), + ); print('\nNo-arg task result: ${taskResult?.value}'); } finally { await taskApp.shutdown(); diff --git a/packages/stem_builder/example/lib/definitions.stem.g.dart b/packages/stem_builder/example/lib/definitions.stem.g.dart index 46e86744..881886c5 100644 --- a/packages/stem_builder/example/lib/definitions.stem.g.dart +++ b/packages/stem_builder/example/lib/definitions.stem.g.dart @@ -113,6 +113,43 @@ abstract final class StemTaskDefinitions { defaultOptions: const TaskOptions(), metadata: const TaskMetadata(), ); + static Future enqueueBuilderExamplePing( + TaskEnqueuer enqueuer, { + Map headers = const {}, + TaskOptions? options, + DateTime? notBefore, + Map? meta, + TaskEnqueueOptions? enqueueOptions, + }) { + return builderExamplePing.enqueueWith( + enqueuer, + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ); + } + + static Future?> enqueueAndWaitBuilderExamplePing( + Stem stem, { + Map headers = const {}, + TaskOptions? options, + DateTime? notBefore, + Map? meta, + TaskEnqueueOptions? enqueueOptions, + Duration? timeout, + }) async { + final taskId = await enqueueBuilderExamplePing( + stem, + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ); + return builderExamplePing.waitFor(stem, taskId, timeout: timeout); + } } final List> _stemTasks = >[ diff --git a/packages/stem_builder/lib/src/stem_registry_builder.dart b/packages/stem_builder/lib/src/stem_registry_builder.dart index f4e58d42..13c315e2 100644 --- a/packages/stem_builder/lib/src/stem_registry_builder.dart +++ b/packages/stem_builder/lib/src/stem_registry_builder.dart @@ -1959,6 +1959,82 @@ class _RegistryEmitter { ); } buffer.writeln(' );'); + if (!task.usesLegacyMapArgs) { + final helperSuffix = _pascalIdentifier(symbol); + final businessArgs = _methodSignature( + positional: ['TaskEnqueuer enqueuer'], + named: [ + ...task.valueParameters.map( + (parameter) => 'required ${parameter.typeCode} ${parameter.name}', + ), + 'Map headers = const {}', + 'TaskOptions? options', + 'DateTime? notBefore', + 'Map? meta', + 'TaskEnqueueOptions? enqueueOptions', + ], + ); + if (usesNoArgsDefinition) { + buffer.writeln( + ' static Future enqueue$helperSuffix($businessArgs) {', + ); + buffer.writeln( + ' return $symbol.enqueueWith(enqueuer, headers: headers, options: options, notBefore: notBefore, meta: meta, enqueueOptions: enqueueOptions);', + ); + } else { + final callArgs = _invocationArgs( + positional: [ + '(${task.valueParameters.map((parameter) => '${parameter.name}: ${parameter.name}').join(', ')})', + ], + named: { + 'headers': 'headers', + 'options': 'options', + 'notBefore': 'notBefore', + 'meta': 'meta', + 'enqueueOptions': 'enqueueOptions', + }, + ); + buffer.writeln( + ' static Future enqueue$helperSuffix($businessArgs) {', + ); + buffer.writeln( + ' return $symbol.call($callArgs).enqueueWith(enqueuer, enqueueOptions: enqueueOptions);', + ); + } + buffer.writeln(' }'); + final waitArgs = _methodSignature( + positional: ['Stem stem'], + named: [ + ...task.valueParameters.map( + (parameter) => 'required ${parameter.typeCode} ${parameter.name}', + ), + 'Map headers = const {}', + 'TaskOptions? options', + 'DateTime? notBefore', + 'Map? meta', + 'TaskEnqueueOptions? enqueueOptions', + 'Duration? timeout', + ], + ); + buffer.writeln( + ' static Future?> enqueueAndWait$helperSuffix($waitArgs) async {', + ); + buffer.writeln(' final taskId = await enqueue$helperSuffix('); + buffer.writeln(' stem,'); + for (final parameter in task.valueParameters) { + buffer.writeln(' ${parameter.name}: ${parameter.name},'); + } + buffer.writeln(' headers: headers,'); + buffer.writeln(' options: options,'); + buffer.writeln(' notBefore: notBefore,'); + buffer.writeln(' meta: meta,'); + buffer.writeln(' enqueueOptions: enqueueOptions,'); + buffer.writeln(' );'); + buffer.writeln( + ' return $symbol.waitFor(stem, taskId, timeout: timeout);', + ); + buffer.writeln(' }'); + } } buffer.writeln('}'); buffer.writeln(); diff --git a/packages/stem_builder/test/stem_registry_builder_test.dart b/packages/stem_builder/test/stem_registry_builder_test.dart index 658c58c7..b7a456bc 100644 --- a/packages/stem_builder/test/stem_registry_builder_test.dart +++ b/packages/stem_builder/test/stem_registry_builder_test.dart @@ -371,6 +371,8 @@ Future pingTask() async => 'pong'; allOf([ contains('static final NoArgsTaskDefinition pingTask ='), contains('NoArgsTaskDefinition('), + contains('static Future enqueuePingTask('), + contains('static Future?> enqueueAndWaitPingTask('), isNot(contains('encodeArgs: (args) => const {}')), ]), ), @@ -378,6 +380,48 @@ Future pingTask() async => 'pong'; ); }); + test('generates direct helpers for typed annotated tasks', () async { + const input = ''' +import 'package:stem/stem.dart'; + +part 'workflows.stem.g.dart'; + +class EmailRequest { + const EmailRequest({required this.email}); + final String email; + Map toJson() => {'email': email}; + factory EmailRequest.fromJson(Map json) => + EmailRequest(email: json['email'] as String); +} + +@TaskDefn(name: 'email.send') +Future sendEmail(EmailRequest request) async => request.email; +'''; + + await testBuilder( + stemRegistryBuilder(BuilderOptions.empty), + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + outputs: { + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( + allOf([ + contains('static Future enqueueEmailSend('), + contains( + 'static Future?> enqueueAndWaitEmailSend(', + ), + contains('required EmailRequest request'), + contains('return emailSend'), + ]), + ), + }, + ); + }); + test( 'generates script workflow step proxies for direct method calls', () async { From 36bd45111859b135af651e4319156e3def7ef48a Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 17:53:50 -0500 Subject: [PATCH 023/302] Add direct generated workflow start helpers --- .site/docs/core-concepts/stem-builder.md | 7 +- .site/docs/workflows/annotated-workflows.md | 7 +- .site/docs/workflows/starting-and-waiting.md | 18 ++- packages/stem/README.md | 7 +- .../example/annotated_workflows/README.md | 1 + .../example/annotated_workflows/bin/main.dart | 20 +-- .../lib/definitions.stem.g.dart | 148 ++++++++++++++++++ packages/stem/example/ecommerce/README.md | 1 + .../stem/example/ecommerce/lib/src/app.dart | 16 +- .../src/workflows/annotated_defs.stem.g.dart | 53 +++++++ packages/stem_builder/README.md | 16 +- packages/stem_builder/example/README.md | 4 +- packages/stem_builder/example/bin/main.dart | 9 +- .../example/bin/runtime_metadata_views.dart | 14 +- .../example/lib/definitions.stem.g.dart | 99 ++++++++++++ .../lib/src/stem_registry_builder.dart | 91 ++++++++++- .../test/stem_registry_builder_test.dart | 53 ++++++- 17 files changed, 508 insertions(+), 56 deletions(-) diff --git a/.site/docs/core-concepts/stem-builder.md b/.site/docs/core-concepts/stem-builder.md index 917eac8c..519ca510 100644 --- a/.site/docs/core-concepts/stem-builder.md +++ b/.site/docs/core-concepts/stem-builder.md @@ -87,9 +87,10 @@ final workflowApp = await StemWorkflowApp.fromUrl( ); await workflowApp.start(); -final result = await StemWorkflowDefinitions.userSignup - .call((email: 'user@example.com')) - .startAndWaitWithApp(workflowApp); +final result = await StemWorkflowDefinitions.startAndWaitUserSignup( + workflowApp, + email: 'user@example.com', +); ``` When you pass `module: stemModule`, the workflow app infers the worker diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index eae91a45..0b87c546 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -47,9 +47,10 @@ Use the generated workflow refs when you want a single typed handle for start and wait operations: ```dart -final result = await StemWorkflowDefinitions.userSignup - .call((email: 'user@example.com')) - .startAndWaitWithApp(workflowApp); +final result = await StemWorkflowDefinitions.startAndWaitUserSignup( + workflowApp, + email: 'user@example.com', +); ``` Annotated tasks use the same shared typed task surface: diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 32a58631..85cb59b0 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -58,13 +58,21 @@ When you use `stem_builder`, generated workflow refs remove the raw workflow-name strings and give you one typed handle for both start and wait: ```dart -final result = await StemWorkflowDefinitions.userSignup - .call((email: 'user@example.com')) - .startAndWaitWithApp(workflowApp); +final result = await StemWorkflowDefinitions.startAndWaitUserSignup( + workflowApp, + email: 'user@example.com', +); ``` -The same definitions work on `WorkflowRuntime` through -`.startWithRuntime(runtime)`. +The same definitions work on `WorkflowRuntime` by passing the runtime as the +`WorkflowCaller`: + +```dart +final runId = await StemWorkflowDefinitions.startUserSignup( + runtime, + email: 'user@example.com', +); +``` If you still need the run identifier for inspection or operator tooling, read it from `result.runId`. diff --git a/packages/stem/README.md b/packages/stem/README.md index bc39a105..cd0a74cd 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -582,9 +582,10 @@ final app = await StemWorkflowApp.fromUrl( module: stemModule, ); -final result = await StemWorkflowDefinitions.userSignup - .call((email: 'user@example.com')) - .startAndWaitWithApp(app); +final result = await StemWorkflowDefinitions.startAndWaitUserSignup( + app, + email: 'user@example.com', +); print(result?.value); await app.close(); ``` diff --git a/packages/stem/example/annotated_workflows/README.md b/packages/stem/example/annotated_workflows/README.md index 8a716809..9707e823 100644 --- a/packages/stem/example/annotated_workflows/README.md +++ b/packages/stem/example/annotated_workflows/README.md @@ -38,6 +38,7 @@ The generated file exposes: - `stemModule` - `StemWorkflowDefinitions` +- direct workflow helpers like `startScript(...)` and `startAndWaitScript(...)` - typed workflow refs for `StemWorkflowApp` and `WorkflowRuntime` - typed task definitions that use the shared `TaskCall` / `TaskDefinition.waitFor(...)` APIs diff --git a/packages/stem/example/annotated_workflows/bin/main.dart b/packages/stem/example/annotated_workflows/bin/main.dart index 34e58644..5fb2e02e 100644 --- a/packages/stem/example/annotated_workflows/bin/main.dart +++ b/packages/stem/example/annotated_workflows/bin/main.dart @@ -8,10 +8,10 @@ Future main() async { final app = await client.createWorkflowApp(module: stemModule); await app.start(); - final flowRunId = await StemWorkflowDefinitions.flow - .call(const {}) - .startWithApp(app); - final flowResult = await StemWorkflowDefinitions.flow.waitFor( + final flowRunId = await StemWorkflowDefinitions.startFlow( + app, + ); + final flowResult = await StemWorkflowDefinitions.waitForFlow( app, flowRunId, timeout: const Duration(seconds: 2), @@ -22,11 +22,9 @@ Future main() async { '${jsonEncode(flowResult?.value?['childResult'])}', ); - final scriptCall = StemWorkflowDefinitions.script.call(( - request: const WelcomeRequest(email: ' SomeEmail@Example.com '), - )); - final scriptResult = await scriptCall.startAndWaitWithApp( + final scriptResult = await StemWorkflowDefinitions.startAndWaitScript( app, + request: const WelcomeRequest(email: ' SomeEmail@Example.com '), timeout: const Duration(seconds: 2), ); print('Script result: ${jsonEncode(scriptResult?.value?.toJson())}'); @@ -47,11 +45,9 @@ Future main() async { print('Persisted script result: ${jsonEncode(scriptDetail?.run.result)}'); print('Script detail: ${jsonEncode(scriptDetail?.toJson())}'); - final contextCall = StemWorkflowDefinitions.contextScript.call(( - request: const WelcomeRequest(email: ' ContextEmail@Example.com '), - )); - final contextResult = await contextCall.startAndWaitWithApp( + final contextResult = await StemWorkflowDefinitions.startAndWaitContextScript( app, + request: const WelcomeRequest(email: ' ContextEmail@Example.com '), timeout: const Duration(seconds: 2), ); print('Context script result: ${jsonEncode(contextResult?.value?.toJson())}'); diff --git a/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart b/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart index 4f7b8383..fdf8fd2d 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart @@ -229,6 +229,54 @@ abstract final class StemWorkflowDefinitions { name: "annotated.flow", encodeParams: (params) => params, ); + static Future startFlow( + WorkflowCaller caller, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return flow + .call( + {}, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ) + .startWith(caller); + } + + static Future?>?> startAndWaitFlow( + WorkflowCaller caller, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return flow + .call( + {}, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ) + .startAndWaitWith(caller, pollInterval: pollInterval, timeout: timeout); + } + + static Future?>?> waitForFlow( + WorkflowCaller caller, + String runId, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return flow.waitForWith( + caller, + runId, + pollInterval: pollInterval, + timeout: timeout, + ); + } + static final WorkflowRef<({WelcomeRequest request}), WelcomeWorkflowResult> script = WorkflowRef<({WelcomeRequest request}), WelcomeWorkflowResult>( name: "annotated.script", @@ -237,6 +285,56 @@ abstract final class StemWorkflowDefinitions { }, decodeResult: StemPayloadCodecs.welcomeWorkflowResult.decode, ); + static Future startScript( + WorkflowCaller caller, { + required WelcomeRequest request, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return script + .call( + (request: request), + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ) + .startWith(caller); + } + + static Future?> startAndWaitScript( + WorkflowCaller caller, { + required WelcomeRequest request, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return script + .call( + (request: request), + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ) + .startAndWaitWith(caller, pollInterval: pollInterval, timeout: timeout); + } + + static Future?> waitForScript( + WorkflowCaller caller, + String runId, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return script.waitForWith( + caller, + runId, + pollInterval: pollInterval, + timeout: timeout, + ); + } + static final WorkflowRef<({WelcomeRequest request}), ContextCaptureResult> contextScript = WorkflowRef<({WelcomeRequest request}), ContextCaptureResult>( name: "annotated.context_script", @@ -245,6 +343,56 @@ abstract final class StemWorkflowDefinitions { }, decodeResult: StemPayloadCodecs.contextCaptureResult.decode, ); + static Future startContextScript( + WorkflowCaller caller, { + required WelcomeRequest request, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return contextScript + .call( + (request: request), + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ) + .startWith(caller); + } + + static Future?> + startAndWaitContextScript( + WorkflowCaller caller, { + required WelcomeRequest request, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return contextScript + .call( + (request: request), + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ) + .startAndWaitWith(caller, pollInterval: pollInterval, timeout: timeout); + } + + static Future?> waitForContextScript( + WorkflowCaller caller, + String runId, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return contextScript.waitForWith( + caller, + runId, + pollInterval: pollInterval, + timeout: timeout, + ); + } } Object? _stemRequireArg(Map args, String name) { diff --git a/packages/stem/example/ecommerce/README.md b/packages/stem/example/ecommerce/README.md index f35586cc..aa2fb701 100644 --- a/packages/stem/example/ecommerce/README.md +++ b/packages/stem/example/ecommerce/README.md @@ -35,6 +35,7 @@ From those annotations, this example uses generated APIs: - `stemModule` (generated workflow/task bundle) - `StemWorkflowDefinitions.addToCart` +- `StemWorkflowDefinitions.startAndWaitAddToCart(...)` - `StemTaskDefinitions.ecommerceAuditLog` - `StemTaskDefinitions.enqueueEcommerceAuditLog(...)` diff --git a/packages/stem/example/ecommerce/lib/src/app.dart b/packages/stem/example/ecommerce/lib/src/app.dart index d82aa7c9..48205d1b 100644 --- a/packages/stem/example/ecommerce/lib/src/app.dart +++ b/packages/stem/example/ecommerce/lib/src/app.dart @@ -93,26 +93,24 @@ class EcommerceServer { final sku = payload['sku']?.toString() ?? ''; final quantity = _toInt(payload['quantity']); - final runId = await StemWorkflowDefinitions.addToCart - .call((cartId: cartId, sku: sku, quantity: quantity)) - .startWithApp(workflowApp); - - final result = await StemWorkflowDefinitions.addToCart.waitFor( + final result = await StemWorkflowDefinitions.startAndWaitAddToCart( workflowApp, - runId, + cartId: cartId, + sku: sku, + quantity: quantity, timeout: const Duration(seconds: 4), ); if (result == null) { return _error(500, 'Add-to-cart workflow run not found.', { - 'runId': runId, + 'runId': null, }); } if (result.status != WorkflowStatus.completed || result.value == null) { return _error(422, 'Add-to-cart workflow did not complete.', { - 'runId': runId, + 'runId': result.runId, 'status': result.status.name, 'lastError': result.state.lastError, }); @@ -127,7 +125,7 @@ class EcommerceServer { unitPriceCents: _toInt(computed['unitPriceCents']), ); - return _json(200, {'runId': runId, 'cart': updatedCart}); + return _json(200, {'runId': result.runId, 'cart': updatedCart}); } on Object catch (error) { return _error(400, 'Failed to add item to cart.', error); } diff --git a/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart b/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart index a49c677f..521cb1d1 100644 --- a/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart +++ b/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart @@ -74,6 +74,59 @@ abstract final class StemWorkflowDefinitions { "quantity": params.quantity, }, ); + static Future startAddToCart( + WorkflowCaller caller, { + required String cartId, + required String sku, + required int quantity, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return addToCart + .call( + (cartId: cartId, sku: sku, quantity: quantity), + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ) + .startWith(caller); + } + + static Future>?> startAndWaitAddToCart( + WorkflowCaller caller, { + required String cartId, + required String sku, + required int quantity, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return addToCart + .call( + (cartId: cartId, sku: sku, quantity: quantity), + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ) + .startAndWaitWith(caller, pollInterval: pollInterval, timeout: timeout); + } + + static Future>?> waitForAddToCart( + WorkflowCaller caller, + String runId, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return addToCart.waitForWith( + caller, + runId, + pollInterval: pollInterval, + timeout: timeout, + ); + } } Object? _stemRequireArg(Map args, String name) { diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index 2004dc6b..c7a4e382 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -166,7 +166,7 @@ dart run build_runner build The generated part exports a bundle plus typed helpers so you can avoid raw workflow-name and task-name strings (for example -`StemWorkflowDefinitions.userSignup.call((email: 'user@example.com'))` or +`StemWorkflowDefinitions.startUserSignup(workflowApp, email: 'user@example.com')` or `StemTaskDefinitions.enqueueBuilderExampleTask(stem, ...)`). Generated output includes: @@ -191,9 +191,10 @@ final workflowApp = await StemWorkflowApp.fromUrl( module: stemModule, ); -final result = await StemWorkflowDefinitions.userSignup - .call((email: 'user@example.com')) - .startAndWaitWithApp(workflowApp); +final result = await StemWorkflowDefinitions.startAndWaitUserSignup( + workflowApp, + email: 'user@example.com', +); ``` When you use `module: stemModule`, the workflow app infers the worker @@ -250,9 +251,10 @@ The generated workflow refs work on `WorkflowRuntime` too: ```dart final runtime = workflowApp.runtime; -final runId = await StemWorkflowDefinitions.userSignup - .call((email: 'user@example.com')) - .startWithRuntime(runtime); +final runId = await StemWorkflowDefinitions.startUserSignup( + runtime, + email: 'user@example.com', +); await runtime.executeRun(runId); ``` diff --git a/packages/stem_builder/example/README.md b/packages/stem_builder/example/README.md index b6a9963d..8951ae75 100644 --- a/packages/stem_builder/example/README.md +++ b/packages/stem_builder/example/README.md @@ -5,8 +5,8 @@ This example demonstrates: - Annotated workflow/task definitions - Generated `stemModule` - Generated typed workflow refs (no manual workflow-name strings): - - `StemWorkflowDefinitions.flow.call(...).startWithRuntime(runtime)` - - `StemWorkflowDefinitions.userSignup.call(...).startWithRuntime(runtime)` + - `StemWorkflowDefinitions.startFlow(runtime, ...)` + - `StemWorkflowDefinitions.startUserSignup(runtime, ...)` - Generated typed task definitions that use the shared `TaskCall` / `TaskDefinition.waitFor(...)` APIs - Generated zero-arg task definitions with direct helpers: diff --git a/packages/stem_builder/example/bin/main.dart b/packages/stem_builder/example/bin/main.dart index a9d00b6c..f40344ec 100644 --- a/packages/stem_builder/example/bin/main.dart +++ b/packages/stem_builder/example/bin/main.dart @@ -26,11 +26,12 @@ Future main() async { print('\nRuntime manifest:'); print(const JsonEncoder.withIndent(' ').convert(runtimeManifest)); - final runId = await StemWorkflowDefinitions.flow - .call(const {'name': 'Stem Builder'}) - .startWithRuntime(runtime); + final runId = await StemWorkflowDefinitions.startFlow( + runtime, + name: 'Stem Builder', + ); await runtime.executeRun(runId); - final result = await StemWorkflowDefinitions.flow.waitFor( + final result = await StemWorkflowDefinitions.waitForFlow( app, runId, timeout: const Duration(seconds: 2), diff --git a/packages/stem_builder/example/bin/runtime_metadata_views.dart b/packages/stem_builder/example/bin/runtime_metadata_views.dart index 1aacdee4..1627de79 100644 --- a/packages/stem_builder/example/bin/runtime_metadata_views.dart +++ b/packages/stem_builder/example/bin/runtime_metadata_views.dart @@ -25,14 +25,16 @@ Future main() async { ), ); - final flowRunId = await StemWorkflowDefinitions.flow - .call(const {'name': 'runtime metadata'}) - .startWithRuntime(runtime); + final flowRunId = await StemWorkflowDefinitions.startFlow( + runtime, + name: 'runtime metadata', + ); await runtime.executeRun(flowRunId); - final scriptRunId = await StemWorkflowDefinitions.userSignup - .call((email: 'dev@stem.dev')) - .startWithRuntime(runtime); + final scriptRunId = await StemWorkflowDefinitions.startUserSignup( + runtime, + email: 'dev@stem.dev', + ); await runtime.executeRun(scriptRunId); final runViews = await runtime.listRunViews(limit: 10); diff --git a/packages/stem_builder/example/lib/definitions.stem.g.dart b/packages/stem_builder/example/lib/definitions.stem.g.dart index 881886c5..9ffceb2d 100644 --- a/packages/stem_builder/example/lib/definitions.stem.g.dart +++ b/packages/stem_builder/example/lib/definitions.stem.g.dart @@ -78,11 +78,110 @@ abstract final class StemWorkflowDefinitions { name: "builder.example.flow", encodeParams: (params) => params, ); + static Future startFlow( + WorkflowCaller caller, { + required String name, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return flow + .call( + {"name": name}, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ) + .startWith(caller); + } + + static Future?> startAndWaitFlow( + WorkflowCaller caller, { + required String name, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return flow + .call( + {"name": name}, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ) + .startAndWaitWith(caller, pollInterval: pollInterval, timeout: timeout); + } + + static Future?> waitForFlow( + WorkflowCaller caller, + String runId, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return flow.waitForWith( + caller, + runId, + pollInterval: pollInterval, + timeout: timeout, + ); + } + static final WorkflowRef<({String email}), Map> userSignup = WorkflowRef<({String email}), Map>( name: "builder.example.user_signup", encodeParams: (params) => {"email": params.email}, ); + static Future startUserSignup( + WorkflowCaller caller, { + required String email, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return userSignup + .call( + (email: email), + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ) + .startWith(caller); + } + + static Future>?> startAndWaitUserSignup( + WorkflowCaller caller, { + required String email, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return userSignup + .call( + (email: email), + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ) + .startAndWaitWith(caller, pollInterval: pollInterval, timeout: timeout); + } + + static Future>?> waitForUserSignup( + WorkflowCaller caller, + String runId, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return userSignup.waitForWith( + caller, + runId, + pollInterval: pollInterval, + timeout: timeout, + ); + } } Object? _stemRequireArg(Map args, String name) { diff --git a/packages/stem_builder/lib/src/stem_registry_builder.dart b/packages/stem_builder/lib/src/stem_registry_builder.dart index 13c315e2..2617dbe4 100644 --- a/packages/stem_builder/lib/src/stem_registry_builder.dart +++ b/packages/stem_builder/lib/src/stem_registry_builder.dart @@ -1641,14 +1641,16 @@ class _RegistryEmitter { if (workflows.isEmpty) { return; } + final symbolNames = _symbolNamesForWorkflows(workflows); final fieldNames = _fieldNamesForWorkflows( workflows, - _symbolNamesForWorkflows(workflows), + symbolNames, ); buffer.writeln('abstract final class StemWorkflowDefinitions {'); for (final workflow in workflows) { final fieldName = fieldNames[workflow]!; + final helperSuffix = symbolNames[workflow]!; final argsTypeCode = _workflowArgsTypeCode(workflow); final isNoArgsScript = workflow.kind == WorkflowKind.script && @@ -1685,6 +1687,93 @@ class _RegistryEmitter { ); } buffer.writeln(' );'); + if (isNoArgsScript) { + final startArgs = _methodSignature( + positional: ['WorkflowCaller caller'], + named: [ + 'String? parentRunId', + 'Duration? ttl', + 'WorkflowCancellationPolicy? cancellationPolicy', + ], + ); + buffer.writeln( + ' static Future start$helperSuffix($startArgs) {', + ); + buffer.writeln( + ' return $fieldName.startWith(caller, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy);', + ); + buffer.writeln(' }'); + final startAndWaitArgs = _methodSignature( + positional: ['WorkflowCaller caller'], + named: [ + 'String? parentRunId', + 'Duration? ttl', + 'WorkflowCancellationPolicy? cancellationPolicy', + 'Duration pollInterval = const Duration(milliseconds: 100)', + 'Duration? timeout', + ], + ); + buffer.writeln( + ' static Future?> startAndWait$helperSuffix($startAndWaitArgs) {', + ); + buffer.writeln( + ' return $fieldName.startAndWaitWith(caller, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy, pollInterval: pollInterval, timeout: timeout);', + ); + buffer.writeln(' }'); + } else { + final parameters = workflow.kind == WorkflowKind.script + ? workflow.runValueParameters + : workflow.steps.first.valueParameters; + final callParams = workflow.kind == WorkflowKind.script + ? '(${parameters.map((parameter) => '${parameter.name}: ${parameter.name}').join(', ')})' + : '{${parameters.map((parameter) => '${_string(parameter.name)}: ${_encodeValueExpression(parameter.name, parameter)}').join(', ')}}'; + final startArgs = _methodSignature( + positional: ['WorkflowCaller caller'], + named: [ + ...parameters.map( + (parameter) => 'required ${parameter.typeCode} ${parameter.name}', + ), + 'String? parentRunId', + 'Duration? ttl', + 'WorkflowCancellationPolicy? cancellationPolicy', + ], + ); + buffer.writeln( + ' static Future start$helperSuffix($startArgs) {', + ); + buffer.writeln( + ' return $fieldName.call($callParams, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy).startWith(caller);', + ); + buffer.writeln(' }'); + final startAndWaitArgs = _methodSignature( + positional: ['WorkflowCaller caller'], + named: [ + ...parameters.map( + (parameter) => 'required ${parameter.typeCode} ${parameter.name}', + ), + 'String? parentRunId', + 'Duration? ttl', + 'WorkflowCancellationPolicy? cancellationPolicy', + 'Duration pollInterval = const Duration(milliseconds: 100)', + 'Duration? timeout', + ], + ); + buffer.writeln( + ' static Future?> startAndWait$helperSuffix($startAndWaitArgs) {', + ); + buffer.writeln( + ' return $fieldName.call($callParams, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy).startAndWaitWith(caller, pollInterval: pollInterval, timeout: timeout);', + ); + buffer.writeln(' }'); + } + buffer.writeln( + ' static Future?> waitFor$helperSuffix(' + '${_methodSignature(positional: ['WorkflowCaller caller', 'String runId'], named: ['Duration pollInterval = const Duration(milliseconds: 100)', 'Duration? timeout'])}) {', + ); + buffer.writeln( + ' return $fieldName.waitForWith(caller, runId, pollInterval: pollInterval, timeout: timeout);', + ); + buffer.writeln(' }'); } buffer.writeln('}'); buffer.writeln(); diff --git a/packages/stem_builder/test/stem_registry_builder_test.dart b/packages/stem_builder/test/stem_registry_builder_test.dart index b7a456bc..8878cb28 100644 --- a/packages/stem_builder/test/stem_registry_builder_test.dart +++ b/packages/stem_builder/test/stem_registry_builder_test.dart @@ -341,6 +341,15 @@ class HelloScriptWorkflow { 'helloScriptWorkflow =', ), contains('NoArgsWorkflowRef('), + contains('static Future startHelloScriptWorkflow('), + contains( + 'static Future?> ' + 'startAndWaitHelloScriptWorkflow(', + ), + contains( + 'static Future?> ' + 'waitForHelloScriptWorkflow(', + ), ]), ), }, @@ -372,7 +381,9 @@ Future pingTask() async => 'pong'; contains('static final NoArgsTaskDefinition pingTask ='), contains('NoArgsTaskDefinition('), contains('static Future enqueuePingTask('), - contains('static Future?> enqueueAndWaitPingTask('), + contains( + 'static Future?> enqueueAndWaitPingTask(', + ), isNot(contains('encodeArgs: (args) => const {}')), ]), ), @@ -422,6 +433,46 @@ Future sendEmail(EmailRequest request) async => request.email; ); }); + test('generates direct helpers for typed workflows', () async { + const input = ''' +import 'package:stem/stem.dart'; + +part 'workflows.stem.g.dart'; + +@WorkflowDefn(kind: WorkflowKind.script) +class SignupWorkflow { + Future run(String email) async => email; +} +'''; + + await testBuilder( + stemRegistryBuilder(BuilderOptions.empty), + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + outputs: { + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( + allOf([ + contains('static Future startSignupWorkflow('), + contains( + 'static Future?> ' + 'startAndWaitSignupWorkflow(', + ), + contains( + 'static Future?> ' + 'waitForSignupWorkflow(', + ), + contains('required String email'), + ]), + ), + }, + ); + }); + test( 'generates script workflow step proxies for direct method calls', () async { From 64cd437f6e9650f7ea76147e00765a4e2f1b02d1 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 17:57:44 -0500 Subject: [PATCH 024/302] Add direct child workflow helper APIs --- .site/docs/workflows/annotated-workflows.md | 4 +- packages/stem/README.md | 4 +- .../example/annotated_workflows/README.md | 4 +- .../annotated_workflows/lib/definitions.dart | 17 ++- .../lib/definitions.stem.g.dart | 121 ++++++++++++++++++ .../src/workflows/annotated_defs.stem.g.dart | 45 +++++++ packages/stem_builder/README.md | 4 +- .../example/lib/definitions.stem.g.dart | 81 ++++++++++++ .../lib/src/stem_registry_builder.dart | 74 +++++++++++ .../test/stem_registry_builder_test.dart | 10 ++ 10 files changed, 350 insertions(+), 14 deletions(-) diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index 0b87c546..f021f4c2 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -135,9 +135,9 @@ This keeps one authoring model: When a workflow needs to start another workflow, do it from a durable boundary: -- `StemWorkflowDefinitions.someWorkflow.call(...).startAndWaitWithContext(context)` +- `StemWorkflowDefinitions.startAndWaitSomeWorkflowWithContext(context, ...)` inside flow steps -- `StemWorkflowDefinitions.someWorkflow.call(...).startAndWaitWithContext(context)` +- `StemWorkflowDefinitions.startAndWaitSomeWorkflowWithContext(context, ...)` inside checkpoint methods Avoid starting child workflows from the raw `WorkflowScriptContext` body. diff --git a/packages/stem/README.md b/packages/stem/README.md index cd0a74cd..d7ebf1e5 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -528,10 +528,10 @@ Context injection works at every runtime layer: Child workflows belong in durable execution boundaries: - use - `StemWorkflowDefinitions.someWorkflow.call(...).startAndWaitWithContext(context)` + `StemWorkflowDefinitions.startAndWaitSomeWorkflowWithContext(context, ...)` inside flow steps - use - `StemWorkflowDefinitions.someWorkflow.call(...).startAndWaitWithContext(context)` + `StemWorkflowDefinitions.startAndWaitSomeWorkflowWithContext(context, ...)` inside script checkpoints - do not start child workflows from the raw `WorkflowScriptContext` body unless you are deliberately managing replay/idempotency yourself diff --git a/packages/stem/example/annotated_workflows/README.md b/packages/stem/example/annotated_workflows/README.md index 9707e823..c9962c40 100644 --- a/packages/stem/example/annotated_workflows/README.md +++ b/packages/stem/example/annotated_workflows/README.md @@ -6,7 +6,7 @@ with the `stem_builder` bundle generator. It now demonstrates the generated script-proxy behavior explicitly: - a flow step using `FlowContext` - a flow step starting and waiting on a child workflow through - `StemWorkflowDefinitions.*.call(...).startAndWaitWithContext(context)` + `StemWorkflowDefinitions.startAndWait*WithContext(context, ...)` - `run(WelcomeRequest request)` calls annotated checkpoint methods directly - `prepareWelcome(...)` calls other annotated checkpoints - `deliverWelcome(...)` calls another annotated checkpoint from inside an @@ -15,7 +15,7 @@ It now demonstrates the generated script-proxy behavior explicitly: (`WorkflowScriptContext? context` / `WorkflowScriptStepContext? context`) to expose `runId`, `workflow`, `stepName`, `stepIndex`, and idempotency keys - a script checkpoint starting and waiting on a child workflow through - `StemWorkflowDefinitions.*.call(...).startAndWaitWithContext(context)` + `StemWorkflowDefinitions.startAndWait*WithContext(context, ...)` - a plain script workflow that returns a codec-backed DTO result and persists a codec-backed DTO checkpoint value - a typed `@TaskDefn` using optional named `TaskInvocationContext? context` diff --git a/packages/stem/example/annotated_workflows/lib/definitions.dart b/packages/stem/example/annotated_workflows/lib/definitions.dart index dd80527a..db5ac4f8 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.dart @@ -194,9 +194,11 @@ class AnnotatedFlowWorkflow { if (!ctx.sleepUntilResumed(const Duration(milliseconds: 50))) { return null; } - final childResult = await StemWorkflowDefinitions.script - .call((request: const WelcomeRequest(email: 'flow-child@example.com'))) - .startAndWaitWithContext(ctx, timeout: const Duration(seconds: 2)); + final childResult = await StemWorkflowDefinitions.startAndWaitScriptWithContext( + ctx, + request: const WelcomeRequest(email: 'flow-child@example.com'), + timeout: const Duration(seconds: 2), + ); return { 'workflow': ctx.workflow, 'runId': ctx.runId, @@ -276,9 +278,12 @@ class AnnotatedContextScriptWorkflow { final ctx = context!; final normalizedEmail = await normalizeEmail(request.email); final subject = await buildWelcomeSubject(normalizedEmail); - final childResult = await StemWorkflowDefinitions.script - .call((request: WelcomeRequest(email: normalizedEmail))) - .startAndWaitWithContext(ctx, timeout: const Duration(seconds: 2)); + final childResult = await StemWorkflowDefinitions + .startAndWaitScriptWithContext( + ctx, + request: WelcomeRequest(email: normalizedEmail), + timeout: const Duration(seconds: 2), + ); return ContextCaptureResult( workflow: ctx.workflow, runId: ctx.runId, diff --git a/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart b/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart index fdf8fd2d..597b9d06 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart @@ -245,6 +245,22 @@ abstract final class StemWorkflowDefinitions { .startWith(caller); } + static Future startFlowWithContext( + WorkflowChildCallerContext context, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return flow + .call( + {}, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ) + .startWithContext(context); + } + static Future?>?> startAndWaitFlow( WorkflowCaller caller, { String? parentRunId, @@ -263,6 +279,29 @@ abstract final class StemWorkflowDefinitions { .startAndWaitWith(caller, pollInterval: pollInterval, timeout: timeout); } + static Future?>?> + startAndWaitFlowWithContext( + WorkflowChildCallerContext context, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return flow + .call( + {}, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ) + .startAndWaitWithContext( + context, + pollInterval: pollInterval, + timeout: timeout, + ); + } + static Future?>?> waitForFlow( WorkflowCaller caller, String runId, { @@ -302,6 +341,23 @@ abstract final class StemWorkflowDefinitions { .startWith(caller); } + static Future startScriptWithContext( + WorkflowChildCallerContext context, { + required WelcomeRequest request, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return script + .call( + (request: request), + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ) + .startWithContext(context); + } + static Future?> startAndWaitScript( WorkflowCaller caller, { required WelcomeRequest request, @@ -321,6 +377,30 @@ abstract final class StemWorkflowDefinitions { .startAndWaitWith(caller, pollInterval: pollInterval, timeout: timeout); } + static Future?> + startAndWaitScriptWithContext( + WorkflowChildCallerContext context, { + required WelcomeRequest request, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return script + .call( + (request: request), + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ) + .startAndWaitWithContext( + context, + pollInterval: pollInterval, + timeout: timeout, + ); + } + static Future?> waitForScript( WorkflowCaller caller, String runId, { @@ -360,6 +440,23 @@ abstract final class StemWorkflowDefinitions { .startWith(caller); } + static Future startContextScriptWithContext( + WorkflowChildCallerContext context, { + required WelcomeRequest request, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return contextScript + .call( + (request: request), + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ) + .startWithContext(context); + } + static Future?> startAndWaitContextScript( WorkflowCaller caller, { @@ -380,6 +477,30 @@ abstract final class StemWorkflowDefinitions { .startAndWaitWith(caller, pollInterval: pollInterval, timeout: timeout); } + static Future?> + startAndWaitContextScriptWithContext( + WorkflowChildCallerContext context, { + required WelcomeRequest request, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return contextScript + .call( + (request: request), + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ) + .startAndWaitWithContext( + context, + pollInterval: pollInterval, + timeout: timeout, + ); + } + static Future?> waitForContextScript( WorkflowCaller caller, String runId, { diff --git a/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart b/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart index 521cb1d1..d7fbd2b1 100644 --- a/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart +++ b/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart @@ -93,6 +93,25 @@ abstract final class StemWorkflowDefinitions { .startWith(caller); } + static Future startAddToCartWithContext( + WorkflowChildCallerContext context, { + required String cartId, + required String sku, + required int quantity, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return addToCart + .call( + (cartId: cartId, sku: sku, quantity: quantity), + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ) + .startWithContext(context); + } + static Future>?> startAndWaitAddToCart( WorkflowCaller caller, { required String cartId, @@ -114,6 +133,32 @@ abstract final class StemWorkflowDefinitions { .startAndWaitWith(caller, pollInterval: pollInterval, timeout: timeout); } + static Future>?> + startAndWaitAddToCartWithContext( + WorkflowChildCallerContext context, { + required String cartId, + required String sku, + required int quantity, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return addToCart + .call( + (cartId: cartId, sku: sku, quantity: quantity), + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ) + .startAndWaitWithContext( + context, + pollInterval: pollInterval, + timeout: timeout, + ); + } + static Future>?> waitForAddToCart( WorkflowCaller caller, String runId, { diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index c7a4e382..084a246a 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -105,9 +105,9 @@ Supported context injection points: Child workflows should be started from durable boundaries: -- `StemWorkflowDefinitions.someWorkflow.call(...).startAndWaitWithContext(context)` +- `StemWorkflowDefinitions.startSomeWorkflowWithContext(context, ...)` inside flow steps -- `StemWorkflowDefinitions.someWorkflow.call(...).startAndWaitWithContext(context)` +- `StemWorkflowDefinitions.startAndWaitSomeWorkflowWithContext(context, ...)` inside script checkpoints Avoid starting child workflows directly from the raw diff --git a/packages/stem_builder/example/lib/definitions.stem.g.dart b/packages/stem_builder/example/lib/definitions.stem.g.dart index 9ffceb2d..49432f7d 100644 --- a/packages/stem_builder/example/lib/definitions.stem.g.dart +++ b/packages/stem_builder/example/lib/definitions.stem.g.dart @@ -95,6 +95,23 @@ abstract final class StemWorkflowDefinitions { .startWith(caller); } + static Future startFlowWithContext( + WorkflowChildCallerContext context, { + required String name, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return flow + .call( + {"name": name}, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ) + .startWithContext(context); + } + static Future?> startAndWaitFlow( WorkflowCaller caller, { required String name, @@ -114,6 +131,29 @@ abstract final class StemWorkflowDefinitions { .startAndWaitWith(caller, pollInterval: pollInterval, timeout: timeout); } + static Future?> startAndWaitFlowWithContext( + WorkflowChildCallerContext context, { + required String name, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return flow + .call( + {"name": name}, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ) + .startAndWaitWithContext( + context, + pollInterval: pollInterval, + timeout: timeout, + ); + } + static Future?> waitForFlow( WorkflowCaller caller, String runId, { @@ -150,6 +190,23 @@ abstract final class StemWorkflowDefinitions { .startWith(caller); } + static Future startUserSignupWithContext( + WorkflowChildCallerContext context, { + required String email, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return userSignup + .call( + (email: email), + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ) + .startWithContext(context); + } + static Future>?> startAndWaitUserSignup( WorkflowCaller caller, { required String email, @@ -169,6 +226,30 @@ abstract final class StemWorkflowDefinitions { .startAndWaitWith(caller, pollInterval: pollInterval, timeout: timeout); } + static Future>?> + startAndWaitUserSignupWithContext( + WorkflowChildCallerContext context, { + required String email, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return userSignup + .call( + (email: email), + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ) + .startAndWaitWithContext( + context, + pollInterval: pollInterval, + timeout: timeout, + ); + } + static Future>?> waitForUserSignup( WorkflowCaller caller, String runId, { diff --git a/packages/stem_builder/lib/src/stem_registry_builder.dart b/packages/stem_builder/lib/src/stem_registry_builder.dart index 2617dbe4..29f9038d 100644 --- a/packages/stem_builder/lib/src/stem_registry_builder.dart +++ b/packages/stem_builder/lib/src/stem_registry_builder.dart @@ -1703,6 +1703,22 @@ class _RegistryEmitter { ' return $fieldName.startWith(caller, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy);', ); buffer.writeln(' }'); + final startWithContextArgs = _methodSignature( + positional: ['WorkflowChildCallerContext context'], + named: [ + 'String? parentRunId', + 'Duration? ttl', + 'WorkflowCancellationPolicy? cancellationPolicy', + ], + ); + buffer.writeln( + ' static Future start$helperSuffix' + 'WithContext($startWithContextArgs) {', + ); + buffer.writeln( + ' return $fieldName.startWithContext(context, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy);', + ); + buffer.writeln(' }'); final startAndWaitArgs = _methodSignature( positional: ['WorkflowCaller caller'], named: [ @@ -1720,6 +1736,24 @@ class _RegistryEmitter { ' return $fieldName.startAndWaitWith(caller, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy, pollInterval: pollInterval, timeout: timeout);', ); buffer.writeln(' }'); + final startAndWaitWithContextArgs = _methodSignature( + positional: ['WorkflowChildCallerContext context'], + named: [ + 'String? parentRunId', + 'Duration? ttl', + 'WorkflowCancellationPolicy? cancellationPolicy', + 'Duration pollInterval = const Duration(milliseconds: 100)', + 'Duration? timeout', + ], + ); + buffer.writeln( + ' static Future?> startAndWait$helperSuffix' + 'WithContext($startAndWaitWithContextArgs) {', + ); + buffer.writeln( + ' return $fieldName.startAndWaitWithContext(context, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy, pollInterval: pollInterval, timeout: timeout);', + ); + buffer.writeln(' }'); } else { final parameters = workflow.kind == WorkflowKind.script ? workflow.runValueParameters @@ -1745,6 +1779,25 @@ class _RegistryEmitter { ' return $fieldName.call($callParams, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy).startWith(caller);', ); buffer.writeln(' }'); + final startWithContextArgs = _methodSignature( + positional: ['WorkflowChildCallerContext context'], + named: [ + ...parameters.map( + (parameter) => 'required ${parameter.typeCode} ${parameter.name}', + ), + 'String? parentRunId', + 'Duration? ttl', + 'WorkflowCancellationPolicy? cancellationPolicy', + ], + ); + buffer.writeln( + ' static Future start$helperSuffix' + 'WithContext($startWithContextArgs) {', + ); + buffer.writeln( + ' return $fieldName.call($callParams, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy).startWithContext(context);', + ); + buffer.writeln(' }'); final startAndWaitArgs = _methodSignature( positional: ['WorkflowCaller caller'], named: [ @@ -1765,6 +1818,27 @@ class _RegistryEmitter { ' return $fieldName.call($callParams, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy).startAndWaitWith(caller, pollInterval: pollInterval, timeout: timeout);', ); buffer.writeln(' }'); + final startAndWaitWithContextArgs = _methodSignature( + positional: ['WorkflowChildCallerContext context'], + named: [ + ...parameters.map( + (parameter) => 'required ${parameter.typeCode} ${parameter.name}', + ), + 'String? parentRunId', + 'Duration? ttl', + 'WorkflowCancellationPolicy? cancellationPolicy', + 'Duration pollInterval = const Duration(milliseconds: 100)', + 'Duration? timeout', + ], + ); + buffer.writeln( + ' static Future?> startAndWait$helperSuffix' + 'WithContext($startAndWaitWithContextArgs) {', + ); + buffer.writeln( + ' return $fieldName.call($callParams, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy).startAndWaitWithContext(context, pollInterval: pollInterval, timeout: timeout);', + ); + buffer.writeln(' }'); } buffer.writeln( ' static Future?> waitFor$helperSuffix(' diff --git a/packages/stem_builder/test/stem_registry_builder_test.dart b/packages/stem_builder/test/stem_registry_builder_test.dart index 8878cb28..b4b3bcf3 100644 --- a/packages/stem_builder/test/stem_registry_builder_test.dart +++ b/packages/stem_builder/test/stem_registry_builder_test.dart @@ -342,10 +342,16 @@ class HelloScriptWorkflow { ), contains('NoArgsWorkflowRef('), contains('static Future startHelloScriptWorkflow('), + contains( + 'static Future startHelloScriptWorkflowWithContext(', + ), contains( 'static Future?> ' 'startAndWaitHelloScriptWorkflow(', ), + contains( + 'startAndWaitHelloScriptWorkflowWithContext(', + ), contains( 'static Future?> ' 'waitForHelloScriptWorkflow(', @@ -458,10 +464,14 @@ class SignupWorkflow { 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( allOf([ contains('static Future startSignupWorkflow('), + contains('static Future startSignupWorkflowWithContext('), contains( 'static Future?> ' 'startAndWaitSignupWorkflow(', ), + contains( + 'startAndWaitSignupWorkflowWithContext(', + ), contains( 'static Future?> ' 'waitForSignupWorkflow(', From 147dbd505a157cd6d1dc8b678133de83c9894f64 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 18:02:52 -0500 Subject: [PATCH 025/302] Add direct generic workflow ref helpers --- .site/docs/workflows/starting-and-waiting.md | 7 +- packages/stem/README.md | 7 +- .../example/docs_snippets/lib/workflows.dart | 21 +++-- .../stem/lib/src/bootstrap/workflow_app.dart | 77 +++++++++++++++++++ .../lib/src/workflow/core/workflow_ref.dart | 77 +++++++++++++++++++ .../test/unit/workflow/flow_context_test.dart | 7 +- ...workflow_runtime_call_extensions_test.dart | 46 +++++++++++ .../workflow/workflow_runtime_ref_test.dart | 12 +-- 8 files changed, 230 insertions(+), 24 deletions(-) diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 85cb59b0..7838dd19 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -28,9 +28,10 @@ final approvalsRef = approvalsFlow.ref<({Map draft})>( encodeParams: (params) => {'draft': params.draft}, ); -final runId = await approvalsRef - .call((draft: const {'documentId': 'doc-42'})) - .startWithApp(workflowApp); +final runId = await approvalsRef.startWithApp( + workflowApp, + (draft: const {'documentId': 'doc-42'}), +); final result = await approvalsRef.waitFor(workflowApp, runId); ``` diff --git a/packages/stem/README.md b/packages/stem/README.md index d7ebf1e5..75c7642b 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -412,9 +412,10 @@ final app = await StemWorkflowApp.fromUrl( tasks: const [], ); -final runId = await approvalsRef - .call((draft: const {'documentId': 'doc-42'})) - .startWithApp(app); +final runId = await approvalsRef.startWithApp( + app, + (draft: const {'documentId': 'doc-42'}), +); final result = await approvalsRef.waitFor(app, runId); print(result?.value); diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index 3ef6fcb1..c24c5f62 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -103,17 +103,16 @@ final retryDefinition = retryScript.definition; // #region workflows-run Future runWorkflow(StemWorkflowApp workflowApp) async { - final runId = await ApprovalsFlow.ref - .call( - ( - draft: const {'documentId': 'doc-42'}, - ), - cancellationPolicy: const WorkflowCancellationPolicy( - maxRunDuration: Duration(hours: 2), - maxSuspendDuration: Duration(minutes: 30), - ), - ) - .startWithApp(workflowApp); + final runId = await ApprovalsFlow.ref.startWithApp( + workflowApp, + ( + draft: const {'documentId': 'doc-42'}, + ), + cancellationPolicy: const WorkflowCancellationPolicy( + maxRunDuration: Duration(hours: 2), + maxSuspendDuration: Duration(minutes: 30), + ), + ); final result = await ApprovalsFlow.ref.waitFor( workflowApp, diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index 6ad8b246..afaf7505 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -222,6 +222,7 @@ class StemWorkflowApp implements WorkflowCaller { } /// Waits for [runId] using the decoding rules from a [WorkflowRef]. + @override Future?> waitForWorkflowRef( String runId, @@ -647,6 +648,82 @@ extension WorkflowStartCallAppExtension /// Convenience helpers for waiting on workflow results using a typed reference. extension WorkflowRefAppExtension on WorkflowRef { + /// Starts this workflow ref with [app]. + Future startWithApp( + StemWorkflowApp app, + TParams params, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return startWith( + app, + params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + + /// Starts this workflow ref with [app] and waits for the result. + Future?> startAndWaitWithApp( + StemWorkflowApp app, + TParams params, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return startAndWaitWith( + app, + params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + pollInterval: pollInterval, + timeout: timeout, + ); + } + + /// Starts this workflow ref with [runtime]. + Future startWithRuntime( + WorkflowRuntime runtime, + TParams params, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return startWith( + runtime, + params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + + /// Starts this workflow ref with [runtime] and waits for the result. + Future?> startAndWaitWithRuntime( + WorkflowRuntime runtime, + TParams params, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return startAndWaitWith( + runtime, + params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + pollInterval: pollInterval, + timeout: timeout, + ); + } + /// Waits for [runId] using this workflow reference's decode rules. Future?> waitFor( StemWorkflowApp app, diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 1df901b8..5ed813e3 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -50,6 +50,83 @@ class WorkflowRef { } return payload as TResult; } + + /// Starts this workflow ref directly with [caller]. + Future startWith( + WorkflowCaller caller, + TParams params, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return call( + params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ).startWith(caller); + } + + /// Starts this workflow ref directly with a workflow child-caller [context]. + Future startWithContext( + WorkflowChildCallerContext context, + TParams params, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return call( + params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ).startWithContext(context); + } + + /// Starts this workflow ref with [caller] and waits for the result. + Future?> startAndWaitWith( + WorkflowCaller caller, + TParams params, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return call( + params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ).startAndWaitWith( + caller, + pollInterval: pollInterval, + timeout: timeout, + ); + } + + /// Starts this workflow ref with a workflow child-caller [context] and + /// waits for the result. + Future?> startAndWaitWithContext( + WorkflowChildCallerContext context, + TParams params, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return call( + params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ).startAndWaitWithContext( + context, + pollInterval: pollInterval, + timeout: timeout, + ); + } } /// Typed producer-facing reference for workflows that take no input params. diff --git a/packages/stem/test/unit/workflow/flow_context_test.dart b/packages/stem/test/unit/workflow/flow_context_test.dart index bc92d28e..7cab213d 100644 --- a/packages/stem/test/unit/workflow/flow_context_test.dart +++ b/packages/stem/test/unit/workflow/flow_context_test.dart @@ -91,7 +91,7 @@ void main() { ); expect( - () => childRef.call(const {'value': 'x'}).startWithContext(context), + () => childRef.startWithContext(context, const {'value': 'x'}), throwsStateError, ); }, @@ -114,7 +114,10 @@ void main() { ); expect( - () => childRef.call(const {'value': 'x'}).startAndWaitWithContext(context), + () => childRef.startAndWaitWithContext( + context, + const {'value': 'x'}, + ), throwsStateError, ); }, diff --git a/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart b/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart index 1d9ce436..380ef1c9 100644 --- a/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart @@ -48,5 +48,51 @@ void main() { } }, ); + + test( + 'WorkflowRef direct helpers mirror WorkflowStartCall dispatch', + () async { + final flow = Flow( + name: 'runtime.extension.direct.flow', + build: (builder) { + builder.step('hello', (ctx) async { + final name = ctx.params['name'] as String? ?? 'world'; + return 'hello $name'; + }); + }, + ); + final workflowRef = WorkflowRef, String>( + name: 'runtime.extension.direct.flow', + encodeParams: (params) => params, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + await workflowApp.start(); + + final runId = await workflowRef.startWithRuntime( + workflowApp.runtime, + const {'name': 'runtime'}, + ); + final waited = await workflowRef.waitForWithRuntime( + workflowApp.runtime, + runId, + timeout: const Duration(seconds: 2), + ); + + expect(waited?.value, 'hello runtime'); + + final oneShot = await workflowRef.startAndWaitWithRuntime( + workflowApp.runtime, + const {'name': 'inline'}, + timeout: const Duration(seconds: 2), + ); + + expect(oneShot?.value, 'hello inline'); + } finally { + await workflowApp.shutdown(); + } + }, + ); }); } diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 4678c40b..d771dd5a 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -21,8 +21,9 @@ void main() { try { await workflowApp.start(); - final runId = await workflowApp.runtime.startWorkflowCall( - workflowRef.call(const {'name': 'runtime'}), + final runId = await workflowRef.startWithRuntime( + workflowApp.runtime, + const {'name': 'runtime'}, ); final waited = await workflowApp.runtime.waitForWorkflowRef( runId, @@ -64,9 +65,10 @@ void main() { try { await workflowApp.start(); - final runId = await workflowRef - .call(const {'name': 'runtime'}) - .startWith(workflowApp.runtime); + final runId = await workflowRef.startWithRuntime( + workflowApp.runtime, + const {'name': 'runtime'}, + ); final waited = await workflowRef.waitForWithRuntime( workflowApp.runtime, runId, From 4685608568105b5198c32f6d3171e0f266631702 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 18:08:17 -0500 Subject: [PATCH 026/302] Add codec-backed manual workflow refs --- .site/docs/workflows/starting-and-waiting.md | 26 ++++++++- packages/stem/README.md | 27 ++++++++- .../example/docs_snippets/lib/workflows.dart | 49 +++++++++++++++-- packages/stem/lib/src/workflow/core/flow.dart | 7 +++ .../workflow/core/workflow_definition.dart | 10 ++++ .../lib/src/workflow/core/workflow_ref.dart | 43 +++++++++++++++ .../src/workflow/core/workflow_script.dart | 7 +++ .../workflow/workflow_runtime_ref_test.dart | 55 +++++++++++++++++++ 8 files changed, 212 insertions(+), 12 deletions(-) diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 7838dd19..ed9c3733 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -24,13 +24,30 @@ Manual `Flow(...)` and `WorkflowScript(...)` definitions can derive a typed ref without repeating the workflow-name string: ```dart -final approvalsRef = approvalsFlow.ref<({Map draft})>( - encodeParams: (params) => {'draft': params.draft}, +const approvalDraftCodec = PayloadCodec( + encode: (value) => value.toJson(), + decode: (payload) => ApprovalDraft.fromJson( + Map.from(payload as Map), + ), +); + +final approvalsRef = approvalsFlow.refWithCodec<({ApprovalDraft draft})>( + paramsCodec: PayloadCodec<({ApprovalDraft draft})>( + encode: (value) => { + 'draft': approvalDraftCodec.encode(value.draft), + }, + decode: (payload) { + final map = Map.from(payload as Map); + return ( + draft: approvalDraftCodec.decode(map['draft']) as ApprovalDraft, + ); + }, + ), ); final runId = await approvalsRef.startWithApp( workflowApp, - (draft: const {'documentId': 'doc-42'}), + (draft: const ApprovalDraft(documentId: 'doc-42')), ); final result = await approvalsRef.waitFor(workflowApp, runId); @@ -39,6 +56,9 @@ final result = await approvalsRef.waitFor(workflowApp, runId); Use this path when you want the same typed start/wait surface as generated workflow refs, but the workflow itself is still hand-written. +`refWithCodec(...)` is the manual DTO path. The codec still needs to encode to +`Map` because workflow params are stored as a map. + For workflows without start params, derive `ref0()` instead and start them directly from the no-args ref. diff --git a/packages/stem/README.md b/packages/stem/README.md index 75c7642b..7b3c4eec 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -402,8 +402,25 @@ final approvalsFlow = Flow( }, ); -final approvalsRef = approvalsFlow.ref<({Map draft})>( - encodeParams: (params) => {'draft': params.draft}, +const approvalDraftCodec = PayloadCodec( + encode: (value) => value.toJson(), + decode: (payload) => ApprovalDraft.fromJson( + Map.from(payload as Map), + ), +); + +final approvalsRef = approvalsFlow.refWithCodec<({ApprovalDraft draft})>( + paramsCodec: PayloadCodec<({ApprovalDraft draft})>( + encode: (value) => { + 'draft': approvalDraftCodec.encode(value.draft), + }, + decode: (payload) { + final map = Map.from(payload as Map); + return ( + draft: approvalDraftCodec.decode(map['draft']) as ApprovalDraft, + ); + }, + ), ); final app = await StemWorkflowApp.fromUrl( @@ -414,7 +431,7 @@ final app = await StemWorkflowApp.fromUrl( final runId = await approvalsRef.startWithApp( app, - (draft: const {'documentId': 'doc-42'}), + (draft: const ApprovalDraft(documentId: 'doc-42')), ); final result = await approvalsRef.waitFor(app, runId); @@ -422,6 +439,10 @@ print(result?.value); await app.close(); ``` +Use `refWithCodec(...)` when your manual workflow start params are DTOs that +already have a `PayloadCodec`. The codec still needs to encode to +`Map` because workflow params are persisted as a map. + For workflows without start parameters, use `ref0()` and start directly from the no-args ref: diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index c24c5f62..8355902c 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -5,6 +5,29 @@ import 'package:stem/stem.dart'; import 'package:stem_postgres/stem_postgres.dart'; import 'package:stem_redis/stem_redis.dart'; +class ApprovalDraft { + const ApprovalDraft({required this.documentId}); + + final String documentId; + + Map toJson() => {'documentId': documentId}; + + factory ApprovalDraft.fromJson(Map json) { + return ApprovalDraft(documentId: json['documentId'] as String); + } +} + +const approvalDraftCodec = PayloadCodec( + encode: _encodeApprovalDraft, + decode: _decodeApprovalDraft, +); + +Object? _encodeApprovalDraft(ApprovalDraft value) => value.toJson(); + +ApprovalDraft _decodeApprovalDraft(Object? payload) { + return ApprovalDraft.fromJson(Map.from(payload as Map)); +} + // #region workflows-runtime Future bootstrapWorkflowRuntime() async { // #region workflows-app-create @@ -65,8 +88,22 @@ class ApprovalsFlow { }, ); - static final ref = flow.ref<({Map draft})>( - encodeParams: (params) => {'draft': params.draft}, + static final ref = flow.refWithCodec<({ApprovalDraft draft})>( + paramsCodec: PayloadCodec<({ApprovalDraft draft})>( + encode: _encodeApprovalStart, + decode: _decodeApprovalStart, + ), + ); +} + +Object? _encodeApprovalStart(({ApprovalDraft draft}) value) { + return {'draft': approvalDraftCodec.encode(value.draft)}; +} + +({ApprovalDraft draft}) _decodeApprovalStart(Object? payload) { + final map = Map.from(payload as Map); + return ( + draft: approvalDraftCodec.decode(map['draft']) as ApprovalDraft, ); } @@ -106,7 +143,7 @@ Future runWorkflow(StemWorkflowApp workflowApp) async { final runId = await ApprovalsFlow.ref.startWithApp( workflowApp, ( - draft: const {'documentId': 'doc-42'}, + draft: const ApprovalDraft(documentId: 'doc-42'), ), cancellationPolicy: const WorkflowCancellationPolicy( maxRunDuration: Duration(hours: 2), @@ -202,9 +239,9 @@ class BillingRetryAnnotatedWorkflow { options: TaskOptions(maxRetries: 5), ) Future sendEmail( - Map args, - {TaskInvocationContext? context} -) async { + Map args, { + TaskInvocationContext? context, +}) async { final ctx = context!; ctx.heartbeat(); // send email diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index a3301406..5a2043b6 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -38,6 +38,13 @@ class Flow { return definition.ref(encodeParams: encodeParams); } + /// Builds a typed [WorkflowRef] backed by a DTO [paramsCodec]. + WorkflowRef refWithCodec({ + required PayloadCodec paramsCodec, + }) { + return definition.refWithCodec(paramsCodec: paramsCodec); + } + /// Builds a typed [NoArgsWorkflowRef] for flows without start params. NoArgsWorkflowRef ref0() { return definition.ref0(); diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index b40a2fec..9597d11b 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -305,6 +305,16 @@ class WorkflowDefinition { ); } + /// Builds a typed [WorkflowRef] backed by a DTO [paramsCodec]. + WorkflowRef refWithCodec({ + required PayloadCodec paramsCodec, + }) { + return WorkflowRef.withPayloadCodec( + name: name, + paramsCodec: paramsCodec, + ); + } + /// Builds a typed [NoArgsWorkflowRef] from this definition. NoArgsWorkflowRef ref0() { return NoArgsWorkflowRef( diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 5ed813e3..e5ff9c3f 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -1,3 +1,4 @@ +import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; import 'package:stem/src/workflow/core/workflow_result.dart'; @@ -14,6 +15,19 @@ class WorkflowRef { this.decodeResult, }); + /// Creates a typed workflow reference backed by payload codecs. + factory WorkflowRef.withPayloadCodec({ + required String name, + required PayloadCodec paramsCodec, + PayloadCodec? resultCodec, + }) { + return WorkflowRef( + name: name, + encodeParams: (params) => _encodeCodecParams(name, paramsCodec, params), + decodeResult: resultCodec?.decode, + ); + } + /// Registered workflow name. final String name; @@ -23,6 +37,35 @@ class WorkflowRef { /// Optional decoder for the final workflow result payload. final TResult Function(Object? payload)? decodeResult; + static Map _encodeCodecParams( + String workflowName, + PayloadCodec codec, + T params, + ) { + final payload = codec.encode(params); + if (payload is Map) { + return Map.from(payload); + } + if (payload is Map) { + final normalized = {}; + for (final entry in payload.entries) { + final key = entry.key; + if (key is! String) { + throw StateError( + 'WorkflowRef.withPayloadCodec($workflowName) requires payload ' + 'keys to be strings, got ${key.runtimeType}.', + ); + } + normalized[key] = entry.value; + } + return normalized; + } + throw StateError( + 'WorkflowRef.withPayloadCodec($workflowName) must encode params to ' + 'Map, got ${payload.runtimeType}.', + ); + } + /// Builds a workflow start call from typed arguments. WorkflowStartCall call( TParams params, { diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index b694f2f3..f408c1e8 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -40,6 +40,13 @@ class WorkflowScript { return definition.ref(encodeParams: encodeParams); } + /// Builds a typed [WorkflowRef] backed by a DTO [paramsCodec]. + WorkflowRef refWithCodec({ + required PayloadCodec paramsCodec, + }) { + return definition.refWithCodec(paramsCodec: paramsCodec); + } + /// Builds a typed [NoArgsWorkflowRef] for scripts without start params. NoArgsWorkflowRef ref0() { return definition.ref0(); diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index d771dd5a..581bfc71 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -1,6 +1,31 @@ import 'package:stem/stem.dart'; import 'package:test/test.dart'; +class _GreetingParams { + const _GreetingParams({required this.name}); + + factory _GreetingParams.fromJson(Map json) { + return _GreetingParams(name: json['name']! as String); + } + + final String name; + + Map toJson() => {'name': name}; +} + +const _greetingParamsCodec = PayloadCodec<_GreetingParams>( + encode: _encodeGreetingParams, + decode: _decodeGreetingParams, +); + +Object? _encodeGreetingParams(_GreetingParams value) => value.toJson(); + +_GreetingParams _decodeGreetingParams(Object? payload) { + return _GreetingParams.fromJson( + Map.from(payload! as Map), + ); +} + void main() { group('runtime workflow refs', () { test('start and wait helpers work directly with WorkflowRuntime', () async { @@ -81,6 +106,36 @@ void main() { } }); + test('manual workflows can derive codec-backed refs', () async { + final flow = Flow( + name: 'runtime.ref.codec.flow', + build: (builder) { + builder.step('hello', (ctx) async { + final name = ctx.params['name'] as String? ?? 'world'; + return 'hello $name'; + }); + }, + ); + final workflowRef = flow.refWithCodec<_GreetingParams>( + paramsCodec: _greetingParamsCodec, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + await workflowApp.start(); + + final result = await workflowRef.startAndWaitWithRuntime( + workflowApp.runtime, + const _GreetingParams(name: 'codec'), + timeout: const Duration(seconds: 2), + ); + + expect(result?.value, 'hello codec'); + } finally { + await workflowApp.shutdown(); + } + }); + test('manual workflows can derive no-args refs', () async { final flow = Flow( name: 'runtime.ref.no-args.flow', From 8d25b08768f218ecb220343952ffb51ba60df896 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 18:11:24 -0500 Subject: [PATCH 027/302] Add codec-backed manual task definitions --- .site/docs/core-concepts/tasks.md | 5 ++ packages/stem/README.md | 21 +++++- .../stem/example/docs_snippets/lib/tasks.dart | 32 +++++++-- packages/stem/lib/src/core/contracts.dart | 72 +++++++++++++++++++ .../stem/test/unit/core/stem_core_test.dart | 68 ++++++++++++++++++ 5 files changed, 190 insertions(+), 8 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 57e162b2..59135524 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -49,6 +49,11 @@ Typed results flow through `TaskResult` when you call `Canvas.chord`. Supplying a custom `decode` callback on the task signature lets you deserialize complex objects before they reach application code. +If your manual task args are DTOs, prefer +`TaskDefinition.withPayloadCodec(...)` over hand-written `encodeArgs` maps. The +codec still needs to encode to `Map` because task args are +published as a map. + For tasks with no producer inputs, use `TaskDefinition.noArgs(...)` instead. That gives you direct `enqueueWith(...)` / `enqueueAndWaitWith(...)` helpers without passing a fake empty map and the same diff --git a/packages/stem/README.md b/packages/stem/README.md index 7b3c4eec..a46b3cd2 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -154,9 +154,9 @@ Use the new typed wrapper when you want compile-time checking and shared metadat ```dart class HelloTask implements TaskHandler { - static final definition = TaskDefinition( + static final definition = TaskDefinition.withPayloadCodec( name: 'demo.hello', - encodeArgs: (args) => {'name': args.name}, + argsCodec: helloArgsCodec, metadata: TaskMetadata(description: 'Simple hello world example'), ); @@ -179,8 +179,21 @@ class HelloTask implements TaskHandler { class HelloArgs { const HelloArgs({required this.name}); final String name; + + Map toJson() => {'name': name}; + + factory HelloArgs.fromJson(Map json) { + return HelloArgs(name: json['name']! as String); + } } +const helloArgsCodec = PayloadCodec( + encode: (value) => value.toJson(), + decode: (payload) => HelloArgs.fromJson( + Map.from(payload! as Map), + ), +); + Future main() async { final broker = await RedisStreamsBroker.connect('redis://localhost:6379'); final backend = await RedisResultBackend.connect('redis://localhost:6379/1'); @@ -207,6 +220,10 @@ Future main() async { producer-only processes do not need to register the worker handler locally just to enqueue typed calls. +Use `TaskDefinition.withPayloadCodec(...)` when your manual task args are DTOs +that already have a `PayloadCodec`. The codec still needs to encode to +`Map` because task args are published as a map. + For typed task calls, the definition and call objects now expose the common producer operations directly: diff --git a/packages/stem/example/docs_snippets/lib/tasks.dart b/packages/stem/example/docs_snippets/lib/tasks.dart index 29a289b7..60e75348 100644 --- a/packages/stem/example/docs_snippets/lib/tasks.dart +++ b/packages/stem/example/docs_snippets/lib/tasks.dart @@ -50,15 +50,35 @@ final redisTasks = [RedisEmailTask()]; class InvoicePayload { const InvoicePayload({required this.invoiceId}); final String invoiceId; + + Map toJson() => {'invoiceId': invoiceId}; + + factory InvoicePayload.fromJson(Map json) { + return InvoicePayload(invoiceId: json['invoiceId']! as String); + } +} + +const invoicePayloadCodec = PayloadCodec( + encode: _encodeInvoicePayload, + decode: _decodeInvoicePayload, +); + +Object? _encodeInvoicePayload(InvoicePayload value) => value.toJson(); + +InvoicePayload _decodeInvoicePayload(Object? payload) { + return InvoicePayload.fromJson(Map.from(payload! as Map)); } class PublishInvoiceTask extends TaskHandler { - static final definition = TaskDefinition( - name: 'invoice.publish', - encodeArgs: (payload) => {'invoiceId': payload.invoiceId}, - metadata: const TaskMetadata(description: 'Publishes invoices downstream'), - defaultOptions: const TaskOptions(queue: 'billing'), - ); + static final definition = + TaskDefinition.withPayloadCodec( + name: 'invoice.publish', + argsCodec: invoicePayloadCodec, + metadata: const TaskMetadata( + description: 'Publishes invoices downstream', + ), + defaultOptions: const TaskOptions(queue: 'billing'), + ); @override String get name => definition.name; diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index def32151..b76fb5ca 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -35,6 +35,7 @@ import 'dart:collection'; import 'package:stem/src/core/clock.dart'; import 'package:stem/src/core/envelope.dart'; +import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/core/task_invocation.dart'; import 'package:stem/src/core/task_payload_encoder.dart'; import 'package:stem/src/observability/heartbeat.dart'; @@ -2000,6 +2001,25 @@ class TaskDefinition { }) : _encodeArgs = encodeArgs, _encodeMeta = encodeMeta; + /// Creates a typed task definition backed by payload codecs. + factory TaskDefinition.withPayloadCodec({ + required String name, + required PayloadCodec argsCodec, + TaskMetaBuilder? encodeMeta, + TaskOptions defaultOptions = const TaskOptions(), + TaskMetadata metadata = const TaskMetadata(), + PayloadCodec? resultCodec, + }) { + return TaskDefinition( + name: name, + encodeArgs: (args) => _encodeCodecArgs(name, argsCodec, args), + encodeMeta: encodeMeta, + defaultOptions: defaultOptions, + metadata: _metadataWithResultCodec(name, metadata, resultCodec), + decodeResult: resultCodec?.decode, + ); + } + /// Creates a typed task definition for handlers with no producer args. static NoArgsTaskDefinition noArgs({ required String name, @@ -2030,6 +2050,58 @@ class TaskDefinition { final TaskArgsEncoder _encodeArgs; final TaskMetaBuilder? _encodeMeta; + static Map _encodeCodecArgs( + String taskName, + PayloadCodec codec, + T args, + ) { + final payload = codec.encode(args); + if (payload is Map) { + return Map.from(payload); + } + if (payload is Map) { + final normalized = {}; + for (final entry in payload.entries) { + final key = entry.key; + if (key is! String) { + throw StateError( + 'TaskDefinition.withPayloadCodec($taskName) requires payload keys ' + 'to be strings, got ${key.runtimeType}.', + ); + } + normalized[key] = entry.value; + } + return normalized; + } + throw StateError( + 'TaskDefinition.withPayloadCodec($taskName) must encode args to ' + 'Map, got ${payload.runtimeType}.', + ); + } + + static TaskMetadata _metadataWithResultCodec( + String taskName, + TaskMetadata metadata, + PayloadCodec? resultCodec, + ) { + if (resultCodec == null) { + return metadata; + } + return TaskMetadata( + description: metadata.description, + tags: metadata.tags, + idempotent: metadata.idempotent, + attributes: metadata.attributes, + argsEncoder: metadata.argsEncoder, + resultEncoder: + metadata.resultEncoder ?? + CodecTaskPayloadEncoder( + idValue: '$taskName.result.codec', + codec: resultCodec, + ), + ); + } + /// Build a typed call which can be passed to `Stem.enqueueCall`. TaskCall call( TArgs args, { diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index b8b9ba16..90998f50 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -100,6 +100,29 @@ void main() { }, ); + test('enqueueCall publishes codec-backed task definitions', () async { + final broker = _RecordingBroker(); + final backend = _RecordingBackend(); + final stem = Stem(broker: broker, backend: backend); + final definition = + TaskDefinition<_CodecTaskArgs, Object?>.withPayloadCodec( + name: 'sample.codec.args', + argsCodec: _codecTaskArgsCodec, + defaultOptions: const TaskOptions(queue: 'typed'), + ); + + final id = await stem.enqueueCall( + definition.call(const _CodecTaskArgs('encoded')), + ); + + expect(id, isNotEmpty); + expect(broker.published.single.envelope.name, 'sample.codec.args'); + expect(broker.published.single.envelope.queue, 'typed'); + expect(broker.published.single.envelope.args, {'value': 'encoded'}); + expect(backend.records.single.id, id); + expect(backend.records.single.state, TaskState.queued); + }); + test( 'enqueueCall uses definition encoder metadata on producer-only paths', () async { @@ -138,6 +161,31 @@ void main() { }, ); + test( + 'codec-backed task definitions attach result encoder metadata by default', + () async { + final broker = _RecordingBroker(); + final backend = _RecordingBackend(); + final stem = Stem(broker: broker, backend: backend); + final definition = + TaskDefinition<_CodecTaskArgs, _CodecReceipt>.withPayloadCodec( + name: 'sample.codec.result', + argsCodec: _codecTaskArgsCodec, + resultCodec: _codecReceiptCodec, + ); + + final id = await stem.enqueueCall( + definition.call(const _CodecTaskArgs('encoded')), + ); + + expect( + backend.records.single.meta[stemResultEncoderMetaKey], + endsWith('.result.codec'), + ); + expect(backend.records.single.id, id); + }, + ); + test( 'enqueueCall publishes no-arg definitions without fake empty maps', () async { @@ -381,6 +429,26 @@ _CodecReceipt _decodeCodecReceipt(Object? payload) { return _CodecReceipt.fromJson(Map.from(payload! as Map)); } +class _CodecTaskArgs { + const _CodecTaskArgs(this.value); + + final String value; + + Map toJson() => {'value': value}; +} + +const _codecTaskArgsCodec = PayloadCodec<_CodecTaskArgs>( + encode: _encodeCodecTaskArgs, + decode: _decodeCodecTaskArgs, +); + +Object? _encodeCodecTaskArgs(_CodecTaskArgs value) => value.toJson(); + +_CodecTaskArgs _decodeCodecTaskArgs(Object? payload) { + final map = Map.from(payload! as Map); + return _CodecTaskArgs(map['value']! as String); +} + class _StubTaskHandler implements TaskHandler { @override String get name => 'sample.task'; From b57c4894fec41106a0818c0444178af8b9b026b7 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 18:13:17 -0500 Subject: [PATCH 028/302] Add direct typed task definition helpers --- .site/docs/core-concepts/tasks.md | 5 +- packages/stem/README.md | 12 ++-- .../stem/example/docs_snippets/lib/tasks.dart | 5 +- packages/stem/lib/src/core/stem.dart | 45 +++++++++++++++ .../stem/test/unit/core/stem_core_test.dart | 55 +++++++++++++++++++ 5 files changed, 114 insertions(+), 8 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 59135524..63c33d74 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -38,7 +38,10 @@ Stem ships with `TaskDefinition` so producers get compile-time checks for required arguments and result types. A definition bundles the task name, argument encoder, optional metadata, and default `TaskOptions`. Build a call with `.call(args)` or `TaskEnqueueBuilder` and hand it to `Stem.enqueueCall` -or `Canvas` helpers: +or `Canvas` helpers. For the common path, use the direct +`definition.enqueueWith(stem, args)` / `definition.enqueueAndWaitWith(...)` +helpers and drop down to `.call(args)` only when you need a reusable prebuilt +request: ```dart file=/../packages/stem/example/docs_snippets/lib/tasks.dart#tasks-typed-definition diff --git a/packages/stem/README.md b/packages/stem/README.md index a46b3cd2..1dae4282 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -206,8 +206,9 @@ Future main() async { ); unawaited(worker.start()); - await stem.enqueueCall( - HelloTask.definition(const HelloArgs(name: 'Stem')), + await HelloTask.definition.enqueueWith( + stem, + const HelloArgs(name: 'Stem'), ); await Future.delayed(const Duration(seconds: 1)); await worker.shutdown(); @@ -228,9 +229,10 @@ For typed task calls, the definition and call objects now expose the common producer operations directly: ```dart -final taskId = await HelloTask.definition - .call(const HelloArgs(name: 'Stem')) - .enqueueWith(stem); +final taskId = await HelloTask.definition.enqueueWith( + stem, + const HelloArgs(name: 'Stem'), +); final result = await HelloTask.definition.waitFor(stem, taskId); ``` diff --git a/packages/stem/example/docs_snippets/lib/tasks.dart b/packages/stem/example/docs_snippets/lib/tasks.dart index 60e75348..f7e3e7e3 100644 --- a/packages/stem/example/docs_snippets/lib/tasks.dart +++ b/packages/stem/example/docs_snippets/lib/tasks.dart @@ -102,8 +102,9 @@ Future runTypedDefinitionExample() async { tasks: [PublishInvoiceTask()], ); - final taskId = await stem.enqueueCall( - PublishInvoiceTask.definition(const InvoicePayload(invoiceId: 'inv_42')), + final taskId = await PublishInvoiceTask.definition.enqueueWith( + stem, + const InvoicePayload(invoiceId: 'inv_42'), ); final result = await stem.waitForTask(taskId); if (result?.isSucceeded == true) { diff --git a/packages/stem/lib/src/core/stem.dart b/packages/stem/lib/src/core/stem.dart index 77dc9c59..2d257e98 100644 --- a/packages/stem/lib/src/core/stem.dart +++ b/packages/stem/lib/src/core/stem.dart @@ -1058,6 +1058,51 @@ extension TaskCallExtension /// Convenience helpers for waiting on typed task definitions. extension TaskDefinitionExtension on TaskDefinition { + /// Enqueues this typed task definition directly with [enqueuer]. + Future enqueueWith( + TaskEnqueuer enqueuer, + TArgs args, { + Map headers = const {}, + TaskOptions? options, + DateTime? notBefore, + Map? meta, + TaskEnqueueOptions? enqueueOptions, + }) { + return call( + args, + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ).enqueueWith(enqueuer, enqueueOptions: enqueueOptions); + } + + /// Enqueues this typed task definition and waits for its typed result. + Future?> enqueueAndWaitWith( + Stem stem, + TArgs args, { + Map headers = const {}, + TaskOptions? options, + DateTime? notBefore, + Map? meta, + TaskEnqueueOptions? enqueueOptions, + Duration? timeout, + }) { + return call( + args, + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ).enqueueAndWaitWith( + stem, + enqueueOptions: enqueueOptions, + timeout: timeout, + ); + } + /// Waits for [taskId] using this definition's decoding rules. Future?> waitFor( Stem stem, diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index 90998f50..b1b5ab19 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -210,6 +210,32 @@ void main() { }); group('TaskCall helpers', () { + test('TaskDefinition.enqueueWith enqueues typed args directly', () async { + final broker = _RecordingBroker(); + final backend = _RecordingBackend(); + final stem = Stem(broker: broker, backend: backend); + final definition = TaskDefinition<({String value}), String>( + name: 'sample.task_definition_enqueue', + encodeArgs: (args) => {'value': args.value}, + defaultOptions: const TaskOptions(queue: 'typed'), + ); + + final taskId = await TaskEnqueueScope.run({'traceId': 'scope-1'}, () { + return definition.enqueueWith(stem, (value: 'ok')); + }); + + expect(taskId, isNotEmpty); + expect( + broker.published.single.envelope.name, + 'sample.task_definition_enqueue', + ); + expect(broker.published.single.envelope.queue, 'typed'); + expect( + broker.published.single.envelope.meta, + containsPair('traceId', 'scope-1'), + ); + }); + test('enqueueWith enqueues typed calls with scoped metadata', () async { final broker = _RecordingBroker(); final backend = _RecordingBackend(); @@ -259,6 +285,35 @@ void main() { expect(result?.isSucceeded, isTrue); expect(result?.value, 'done'); }); + + test('TaskDefinition.enqueueAndWaitWith returns typed results', () async { + final broker = _RecordingBroker(); + final backend = _RecordingBackend(); + final stem = Stem(broker: broker, backend: backend); + final definition = TaskDefinition<({String value}), String>( + name: 'sample.task_definition_wait', + encodeArgs: (args) => {'value': args.value}, + ); + + unawaited( + Future(() async { + while (broker.published.isEmpty) { + await Future.delayed(Duration.zero); + } + final taskId = broker.published.single.envelope.id; + await backend.set(taskId, TaskState.succeeded, payload: 'done'); + }), + ); + + final result = await definition.enqueueAndWaitWith( + stem, + (value: 'ok'), + timeout: const Duration(seconds: 1), + ); + + expect(result?.isSucceeded, isTrue); + expect(result?.value, 'done'); + }); }); group('TaskDefinition.waitFor', () { From aa2498b8d8eeac784ca20fc82edb75671cf0bc24 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 18:15:06 -0500 Subject: [PATCH 029/302] Use direct task definition helpers in examples --- .../example/docs_snippets/lib/producer.dart | 5 ++-- .../task_context_mixed/lib/shared.dart | 24 +++++++++---------- .../stem/example/task_usage_patterns.dart | 17 +++++++------ 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/stem/example/docs_snippets/lib/producer.dart b/packages/stem/example/docs_snippets/lib/producer.dart index 3105e629..970ee1f1 100644 --- a/packages/stem/example/docs_snippets/lib/producer.dart +++ b/packages/stem/example/docs_snippets/lib/producer.dart @@ -131,13 +131,12 @@ Future enqueueTyped() async { final app = await StemApp.inMemory(tasks: [GenerateReportTask()]); await app.start(); - final call = GenerateReportTask.definition.call( + final taskId = await GenerateReportTask.definition.enqueueWith( + app.stem, const ReportPayload(reportId: 'monthly-2025-10'), options: const TaskOptions(priority: 5), headers: const {'x-requested-by': 'analytics'}, ); - - final taskId = await app.stem.enqueueCall(call); final result = await app.stem.waitForTask(taskId); print(result?.value); await app.close(); diff --git a/packages/stem/example/task_context_mixed/lib/shared.dart b/packages/stem/example/task_context_mixed/lib/shared.dart index 55d28352..a3130379 100644 --- a/packages/stem/example/task_context_mixed/lib/shared.dart +++ b/packages/stem/example/task_context_mixed/lib/shared.dart @@ -218,12 +218,11 @@ class InlineCoordinatorTask extends TaskHandler { ), ); - final auditId = await context.enqueueCall( - auditDefinition.call( - AuditArgs( - runId: runId, - message: 'inline parent scheduled child tasks', - ), + final auditId = await auditDefinition.enqueueWith( + context, + AuditArgs( + runId: runId, + message: 'inline parent scheduled child tasks', ), ); @@ -286,14 +285,13 @@ FutureOr inlineEntrypoint( '[inline_entrypoint] id=${context.id} attempt=${context.attempt} runId=$runId meta=${context.meta}', ); - await context.enqueueCall( - auditDefinition.call( - AuditArgs( - runId: runId, - message: 'inline entrypoint completed', - ), - enqueueOptions: const TaskEnqueueOptions(priority: 4), + await auditDefinition.enqueueWith( + context, + AuditArgs( + runId: runId, + message: 'inline entrypoint completed', ), + enqueueOptions: const TaskEnqueueOptions(priority: 4), ); return 'inline-ok'; diff --git a/packages/stem/example/task_usage_patterns.dart b/packages/stem/example/task_usage_patterns.dart index 80341b02..29415e3c 100644 --- a/packages/stem/example/task_usage_patterns.dart +++ b/packages/stem/example/task_usage_patterns.dart @@ -37,7 +37,7 @@ class ParentTask extends TaskHandler { ), ); - await context.enqueueCall(childDefinition.call(const ChildArgs('typed'))); + await childDefinition.enqueueWith(context, const ChildArgs('typed')); } } @@ -99,9 +99,10 @@ Future main() async { await stem.enqueue('tasks.parent', args: const {}); await stem.enqueue('tasks.invocation_parent', args: const {}); - final directTaskId = await childDefinition - .call(const ChildArgs('direct-call')) - .enqueueWith(stem); + final directTaskId = await childDefinition.enqueueWith( + stem, + const ChildArgs('direct-call'), + ); final directResult = await childDefinition.waitFor( stem, directTaskId, @@ -111,9 +112,11 @@ Future main() async { // ignore: avoid_print print('[direct] result=${directResult?.value}'); - final inlineResult = await childDefinition - .call(const ChildArgs('inline-wait')) - .enqueueAndWaitWith(stem, timeout: const Duration(seconds: 1)); + final inlineResult = await childDefinition.enqueueAndWaitWith( + stem, + const ChildArgs('inline-wait'), + timeout: const Duration(seconds: 1), + ); // Example output keeps the script runnable without adding logging setup. // ignore: avoid_print print('[inline] result=${inlineResult?.value}'); From 3c89dbdcd2d142ae1aecfce76953142977fde20b Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 18:16:43 -0500 Subject: [PATCH 030/302] Preserve result decoding for codec workflow refs --- .../workflow/core/workflow_definition.dart | 1 + .../lib/src/workflow/core/workflow_ref.dart | 3 +- .../workflow/workflow_runtime_ref_test.dart | 54 +++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index 9597d11b..4476365d 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -312,6 +312,7 @@ class WorkflowDefinition { return WorkflowRef.withPayloadCodec( name: name, paramsCodec: paramsCodec, + decodeResult: (payload) => decodeResult(payload) as T, ); } diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index e5ff9c3f..c5fe3521 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -20,11 +20,12 @@ class WorkflowRef { required String name, required PayloadCodec paramsCodec, PayloadCodec? resultCodec, + TResult Function(Object? payload)? decodeResult, }) { return WorkflowRef( name: name, encodeParams: (params) => _encodeCodecParams(name, paramsCodec, params), - decodeResult: resultCodec?.decode, + decodeResult: decodeResult ?? resultCodec?.decode, ); } diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 581bfc71..ee42d3d2 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -13,11 +13,28 @@ class _GreetingParams { Map toJson() => {'name': name}; } +class _GreetingResult { + const _GreetingResult({required this.message}); + + factory _GreetingResult.fromJson(Map json) { + return _GreetingResult(message: json['message']! as String); + } + + final String message; + + Map toJson() => {'message': message}; +} + const _greetingParamsCodec = PayloadCodec<_GreetingParams>( encode: _encodeGreetingParams, decode: _decodeGreetingParams, ); +const _greetingResultCodec = PayloadCodec<_GreetingResult>( + encode: _encodeGreetingResult, + decode: _decodeGreetingResult, +); + Object? _encodeGreetingParams(_GreetingParams value) => value.toJson(); _GreetingParams _decodeGreetingParams(Object? payload) { @@ -26,6 +43,12 @@ _GreetingParams _decodeGreetingParams(Object? payload) { ); } +Object? _encodeGreetingResult(_GreetingResult value) => value.toJson(); + +_GreetingResult _decodeGreetingResult(Object? payload) { + return _GreetingResult.fromJson(Map.from(payload! as Map)); +} + void main() { group('runtime workflow refs', () { test('start and wait helpers work directly with WorkflowRuntime', () async { @@ -136,6 +159,37 @@ void main() { } }); + test('codec-backed refs preserve workflow result decoding', () async { + final flow = Flow<_GreetingResult>( + name: 'runtime.ref.codec.result.flow', + resultCodec: _greetingResultCodec, + build: (builder) { + builder.step('hello', (ctx) async { + final name = ctx.params['name'] as String? ?? 'world'; + return _GreetingResult(message: 'hello $name'); + }); + }, + ); + final workflowRef = flow.refWithCodec<_GreetingParams>( + paramsCodec: _greetingParamsCodec, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + await workflowApp.start(); + + final result = await workflowRef.startAndWaitWithRuntime( + workflowApp.runtime, + const _GreetingParams(name: 'codec'), + timeout: const Duration(seconds: 2), + ); + + expect(result?.value?.message, 'hello codec'); + } finally { + await workflowApp.shutdown(); + } + }); + test('manual workflows can derive no-args refs', () async { final flow = Flow( name: 'runtime.ref.no-args.flow', From 1fad61d4ca26b4524bde1ed2160bc2a432a3aea5 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 18:17:43 -0500 Subject: [PATCH 031/302] Support result codecs for no-arg task definitions --- packages/stem/README.md | 3 ++ packages/stem/lib/src/core/contracts.dart | 9 +++-- .../stem/test/unit/core/stem_core_test.dart | 33 ++++++++++++++++--- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/stem/README.md b/packages/stem/README.md index 1dae4282..ae458ded 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -248,6 +248,9 @@ final healthcheckDefinition = TaskDefinition.noArgs( await healthcheckDefinition.enqueueWith(stem); ``` +If a no-arg task returns a DTO, pass `resultCodec:` so waiting helpers decode +the result and the task metadata advertises the right result encoder. + You can also build requests fluently with the `TaskEnqueueBuilder`: ```dart diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index b76fb5ca..c874d2b7 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2026,12 +2026,17 @@ class TaskDefinition { TaskOptions defaultOptions = const TaskOptions(), TaskMetadata metadata = const TaskMetadata(), TaskResultDecoder? decodeResult, + PayloadCodec? resultCodec, }) { return NoArgsTaskDefinition( name: name, defaultOptions: defaultOptions, - metadata: metadata, - decodeResult: decodeResult, + metadata: TaskDefinition._metadataWithResultCodec( + name, + metadata, + resultCodec, + ), + decodeResult: decodeResult ?? resultCodec?.decode, ); } diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index b1b5ab19..ae8d53db 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -207,6 +207,27 @@ void main() { expect(backend.records.single.state, TaskState.queued); }, ); + + test( + 'no-arg task definitions can attach codec-backed result metadata', + () async { + final broker = _RecordingBroker(); + final backend = _RecordingBackend(); + final stem = Stem(broker: broker, backend: backend); + final definition = TaskDefinition.noArgs<_CodecReceipt>( + name: 'sample.no_args.codec', + resultCodec: _codecReceiptCodec, + ); + + final id = await definition.enqueueWith(stem); + + expect( + backend.records.single.meta[stemResultEncoderMetaKey], + endsWith('.result.codec'), + ); + expect(backend.records.single.id, id); + }, + ); }); group('TaskCall helpers', () { @@ -340,18 +361,22 @@ void main() { test('supports no-arg task definitions', () async { final backend = InMemoryResultBackend(); final stem = Stem(broker: _RecordingBroker(), backend: backend); - final definition = TaskDefinition.noArgs(name: 'no-args.wait'); + final definition = TaskDefinition.noArgs<_CodecReceipt>( + name: 'no-args.wait', + resultCodec: _codecReceiptCodec, + ); await backend.set( 'task-no-args-wait', TaskState.succeeded, - payload: 'done', + payload: const _CodecReceipt('done'), + meta: {stemResultEncoderMetaKey: _codecReceiptEncoder.id}, ); final result = await definition.waitFor(stem, 'task-no-args-wait'); - expect(result?.value, 'done'); - expect(result?.rawPayload, 'done'); + expect(result?.value?.id, 'done'); + expect(result?.rawPayload, isA<_CodecReceipt>()); }); test('enqueueAndWaitWith supports no-arg task definitions', () async { From d89a7dda579e87a0e0e36a19cef00a03584ee362 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 18:25:39 -0500 Subject: [PATCH 032/302] Add typed workflow event refs --- .../docs/workflows/suspensions-and-events.md | 5 +- packages/stem/README.md | 7 +- packages/stem/example/durable_watchers.dart | 22 +- .../example/workflows/sleep_and_event.dart | 12 +- .../stem/lib/src/bootstrap/workflow_app.dart | 6 + .../src/workflow/core/workflow_event_ref.dart | 20 ++ .../src/workflow/core/workflow_resume.dart | 32 +++ .../workflow/runtime/workflow_runtime.dart | 19 +- packages/stem/lib/src/workflow/workflow.dart | 1 + .../unit/workflow/workflow_resume_test.dart | 53 +++++ .../test/workflow/workflow_runtime_test.dart | 189 +++++++++++------- 11 files changed, 273 insertions(+), 93 deletions(-) create mode 100644 packages/stem/lib/src/workflow/core/workflow_event_ref.dart diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index 5046d114..40a8bc89 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -34,7 +34,7 @@ Typical flow: `WorkflowRuntime.emitValue(...)` (or an app/service wrapper around it) with a payload 4. the runtime resumes the run and exposes the payload through - `waitForEventValue(...)` or the lower-level + `waitForEventValue(...)`, `waitForEventRef(...)`, or the lower-level `takeResumeData()` / `takeResumeValue(codec: ...)` For the common "wait for one event and continue" case, prefer: @@ -65,6 +65,9 @@ Typed event payloads still serialize to the existing `Map` wire format. `emitValue(...)` is a DTO/codec convenience layer, not a new transport shape. +When the topic and codec travel together in your codebase, prefer a typed +`WorkflowEventRef` and `emitEvent(...)` / `waitForEventRef(...)`. + ## Inspect waiting runs The workflow store can tell you which runs are waiting on a topic: diff --git a/packages/stem/README.md b/packages/stem/README.md index ae458ded..48469b3c 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -373,6 +373,8 @@ Inside a script checkpoint you can access the same metadata as `FlowContext`: - `step.sleepUntilResumed(...)` handles the common sleep-once, continue-on- resume path. - `step.waitForEventValue(...)` handles the common wait-for-one-event path. +- `step.waitForEventRef(...)` handles the same path when you already have a + typed `WorkflowEventRef`. - `step.takeResumeData()` and `step.takeResumeValue(codec: ...)` surface payloads from sleeps or awaited events when you need lower-level control. @@ -992,8 +994,9 @@ backend metadata under `stem.unique.duplicates`. - Awaited events behave the same way: the emitted payload is delivered via `takeResumeData()` / `takeResumeValue(codec: ...)` when the run resumes. - When you have a DTO event, emit it through `runtime.emitValue(...)` / - `workflowApp.emitValue(...)` with a `PayloadCodec` instead of hand-building - the payload map. Event payloads still serialize onto the existing + `workflowApp.emitValue(...)` with a `PayloadCodec`, or bundle the topic + and codec once in a `WorkflowEventRef` and use `emitEvent(...)` / + `waitForEventRef(...)`. Event payloads still serialize onto the existing `Map` wire format. - Only return values you want persisted. If a handler returns `null`, the runtime treats it as "no result yet" and will run the step again on resume. diff --git a/packages/stem/example/durable_watchers.dart b/packages/stem/example/durable_watchers.dart index 2170d625..1f537733 100644 --- a/packages/stem/example/durable_watchers.dart +++ b/packages/stem/example/durable_watchers.dart @@ -4,6 +4,10 @@ final shipmentReadyEventCodec = PayloadCodec<_ShipmentReadyEvent>( encode: (value) => value.toJson(), decode: _ShipmentReadyEvent.fromJson, ); +final shipmentReadyEvent = WorkflowEventRef<_ShipmentReadyEvent>( + topic: 'shipment.ready', + codec: shipmentReadyEventCodec, +); /// Runs a workflow that suspends on `awaitEvent` and resumes once a payload is /// emitted. The example also inspects watcher metadata before the resume. @@ -17,15 +21,12 @@ Future main() async { }); final trackingId = await script.step('wait-for-shipment', (step) async { - final payload = step.takeResumeValue<_ShipmentReadyEvent>( - codec: shipmentReadyEventCodec, + final payload = step.waitForEventRef( + shipmentReadyEvent, + deadline: DateTime.now().add(const Duration(minutes: 5)), + data: const {'reason': 'waiting-for-carrier'}, ); if (payload == null) { - await step.awaitEvent( - 'shipment.ready', - deadline: DateTime.now().add(const Duration(minutes: 5)), - data: const {'reason': 'waiting-for-carrier'}, - ); return 'waiting'; } return payload.trackingId; @@ -48,7 +49,7 @@ Future main() async { // Drive the run until it suspends on the watcher. await app.runtime.executeRun(runId); - final watchers = await app.store.listWatchers('shipment.ready'); + final watchers = await app.store.listWatchers(shipmentReadyEvent.topic); for (final watcher in watchers) { print( 'Run ${watcher.runId} waiting on ${watcher.topic} (step ${watcher.stepName})', @@ -56,10 +57,9 @@ Future main() async { print('Watcher metadata: ${watcher.data}'); } - await app.emitValue( - 'shipment.ready', + await app.emitEvent( + shipmentReadyEvent, const _ShipmentReadyEvent(trackingId: 'ZX-42'), - codec: shipmentReadyEventCodec, ); await app.runtime.executeRun(runId); diff --git a/packages/stem/example/workflows/sleep_and_event.dart b/packages/stem/example/workflows/sleep_and_event.dart index fafa5f2a..46e730fe 100644 --- a/packages/stem/example/workflows/sleep_and_event.dart +++ b/packages/stem/example/workflows/sleep_and_event.dart @@ -5,6 +5,10 @@ import 'dart:async'; import 'package:stem/stem.dart'; +const demoEvent = WorkflowEventRef>( + topic: 'demo.event', +); + Future main() async { final sleepAndEvent = Flow( name: 'durable.sleep.event', @@ -17,9 +21,7 @@ Future main() async { }); flow.step('await-event', (ctx) async { - final payload = ctx.waitForEventValue>( - 'demo.event', - ); + final payload = ctx.waitForEventRef(demoEvent); if (payload == null) { return null; } @@ -39,13 +41,13 @@ Future main() async { // losing the signal. while (true) { final state = await app.getRun(runId); - if (state?.waitTopic == 'demo.event') { + if (state?.waitTopic == demoEvent.topic) { break; } await Future.delayed(const Duration(milliseconds: 50)); } - await app.runtime.emit('demo.event', {'message': 'event received'}); + await app.emitEvent(demoEvent, {'message': 'event received'}); final result = await sleepAndEventRef.waitFor(app, runId); print('Workflow $runId resumed and completed with: ${result?.value}'); diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index afaf7505..9efba845 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -14,6 +14,7 @@ import 'package:stem/src/workflow/core/flow.dart'; import 'package:stem/src/workflow/core/run_state.dart'; import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; import 'package:stem/src/workflow/core/workflow_definition.dart'; +import 'package:stem/src/workflow/core/workflow_event_ref.dart'; import 'package:stem/src/workflow/core/workflow_ref.dart'; import 'package:stem/src/workflow/core/workflow_result.dart'; import 'package:stem/src/workflow/core/workflow_script.dart'; @@ -170,6 +171,11 @@ class StemWorkflowApp implements WorkflowCaller { return runtime.emitValue(topic, value, codec: codec); } + /// Emits a typed event through a [WorkflowEventRef]. + Future emitEvent(WorkflowEventRef event, T value) { + return runtime.emitEvent(event, value); + } + /// Returns the current [RunState] of a workflow run, or `null` if not found. /// /// Example: diff --git a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart new file mode 100644 index 00000000..86cae4bf --- /dev/null +++ b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart @@ -0,0 +1,20 @@ +import 'package:stem/src/core/payload_codec.dart'; + +/// Typed reference to a workflow resume event topic. +/// +/// This bundles the durable topic name with an optional payload codec so +/// callers do not need to repeat a raw topic string and separate codec across +/// wait and emit sites. +class WorkflowEventRef { + /// Creates a typed workflow event reference. + const WorkflowEventRef({ + required this.topic, + this.codec, + }); + + /// Durable topic name used to suspend and resume workflow runs. + final String topic; + + /// Optional codec for encoding and decoding event payloads. + final PayloadCodec? codec; +} diff --git a/packages/stem/lib/src/workflow/core/workflow_resume.dart b/packages/stem/lib/src/workflow/core/workflow_resume.dart index b83c9330..327fca35 100644 --- a/packages/stem/lib/src/workflow/core/workflow_resume.dart +++ b/packages/stem/lib/src/workflow/core/workflow_resume.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/workflow/core/flow_context.dart'; +import 'package:stem/src/workflow/core/workflow_event_ref.dart'; import 'package:stem/src/workflow/core/workflow_script_context.dart'; /// Typed resume helpers for durable workflow suspensions. @@ -69,6 +70,21 @@ extension FlowContextResumeValues on FlowContext { awaitEvent(topic, deadline: deadline, data: data); return null; } + + /// Returns the next event payload from [event] when the step has resumed, or + /// registers an event wait and returns `null` on the first invocation. + T? waitForEventRef( + WorkflowEventRef event, { + DateTime? deadline, + Map? data, + }) { + return waitForEventValue( + event.topic, + deadline: deadline, + data: data, + codec: event.codec, + ); + } } /// Typed resume helpers for durable script checkpoints. @@ -113,4 +129,20 @@ extension WorkflowScriptStepResumeValues on WorkflowScriptStepContext { unawaited(awaitEvent(topic, deadline: deadline, data: data)); return null; } + + /// Returns the next event payload from [event] when the checkpoint has + /// resumed, or registers an event wait and returns `null` on the first + /// invocation. + T? waitForEventRef( + WorkflowEventRef event, { + DateTime? deadline, + Map? data, + }) { + return waitForEventValue( + event.topic, + deadline: deadline, + data: data, + codec: event.codec, + ); + } } diff --git a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart index 7f495adc..ca9042c4 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart @@ -38,6 +38,7 @@ import 'package:stem/src/signals/emitter.dart'; import 'package:stem/src/signals/payloads.dart'; import 'package:stem/src/workflow/core/event_bus.dart'; import 'package:stem/src/workflow/core/flow_context.dart'; +import 'package:stem/src/workflow/core/workflow_event_ref.dart'; import 'package:stem/src/workflow/core/flow_step.dart'; import 'package:stem/src/workflow/core/run_state.dart'; import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; @@ -364,6 +365,11 @@ class WorkflowRuntime implements WorkflowCaller { return emit(topic, _coerceEventPayload(topic, encoded)); } + /// Emits a typed external event using a [WorkflowEventRef]. + Future emitEvent(WorkflowEventRef event, T value) { + return emitValue(event.topic, value, codec: event.codec); + } + /// Starts periodic polling that resumes runs whose wake-up time has elapsed. Future start() async { if (_started) return; @@ -892,9 +898,10 @@ class WorkflowRuntime implements WorkflowCaller { final completedIterations = await _loadCompletedIterations(runId); Object? previousResult; if (checkpoints.isNotEmpty) { - previousResult = definition - .checkpointByName(checkpoints.last.baseName) - ?.decodeValue(checkpoints.last.value) ?? + previousResult = + definition + .checkpointByName(checkpoints.last.baseName) + ?.decodeValue(checkpoints.last.value) ?? checkpoints.last.value; } final execution = _WorkflowScriptExecution( @@ -1961,10 +1968,8 @@ class _ChildWorkflowCaller implements WorkflowCaller { ); } - Future?> _waitForChildWorkflow< - TParams, - TResult extends Object? - >( + Future?> + _waitForChildWorkflow( String runId, WorkflowRef definition, { required Duration pollInterval, diff --git a/packages/stem/lib/src/workflow/workflow.dart b/packages/stem/lib/src/workflow/workflow.dart index 9d0e0009..dd920614 100644 --- a/packages/stem/lib/src/workflow/workflow.dart +++ b/packages/stem/lib/src/workflow/workflow.dart @@ -10,6 +10,7 @@ export 'core/workflow_cancellation_policy.dart'; export 'core/workflow_clock.dart'; export 'core/workflow_checkpoint.dart'; export 'core/workflow_definition.dart'; +export 'core/workflow_event_ref.dart'; export 'core/workflow_ref.dart'; export 'core/workflow_result.dart'; export 'core/workflow_runtime_metadata.dart'; diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart index c0b552b6..1cfaba99 100644 --- a/packages/stem/test/unit/workflow/workflow_resume_test.dart +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -2,6 +2,7 @@ import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/workflow/core/flow_context.dart'; import 'package:stem/src/workflow/core/flow_step.dart'; +import 'package:stem/src/workflow/core/workflow_event_ref.dart'; import 'package:stem/src/workflow/core/workflow_resume.dart'; import 'package:stem/src/workflow/core/workflow_script_context.dart'; import 'package:test/test.dart'; @@ -124,6 +125,41 @@ void main() { }, ); + test('FlowContext.waitForEventRef reuses topic and codec', () { + const event = WorkflowEventRef<_ResumePayload>( + topic: 'demo.event', + codec: _resumePayloadCodec, + ); + final firstContext = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + ); + + final firstResult = firstContext.waitForEventRef(event); + + expect(firstResult, isNull); + final control = firstContext.takeControl(); + expect(control, isNotNull); + expect(control!.topic, 'demo.event'); + + final resumedContext = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + resumeData: const {'message': 'approved'}, + ); + + final resumed = resumedContext.waitForEventRef(event); + expect(resumed?.message, 'approved'); + }); + test( 'WorkflowScriptStepContext helpers suspend once and decode resumed values', () { @@ -163,6 +199,23 @@ void main() { expect(resumedEvent.awaitedTopics, isEmpty); }, ); + + test('WorkflowScriptStepContext.waitForEventRef reuses topic and codec', () { + const event = WorkflowEventRef<_ResumePayload>( + topic: 'demo.event', + codec: _resumePayloadCodec, + ); + final waiting = _FakeWorkflowScriptStepContext(); + final firstEvent = waiting.waitForEventRef(event); + expect(firstEvent, isNull); + expect(waiting.awaitedTopics, ['demo.event']); + + final resumed = _FakeWorkflowScriptStepContext( + resumeData: const {'message': 'approved'}, + ); + final resumedValue = resumed.waitForEventRef(event); + expect(resumedValue?.message, 'approved'); + }); } class _ResumePayload { diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index 053a3133..8b2cf01e 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -104,31 +104,34 @@ void main() { }, ); - test('startWorkflow persists parent run id without exposing it to handlers', () async { - runtime.registerWorkflow( - Flow( - name: 'parent.runtime.workflow', - build: (flow) { - flow.step('inspect', (context) async => context.params); - }, - ).definition, - ); + test( + 'startWorkflow persists parent run id without exposing it to handlers', + () async { + runtime.registerWorkflow( + Flow( + name: 'parent.runtime.workflow', + build: (flow) { + flow.step('inspect', (context) async => context.params); + }, + ).definition, + ); - final runId = await runtime.startWorkflow( - 'parent.runtime.workflow', - parentRunId: 'wf-parent', - params: const {'tenant': 'acme'}, - ); + final runId = await runtime.startWorkflow( + 'parent.runtime.workflow', + parentRunId: 'wf-parent', + params: const {'tenant': 'acme'}, + ); - final state = await store.get(runId); - expect(state, isNotNull); - expect(state!.parentRunId, 'wf-parent'); - expect(state.workflowParams, equals(const {'tenant': 'acme'})); - expect( - state.params[workflowParentRunIdParamKey], - equals('wf-parent'), - ); - }); + final state = await store.get(runId); + expect(state, isNotNull); + expect(state!.parentRunId, 'wf-parent'); + expect(state.workflowParams, equals(const {'tenant': 'acme'})); + expect( + state.params[workflowParentRunIdParamKey], + equals('wf-parent'), + ); + }, + ); test('flow context workflows starts typed child workflows', () async { final childRef = WorkflowRef, String>( @@ -221,52 +224,57 @@ void main() { expect(childState.workflowParams, equals(const {'value': 'script-child'})); }); - test('flow context workflows startAndWaitWithContext waits for child result', () async { - final childRef = WorkflowRef, String>( - name: 'child.runtime.wait.flow', - encodeParams: (params) => params, - ); - - runtime - ..registerWorkflow( - Flow( - name: 'child.runtime.wait.flow', - build: (flow) { - flow.step('hello', (context) async { - final value = context.params['value'] as String? ?? 'child'; - return 'ok:$value'; - }); - }, - ).definition, - ) - ..registerWorkflow( - Flow( - name: 'parent.runtime.wait.flow', - build: (flow) { - flow.step('spawn', (context) async { - final childResult = await childRef - .call(const {'value': 'spawned'}) - .startAndWaitWithContext( - context, - timeout: const Duration(seconds: 2), - ); - return { - 'childRunId': childResult?.runId, - 'childValue': childResult?.value, - }; - }); - }, - ).definition, + test( + 'flow context workflows startAndWaitWithContext waits for child result', + () async { + final childRef = WorkflowRef, String>( + name: 'child.runtime.wait.flow', + encodeParams: (params) => params, ); - final parentRunId = await runtime.startWorkflow('parent.runtime.wait.flow'); - await runtime.executeRun(parentRunId); + runtime + ..registerWorkflow( + Flow( + name: 'child.runtime.wait.flow', + build: (flow) { + flow.step('hello', (context) async { + final value = context.params['value'] as String? ?? 'child'; + return 'ok:$value'; + }); + }, + ).definition, + ) + ..registerWorkflow( + Flow( + name: 'parent.runtime.wait.flow', + build: (flow) { + flow.step('spawn', (context) async { + final childResult = await childRef + .call(const {'value': 'spawned'}) + .startAndWaitWithContext( + context, + timeout: const Duration(seconds: 2), + ); + return { + 'childRunId': childResult?.runId, + 'childValue': childResult?.value, + }; + }); + }, + ).definition, + ); - final parentState = await store.get(parentRunId); - final result = Map.from(parentState!.result! as Map); - expect(result['childRunId'], isA()); - expect(result['childValue'], 'ok:spawned'); - }); + final parentRunId = await runtime.startWorkflow( + 'parent.runtime.wait.flow', + ); + await runtime.executeRun(parentRunId); + + final parentState = await store.get(parentRunId); + final result = Map.from(parentState!.result! as Map); + expect(result['childRunId'], isA()); + expect(result['childValue'], 'ok:spawned'); + }, + ); test( 'script checkpoint workflows startAndWaitWithContext waits for child result', @@ -293,7 +301,9 @@ void main() { name: 'parent.runtime.wait.script', checkpoints: [WorkflowCheckpoint(name: 'spawn')], run: (script) async { - return script.step>('spawn', (context) async { + return script.step>('spawn', ( + context, + ) async { final childResult = await childRef .call(const {'value': 'script-child'}) .startAndWaitWithContext( @@ -589,6 +599,51 @@ void main() { expect(completed?.result, 'user-typed-1'); }); + test('emitEvent resumes flows with typed workflow event refs', () async { + final event = WorkflowEventRef<_UserUpdatedEvent>( + topic: 'user.updated.ref', + codec: _userUpdatedEventCodec, + ); + _UserUpdatedEvent? observedPayload; + + runtime.registerWorkflow( + Flow( + name: 'event.ref.workflow', + build: (flow) { + flow.step( + 'wait', + (context) async { + final resume = context.waitForEventRef(event); + if (resume == null) { + return null; + } + observedPayload = resume; + return resume.id; + }, + ); + }, + ).definition, + ); + + final runId = await runtime.startWorkflow('event.ref.workflow'); + await runtime.executeRun(runId); + + final suspended = await store.get(runId); + expect(suspended?.status, WorkflowStatus.suspended); + expect(suspended?.waitTopic, event.topic); + + await runtime.emitEvent( + event, + const _UserUpdatedEvent(id: 'user-typed-2'), + ); + await runtime.executeRun(runId); + + final completed = await store.get(runId); + expect(completed?.status, WorkflowStatus.completed); + expect(observedPayload?.id, 'user-typed-2'); + expect(completed?.result, 'user-typed-2'); + }); + test('emit persists payload before worker resumes execution', () async { runtime.registerWorkflow( Flow( From 6062d9a2b004e5ebb9a22927fb0fb69c64730539 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 18:39:28 -0500 Subject: [PATCH 033/302] Simplify generated workflow and task surfaces --- .site/docs/core-concepts/stem-builder.md | 4 +- .site/docs/workflows/annotated-workflows.md | 22 +- .site/docs/workflows/starting-and-waiting.md | 8 +- packages/stem/README.md | 25 +- .../example/annotated_workflows/README.md | 5 +- .../example/annotated_workflows/bin/main.dart | 34 +- .../annotated_workflows/lib/definitions.dart | 17 +- .../lib/definitions.stem.g.dart | 318 +----------------- packages/stem/example/ecommerce/README.md | 5 +- .../stem/example/ecommerce/lib/src/app.dart | 7 +- .../src/workflows/annotated_defs.stem.g.dart | 147 -------- packages/stem_builder/README.md | 25 +- packages/stem_builder/example/README.md | 8 +- packages/stem_builder/example/bin/main.dart | 9 +- .../example/bin/runtime_metadata_views.dart | 13 +- .../example/lib/definitions.stem.g.dart | 223 +----------- .../lib/src/stem_registry_builder.dart | 278 ++------------- .../test/stem_registry_builder_test.dart | 83 +++-- 18 files changed, 175 insertions(+), 1056 deletions(-) diff --git a/.site/docs/core-concepts/stem-builder.md b/.site/docs/core-concepts/stem-builder.md index 519ca510..7b47bd7d 100644 --- a/.site/docs/core-concepts/stem-builder.md +++ b/.site/docs/core-concepts/stem-builder.md @@ -87,9 +87,9 @@ final workflowApp = await StemWorkflowApp.fromUrl( ); await workflowApp.start(); -final result = await StemWorkflowDefinitions.startAndWaitUserSignup( +final result = await StemWorkflowDefinitions.userSignup.startAndWaitWithApp( workflowApp, - email: 'user@example.com', + (email: 'user@example.com'), ); ``` diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index f021f4c2..c6fc0ca6 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -47,22 +47,24 @@ Use the generated workflow refs when you want a single typed handle for start and wait operations: ```dart -final result = await StemWorkflowDefinitions.startAndWaitUserSignup( +final result = await StemWorkflowDefinitions.userSignup.startAndWaitWithApp( workflowApp, - email: 'user@example.com', + (email: 'user@example.com'), ); ``` Annotated tasks use the same shared typed task surface: ```dart -final result = await StemTaskDefinitions.enqueueAndWaitSendEmailTyped( +final result = await StemTaskDefinitions.sendEmailTyped.enqueueAndWaitWith( workflowApp.app.stem, - dispatch: EmailDispatch( - email: 'typed@example.com', - subject: 'Welcome', - body: 'Codec-backed DTO payloads', - tags: ['welcome'], + ( + dispatch: EmailDispatch( + email: 'typed@example.com', + subject: 'Welcome', + body: 'Codec-backed DTO payloads', + tags: ['welcome'], + ), ), ); ``` @@ -135,9 +137,9 @@ This keeps one authoring model: When a workflow needs to start another workflow, do it from a durable boundary: -- `StemWorkflowDefinitions.startAndWaitSomeWorkflowWithContext(context, ...)` +- `StemWorkflowDefinitions.someWorkflow.startAndWaitWithContext(context, (...))` inside flow steps -- `StemWorkflowDefinitions.startAndWaitSomeWorkflowWithContext(context, ...)` +- `StemWorkflowDefinitions.someWorkflow.startAndWaitWithContext(context, (...))` inside checkpoint methods Avoid starting child workflows from the raw `WorkflowScriptContext` body. diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index ed9c3733..b8e74ff4 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -79,9 +79,9 @@ When you use `stem_builder`, generated workflow refs remove the raw workflow-name strings and give you one typed handle for both start and wait: ```dart -final result = await StemWorkflowDefinitions.startAndWaitUserSignup( +final result = await StemWorkflowDefinitions.userSignup.startAndWaitWithApp( workflowApp, - email: 'user@example.com', + (email: 'user@example.com'), ); ``` @@ -89,9 +89,9 @@ The same definitions work on `WorkflowRuntime` by passing the runtime as the `WorkflowCaller`: ```dart -final runId = await StemWorkflowDefinitions.startUserSignup( +final runId = await StemWorkflowDefinitions.userSignup.startWith( runtime, - email: 'user@example.com', + (email: 'user@example.com'), ); ``` diff --git a/packages/stem/README.md b/packages/stem/README.md index 48469b3c..eb93f914 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -574,10 +574,10 @@ Context injection works at every runtime layer: Child workflows belong in durable execution boundaries: - use - `StemWorkflowDefinitions.startAndWaitSomeWorkflowWithContext(context, ...)` + `StemWorkflowDefinitions.someWorkflow.startAndWaitWithContext(context, (...))` inside flow steps - use - `StemWorkflowDefinitions.startAndWaitSomeWorkflowWithContext(context, ...)` + `StemWorkflowDefinitions.someWorkflow.startAndWaitWithContext(context, (...))` inside script checkpoints - do not start child workflows from the raw `WorkflowScriptContext` body unless you are deliberately managing replay/idempotency yourself @@ -628,9 +628,9 @@ final app = await StemWorkflowApp.fromUrl( module: stemModule, ); -final result = await StemWorkflowDefinitions.startAndWaitUserSignup( +final result = await StemWorkflowDefinitions.userSignup.startAndWaitWithApp( app, - email: 'user@example.com', + (email: 'user@example.com'), ); print(result?.value); await app.close(); @@ -647,7 +647,8 @@ Generated output gives you: - `StemWorkflowDefinitions` - `StemTaskDefinitions` - typed workflow refs and task definitions that use the shared - `WorkflowStartCall`, `TaskCall`, and `TaskDefinition.waitFor(...)` APIs + `WorkflowStartCall`, `TaskCall`, `WorkflowRef`, and + `TaskDefinition.waitFor(...)` APIs The same bundle also works for plain task apps: @@ -768,13 +769,15 @@ if (charge?.isSucceeded == true) { Generated annotated tasks use the same surface: ```dart -final receipt = await StemTaskDefinitions.enqueueAndWaitSendEmailTyped( +final receipt = await StemTaskDefinitions.sendEmailTyped.enqueueAndWaitWith( stem, - dispatch: EmailDispatch( - email: 'typed@example.com', - subject: 'Welcome', - body: 'Codec-backed DTO payloads', - tags: ['welcome'], + ( + dispatch: EmailDispatch( + email: 'typed@example.com', + subject: 'Welcome', + body: 'Codec-backed DTO payloads', + tags: ['welcome'], + ), ), ); print(receipt?.value?.deliveryId); diff --git a/packages/stem/example/annotated_workflows/README.md b/packages/stem/example/annotated_workflows/README.md index c9962c40..d7ea4e21 100644 --- a/packages/stem/example/annotated_workflows/README.md +++ b/packages/stem/example/annotated_workflows/README.md @@ -6,7 +6,7 @@ with the `stem_builder` bundle generator. It now demonstrates the generated script-proxy behavior explicitly: - a flow step using `FlowContext` - a flow step starting and waiting on a child workflow through - `StemWorkflowDefinitions.startAndWait*WithContext(context, ...)` + `StemWorkflowDefinitions.*.startAndWaitWithContext(context, (...))` - `run(WelcomeRequest request)` calls annotated checkpoint methods directly - `prepareWelcome(...)` calls other annotated checkpoints - `deliverWelcome(...)` calls another annotated checkpoint from inside an @@ -15,7 +15,7 @@ It now demonstrates the generated script-proxy behavior explicitly: (`WorkflowScriptContext? context` / `WorkflowScriptStepContext? context`) to expose `runId`, `workflow`, `stepName`, `stepIndex`, and idempotency keys - a script checkpoint starting and waiting on a child workflow through - `StemWorkflowDefinitions.startAndWait*WithContext(context, ...)` + `StemWorkflowDefinitions.*.startAndWaitWithContext(context, (...))` - a plain script workflow that returns a codec-backed DTO result and persists a codec-backed DTO checkpoint value - a typed `@TaskDefn` using optional named `TaskInvocationContext? context` @@ -38,7 +38,6 @@ The generated file exposes: - `stemModule` - `StemWorkflowDefinitions` -- direct workflow helpers like `startScript(...)` and `startAndWaitScript(...)` - typed workflow refs for `StemWorkflowApp` and `WorkflowRuntime` - typed task definitions that use the shared `TaskCall` / `TaskDefinition.waitFor(...)` APIs diff --git a/packages/stem/example/annotated_workflows/bin/main.dart b/packages/stem/example/annotated_workflows/bin/main.dart index 5fb2e02e..05e9f7fc 100644 --- a/packages/stem/example/annotated_workflows/bin/main.dart +++ b/packages/stem/example/annotated_workflows/bin/main.dart @@ -8,10 +8,8 @@ Future main() async { final app = await client.createWorkflowApp(module: stemModule); await app.start(); - final flowRunId = await StemWorkflowDefinitions.startFlow( - app, - ); - final flowResult = await StemWorkflowDefinitions.waitForFlow( + final flowRunId = await StemWorkflowDefinitions.flow.startWithApp(app); + final flowResult = await StemWorkflowDefinitions.flow.waitFor( app, flowRunId, timeout: const Duration(seconds: 2), @@ -22,9 +20,11 @@ Future main() async { '${jsonEncode(flowResult?.value?['childResult'])}', ); - final scriptResult = await StemWorkflowDefinitions.startAndWaitScript( + final scriptResult = await StemWorkflowDefinitions.script.startAndWaitWithApp( app, - request: const WelcomeRequest(email: ' SomeEmail@Example.com '), + ( + request: const WelcomeRequest(email: ' SomeEmail@Example.com '), + ), timeout: const Duration(seconds: 2), ); print('Script result: ${jsonEncode(scriptResult?.value?.toJson())}'); @@ -45,9 +45,12 @@ Future main() async { print('Persisted script result: ${jsonEncode(scriptDetail?.run.result)}'); print('Script detail: ${jsonEncode(scriptDetail?.toJson())}'); - final contextResult = await StemWorkflowDefinitions.startAndWaitContextScript( + final contextResult = await StemWorkflowDefinitions.contextScript + .startAndWaitWithApp( app, - request: const WelcomeRequest(email: ' ContextEmail@Example.com '), + ( + request: const WelcomeRequest(email: ' ContextEmail@Example.com '), + ), timeout: const Duration(seconds: 2), ); print('Context script result: ${jsonEncode(contextResult?.value?.toJson())}'); @@ -64,13 +67,16 @@ Future main() async { '${jsonEncode(contextResult.value!.childResult.toJson())}', ); - final typedTaskResult = await StemTaskDefinitions.enqueueAndWaitSendEmailTyped( + final typedTaskResult = await StemTaskDefinitions.sendEmailTyped + .enqueueAndWaitWith( app.app.stem, - dispatch: const EmailDispatch( - email: 'typed@example.com', - subject: 'Welcome', - body: 'Codec-backed DTO payloads', - tags: ['welcome', 'transactional', 'annotated'], + ( + dispatch: const EmailDispatch( + email: 'typed@example.com', + subject: 'Welcome', + body: 'Codec-backed DTO payloads', + tags: ['welcome', 'transactional', 'annotated'], + ), ), meta: const {'origin': 'annotated_workflows_example'}, timeout: const Duration(seconds: 2), diff --git a/packages/stem/example/annotated_workflows/lib/definitions.dart b/packages/stem/example/annotated_workflows/lib/definitions.dart index db5ac4f8..5394b378 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.dart @@ -194,11 +194,12 @@ class AnnotatedFlowWorkflow { if (!ctx.sleepUntilResumed(const Duration(milliseconds: 50))) { return null; } - final childResult = await StemWorkflowDefinitions.startAndWaitScriptWithContext( - ctx, - request: const WelcomeRequest(email: 'flow-child@example.com'), - timeout: const Duration(seconds: 2), - ); + final childResult = await StemWorkflowDefinitions.script + .startAndWaitWithContext( + ctx, + (request: const WelcomeRequest(email: 'flow-child@example.com')), + timeout: const Duration(seconds: 2), + ); return { 'workflow': ctx.workflow, 'runId': ctx.runId, @@ -278,10 +279,10 @@ class AnnotatedContextScriptWorkflow { final ctx = context!; final normalizedEmail = await normalizeEmail(request.email); final subject = await buildWelcomeSubject(normalizedEmail); - final childResult = await StemWorkflowDefinitions - .startAndWaitScriptWithContext( + final childResult = await StemWorkflowDefinitions.script + .startAndWaitWithContext( ctx, - request: WelcomeRequest(email: normalizedEmail), + (request: WelcomeRequest(email: normalizedEmail)), timeout: const Duration(seconds: 2), ); return ContextCaptureResult( diff --git a/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart b/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart index 597b9d06..82648eac 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart @@ -224,98 +224,8 @@ final List _stemScripts = [ ]; abstract final class StemWorkflowDefinitions { - static final WorkflowRef, Map?> flow = - WorkflowRef, Map?>( - name: "annotated.flow", - encodeParams: (params) => params, - ); - static Future startFlow( - WorkflowCaller caller, { - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return flow - .call( - {}, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ) - .startWith(caller); - } - - static Future startFlowWithContext( - WorkflowChildCallerContext context, { - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return flow - .call( - {}, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ) - .startWithContext(context); - } - - static Future?>?> startAndWaitFlow( - WorkflowCaller caller, { - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return flow - .call( - {}, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ) - .startAndWaitWith(caller, pollInterval: pollInterval, timeout: timeout); - } - - static Future?>?> - startAndWaitFlowWithContext( - WorkflowChildCallerContext context, { - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return flow - .call( - {}, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ) - .startAndWaitWithContext( - context, - pollInterval: pollInterval, - timeout: timeout, - ); - } - - static Future?>?> waitForFlow( - WorkflowCaller caller, - String runId, { - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return flow.waitForWith( - caller, - runId, - pollInterval: pollInterval, - timeout: timeout, - ); - } - + static final NoArgsWorkflowRef?> flow = + NoArgsWorkflowRef?>(name: "annotated.flow"); static final WorkflowRef<({WelcomeRequest request}), WelcomeWorkflowResult> script = WorkflowRef<({WelcomeRequest request}), WelcomeWorkflowResult>( name: "annotated.script", @@ -324,97 +234,6 @@ abstract final class StemWorkflowDefinitions { }, decodeResult: StemPayloadCodecs.welcomeWorkflowResult.decode, ); - static Future startScript( - WorkflowCaller caller, { - required WelcomeRequest request, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return script - .call( - (request: request), - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ) - .startWith(caller); - } - - static Future startScriptWithContext( - WorkflowChildCallerContext context, { - required WelcomeRequest request, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return script - .call( - (request: request), - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ) - .startWithContext(context); - } - - static Future?> startAndWaitScript( - WorkflowCaller caller, { - required WelcomeRequest request, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return script - .call( - (request: request), - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ) - .startAndWaitWith(caller, pollInterval: pollInterval, timeout: timeout); - } - - static Future?> - startAndWaitScriptWithContext( - WorkflowChildCallerContext context, { - required WelcomeRequest request, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return script - .call( - (request: request), - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ) - .startAndWaitWithContext( - context, - pollInterval: pollInterval, - timeout: timeout, - ); - } - - static Future?> waitForScript( - WorkflowCaller caller, - String runId, { - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return script.waitForWith( - caller, - runId, - pollInterval: pollInterval, - timeout: timeout, - ); - } - static final WorkflowRef<({WelcomeRequest request}), ContextCaptureResult> contextScript = WorkflowRef<({WelcomeRequest request}), ContextCaptureResult>( name: "annotated.context_script", @@ -423,97 +242,6 @@ abstract final class StemWorkflowDefinitions { }, decodeResult: StemPayloadCodecs.contextCaptureResult.decode, ); - static Future startContextScript( - WorkflowCaller caller, { - required WelcomeRequest request, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return contextScript - .call( - (request: request), - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ) - .startWith(caller); - } - - static Future startContextScriptWithContext( - WorkflowChildCallerContext context, { - required WelcomeRequest request, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return contextScript - .call( - (request: request), - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ) - .startWithContext(context); - } - - static Future?> - startAndWaitContextScript( - WorkflowCaller caller, { - required WelcomeRequest request, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return contextScript - .call( - (request: request), - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ) - .startAndWaitWith(caller, pollInterval: pollInterval, timeout: timeout); - } - - static Future?> - startAndWaitContextScriptWithContext( - WorkflowChildCallerContext context, { - required WelcomeRequest request, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return contextScript - .call( - (request: request), - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ) - .startAndWaitWithContext( - context, - pollInterval: pollInterval, - timeout: timeout, - ); - } - - static Future?> waitForContextScript( - WorkflowCaller caller, - String runId, { - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return contextScript.waitForWith( - caller, - runId, - pollInterval: pollInterval, - timeout: timeout, - ); - } } Object? _stemRequireArg(Map args, String name) { @@ -561,48 +289,6 @@ abstract final class StemTaskDefinitions { metadata: const TaskMetadata(), decodeResult: StemPayloadCodecs.emailDeliveryReceipt.decode, ); - static Future enqueueSendEmailTyped( - TaskEnqueuer enqueuer, { - required EmailDispatch dispatch, - Map headers = const {}, - TaskOptions? options, - DateTime? notBefore, - Map? meta, - TaskEnqueueOptions? enqueueOptions, - }) { - return sendEmailTyped - .call( - (dispatch: dispatch), - headers: headers, - options: options, - notBefore: notBefore, - meta: meta, - enqueueOptions: enqueueOptions, - ) - .enqueueWith(enqueuer, enqueueOptions: enqueueOptions); - } - - static Future?> enqueueAndWaitSendEmailTyped( - Stem stem, { - required EmailDispatch dispatch, - Map headers = const {}, - TaskOptions? options, - DateTime? notBefore, - Map? meta, - TaskEnqueueOptions? enqueueOptions, - Duration? timeout, - }) async { - final taskId = await enqueueSendEmailTyped( - stem, - dispatch: dispatch, - headers: headers, - options: options, - notBefore: notBefore, - meta: meta, - enqueueOptions: enqueueOptions, - ); - return sendEmailTyped.waitFor(stem, taskId, timeout: timeout); - } } final List> _stemTasks = >[ diff --git a/packages/stem/example/ecommerce/README.md b/packages/stem/example/ecommerce/README.md index aa2fb701..fb6f1630 100644 --- a/packages/stem/example/ecommerce/README.md +++ b/packages/stem/example/ecommerce/README.md @@ -35,9 +35,10 @@ From those annotations, this example uses generated APIs: - `stemModule` (generated workflow/task bundle) - `StemWorkflowDefinitions.addToCart` -- `StemWorkflowDefinitions.startAndWaitAddToCart(...)` +- `StemWorkflowDefinitions.addToCart.startAndWaitWithApp(...)` - `StemTaskDefinitions.ecommerceAuditLog` -- `StemTaskDefinitions.enqueueEcommerceAuditLog(...)` +- direct task definition helpers like + `StemTaskDefinitions.ecommerceAuditLog.enqueueWith(...)` The manual checkout flow also derives a typed ref from its `Flow` definition: diff --git a/packages/stem/example/ecommerce/lib/src/app.dart b/packages/stem/example/ecommerce/lib/src/app.dart index 48205d1b..9779edf2 100644 --- a/packages/stem/example/ecommerce/lib/src/app.dart +++ b/packages/stem/example/ecommerce/lib/src/app.dart @@ -93,11 +93,10 @@ class EcommerceServer { final sku = payload['sku']?.toString() ?? ''; final quantity = _toInt(payload['quantity']); - final result = await StemWorkflowDefinitions.startAndWaitAddToCart( + final result = await StemWorkflowDefinitions.addToCart + .startAndWaitWithApp( workflowApp, - cartId: cartId, - sku: sku, - quantity: quantity, + (cartId: cartId, sku: sku, quantity: quantity), timeout: const Duration(seconds: 4), ); diff --git a/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart b/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart index d7fbd2b1..e8ed64bf 100644 --- a/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart +++ b/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart @@ -74,104 +74,6 @@ abstract final class StemWorkflowDefinitions { "quantity": params.quantity, }, ); - static Future startAddToCart( - WorkflowCaller caller, { - required String cartId, - required String sku, - required int quantity, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return addToCart - .call( - (cartId: cartId, sku: sku, quantity: quantity), - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ) - .startWith(caller); - } - - static Future startAddToCartWithContext( - WorkflowChildCallerContext context, { - required String cartId, - required String sku, - required int quantity, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return addToCart - .call( - (cartId: cartId, sku: sku, quantity: quantity), - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ) - .startWithContext(context); - } - - static Future>?> startAndWaitAddToCart( - WorkflowCaller caller, { - required String cartId, - required String sku, - required int quantity, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return addToCart - .call( - (cartId: cartId, sku: sku, quantity: quantity), - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ) - .startAndWaitWith(caller, pollInterval: pollInterval, timeout: timeout); - } - - static Future>?> - startAndWaitAddToCartWithContext( - WorkflowChildCallerContext context, { - required String cartId, - required String sku, - required int quantity, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return addToCart - .call( - (cartId: cartId, sku: sku, quantity: quantity), - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ) - .startAndWaitWithContext( - context, - pollInterval: pollInterval, - timeout: timeout, - ); - } - - static Future>?> waitForAddToCart( - WorkflowCaller caller, - String runId, { - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return addToCart.waitForWith( - caller, - runId, - pollInterval: pollInterval, - timeout: timeout, - ); - } } Object? _stemRequireArg(Map args, String name) { @@ -214,55 +116,6 @@ abstract final class StemTaskDefinitions { defaultOptions: const TaskOptions(queue: "default"), metadata: const TaskMetadata(), ); - static Future enqueueEcommerceAuditLog( - TaskEnqueuer enqueuer, { - required String event, - required String entityId, - required String detail, - Map headers = const {}, - TaskOptions? options, - DateTime? notBefore, - Map? meta, - TaskEnqueueOptions? enqueueOptions, - }) { - return ecommerceAuditLog - .call( - (event: event, entityId: entityId, detail: detail), - headers: headers, - options: options, - notBefore: notBefore, - meta: meta, - enqueueOptions: enqueueOptions, - ) - .enqueueWith(enqueuer, enqueueOptions: enqueueOptions); - } - - static Future>?> - enqueueAndWaitEcommerceAuditLog( - Stem stem, { - required String event, - required String entityId, - required String detail, - Map headers = const {}, - TaskOptions? options, - DateTime? notBefore, - Map? meta, - TaskEnqueueOptions? enqueueOptions, - Duration? timeout, - }) async { - final taskId = await enqueueEcommerceAuditLog( - stem, - event: event, - entityId: entityId, - detail: detail, - headers: headers, - options: options, - notBefore: notBefore, - meta: meta, - enqueueOptions: enqueueOptions, - ); - return ecommerceAuditLog.waitFor(stem, taskId, timeout: timeout); - } } final List> _stemTasks = >[ diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index 084a246a..c180680f 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -105,9 +105,9 @@ Supported context injection points: Child workflows should be started from durable boundaries: -- `StemWorkflowDefinitions.startSomeWorkflowWithContext(context, ...)` +- `StemWorkflowDefinitions.someWorkflow.startWithContext(context, (...))` inside flow steps -- `StemWorkflowDefinitions.startAndWaitSomeWorkflowWithContext(context, ...)` +- `StemWorkflowDefinitions.someWorkflow.startAndWaitWithContext(context, (...))` inside script checkpoints Avoid starting child workflows directly from the raw @@ -140,9 +140,8 @@ The intended DX is: - pass generated `stemModule` into `StemWorkflowApp` or `StemClient` - start workflows through generated workflow refs instead of raw workflow-name strings -- enqueue annotated tasks through generated direct helpers like - `StemTaskDefinitions.enqueueSendEmailTyped(...)` - instead of raw task-name strings +- enqueue annotated tasks through generated task definitions instead of raw + task-name strings You can customize generated workflow ref names via `@WorkflowDefn`: @@ -164,10 +163,10 @@ Run build_runner to generate `*.stem.g.dart` part files: dart run build_runner build ``` -The generated part exports a bundle plus typed helpers so you can avoid raw -workflow-name and task-name strings (for example -`StemWorkflowDefinitions.startUserSignup(workflowApp, email: 'user@example.com')` or -`StemTaskDefinitions.enqueueBuilderExampleTask(stem, ...)`). +The generated part exports a bundle plus typed refs/definitions so you can +avoid raw workflow-name and task-name strings (for example +`StemWorkflowDefinitions.userSignup.startWith(workflowApp, (email: 'user@example.com'))` +or `StemTaskDefinitions.builderExamplePing.enqueueWith(stem)`). Generated output includes: @@ -191,9 +190,9 @@ final workflowApp = await StemWorkflowApp.fromUrl( module: stemModule, ); -final result = await StemWorkflowDefinitions.startAndWaitUserSignup( +final result = await StemWorkflowDefinitions.userSignup.startAndWaitWithApp( workflowApp, - email: 'user@example.com', + (email: 'user@example.com'), ); ``` @@ -251,9 +250,9 @@ The generated workflow refs work on `WorkflowRuntime` too: ```dart final runtime = workflowApp.runtime; -final runId = await StemWorkflowDefinitions.startUserSignup( +final runId = await StemWorkflowDefinitions.userSignup.startWith( runtime, - email: 'user@example.com', + (email: 'user@example.com'), ); await runtime.executeRun(runId); ``` diff --git a/packages/stem_builder/example/README.md b/packages/stem_builder/example/README.md index 8951ae75..a8910abb 100644 --- a/packages/stem_builder/example/README.md +++ b/packages/stem_builder/example/README.md @@ -5,12 +5,12 @@ This example demonstrates: - Annotated workflow/task definitions - Generated `stemModule` - Generated typed workflow refs (no manual workflow-name strings): - - `StemWorkflowDefinitions.startFlow(runtime, ...)` - - `StemWorkflowDefinitions.startUserSignup(runtime, ...)` + - `StemWorkflowDefinitions.flow.startWithRuntime(runtime, (...))` + - `StemWorkflowDefinitions.userSignup.startWithRuntime(runtime, (...))` - Generated typed task definitions that use the shared `TaskCall` / `TaskDefinition.waitFor(...)` APIs -- Generated zero-arg task definitions with direct helpers: - - `StemTaskDefinitions.enqueueAndWaitBuilderExamplePing(stem)` +- Generated zero-arg task definitions with direct helpers from core: + - `StemTaskDefinitions.builderExamplePing.enqueueAndWaitWith(stem)` - Generated workflow manifest via `stemModule.workflowManifest` - Running generated definitions through `StemWorkflowApp` - Runtime manifest + run/step metadata views via `WorkflowRuntime` diff --git a/packages/stem_builder/example/bin/main.dart b/packages/stem_builder/example/bin/main.dart index f40344ec..beb7832b 100644 --- a/packages/stem_builder/example/bin/main.dart +++ b/packages/stem_builder/example/bin/main.dart @@ -26,12 +26,12 @@ Future main() async { print('\nRuntime manifest:'); print(const JsonEncoder.withIndent(' ').convert(runtimeManifest)); - final runId = await StemWorkflowDefinitions.startFlow( + final runId = await StemWorkflowDefinitions.flow.startWithRuntime( runtime, - name: 'Stem Builder', + (name: 'Stem Builder'), ); await runtime.executeRun(runId); - final result = await StemWorkflowDefinitions.waitForFlow( + final result = await StemWorkflowDefinitions.flow.waitFor( app, runId, timeout: const Duration(seconds: 2), @@ -44,7 +44,8 @@ Future main() async { final taskApp = await StemApp.inMemory(module: stemModule); try { await taskApp.start(); - final taskResult = await StemTaskDefinitions.enqueueAndWaitBuilderExamplePing( + final taskResult = await StemTaskDefinitions.builderExamplePing + .enqueueAndWaitWith( taskApp.stem, timeout: const Duration(seconds: 2), ); diff --git a/packages/stem_builder/example/bin/runtime_metadata_views.dart b/packages/stem_builder/example/bin/runtime_metadata_views.dart index 1627de79..f53e8b99 100644 --- a/packages/stem_builder/example/bin/runtime_metadata_views.dart +++ b/packages/stem_builder/example/bin/runtime_metadata_views.dart @@ -25,16 +25,17 @@ Future main() async { ), ); - final flowRunId = await StemWorkflowDefinitions.startFlow( + final flowRunId = await StemWorkflowDefinitions.flow.startWithRuntime( runtime, - name: 'runtime metadata', + (name: 'runtime metadata'), ); await runtime.executeRun(flowRunId); - final scriptRunId = await StemWorkflowDefinitions.startUserSignup( - runtime, - email: 'dev@stem.dev', - ); + final scriptRunId = await StemWorkflowDefinitions.userSignup + .startWithRuntime( + runtime, + (email: 'dev@stem.dev'), + ); await runtime.executeRun(scriptRunId); final runViews = await runtime.listRunViews(limit: 10); diff --git a/packages/stem_builder/example/lib/definitions.stem.g.dart b/packages/stem_builder/example/lib/definitions.stem.g.dart index 49432f7d..81780cc9 100644 --- a/packages/stem_builder/example/lib/definitions.stem.g.dart +++ b/packages/stem_builder/example/lib/definitions.stem.g.dart @@ -73,196 +73,16 @@ final List _stemScripts = [ ]; abstract final class StemWorkflowDefinitions { - static final WorkflowRef, String> flow = - WorkflowRef, String>( + static final WorkflowRef<({String name}), String> flow = + WorkflowRef<({String name}), String>( name: "builder.example.flow", - encodeParams: (params) => params, + encodeParams: (params) => {"name": params.name}, ); - static Future startFlow( - WorkflowCaller caller, { - required String name, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return flow - .call( - {"name": name}, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ) - .startWith(caller); - } - - static Future startFlowWithContext( - WorkflowChildCallerContext context, { - required String name, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return flow - .call( - {"name": name}, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ) - .startWithContext(context); - } - - static Future?> startAndWaitFlow( - WorkflowCaller caller, { - required String name, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return flow - .call( - {"name": name}, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ) - .startAndWaitWith(caller, pollInterval: pollInterval, timeout: timeout); - } - - static Future?> startAndWaitFlowWithContext( - WorkflowChildCallerContext context, { - required String name, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return flow - .call( - {"name": name}, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ) - .startAndWaitWithContext( - context, - pollInterval: pollInterval, - timeout: timeout, - ); - } - - static Future?> waitForFlow( - WorkflowCaller caller, - String runId, { - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return flow.waitForWith( - caller, - runId, - pollInterval: pollInterval, - timeout: timeout, - ); - } - static final WorkflowRef<({String email}), Map> userSignup = WorkflowRef<({String email}), Map>( name: "builder.example.user_signup", encodeParams: (params) => {"email": params.email}, ); - static Future startUserSignup( - WorkflowCaller caller, { - required String email, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return userSignup - .call( - (email: email), - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ) - .startWith(caller); - } - - static Future startUserSignupWithContext( - WorkflowChildCallerContext context, { - required String email, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return userSignup - .call( - (email: email), - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ) - .startWithContext(context); - } - - static Future>?> startAndWaitUserSignup( - WorkflowCaller caller, { - required String email, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return userSignup - .call( - (email: email), - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ) - .startAndWaitWith(caller, pollInterval: pollInterval, timeout: timeout); - } - - static Future>?> - startAndWaitUserSignupWithContext( - WorkflowChildCallerContext context, { - required String email, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return userSignup - .call( - (email: email), - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ) - .startAndWaitWithContext( - context, - pollInterval: pollInterval, - timeout: timeout, - ); - } - - static Future>?> waitForUserSignup( - WorkflowCaller caller, - String runId, { - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return userSignup.waitForWith( - caller, - runId, - pollInterval: pollInterval, - timeout: timeout, - ); - } } Object? _stemRequireArg(Map args, String name) { @@ -293,43 +113,6 @@ abstract final class StemTaskDefinitions { defaultOptions: const TaskOptions(), metadata: const TaskMetadata(), ); - static Future enqueueBuilderExamplePing( - TaskEnqueuer enqueuer, { - Map headers = const {}, - TaskOptions? options, - DateTime? notBefore, - Map? meta, - TaskEnqueueOptions? enqueueOptions, - }) { - return builderExamplePing.enqueueWith( - enqueuer, - headers: headers, - options: options, - notBefore: notBefore, - meta: meta, - enqueueOptions: enqueueOptions, - ); - } - - static Future?> enqueueAndWaitBuilderExamplePing( - Stem stem, { - Map headers = const {}, - TaskOptions? options, - DateTime? notBefore, - Map? meta, - TaskEnqueueOptions? enqueueOptions, - Duration? timeout, - }) async { - final taskId = await enqueueBuilderExamplePing( - stem, - headers: headers, - options: options, - notBefore: notBefore, - meta: meta, - enqueueOptions: enqueueOptions, - ); - return builderExamplePing.waitFor(stem, taskId, timeout: timeout); - } } final List> _stemTasks = >[ diff --git a/packages/stem_builder/lib/src/stem_registry_builder.dart b/packages/stem_builder/lib/src/stem_registry_builder.dart index 29f9038d..051f6f83 100644 --- a/packages/stem_builder/lib/src/stem_registry_builder.dart +++ b/packages/stem_builder/lib/src/stem_registry_builder.dart @@ -1650,25 +1650,35 @@ class _RegistryEmitter { buffer.writeln('abstract final class StemWorkflowDefinitions {'); for (final workflow in workflows) { final fieldName = fieldNames[workflow]!; - final helperSuffix = symbolNames[workflow]!; final argsTypeCode = _workflowArgsTypeCode(workflow); - final isNoArgsScript = - workflow.kind == WorkflowKind.script && - workflow.runValueParameters.isEmpty; + final valueParameters = + workflow.kind == WorkflowKind.script + ? workflow.runValueParameters + : workflow.steps.first.valueParameters; + final usesNoArgsDefinition = valueParameters.isEmpty; final refType = - isNoArgsScript + usesNoArgsDefinition ? 'NoArgsWorkflowRef<${workflow.resultTypeCode}>' : 'WorkflowRef<$argsTypeCode, ${workflow.resultTypeCode}>'; final constructorType = - isNoArgsScript + usesNoArgsDefinition ? 'NoArgsWorkflowRef<${workflow.resultTypeCode}>' : 'WorkflowRef<$argsTypeCode, ${workflow.resultTypeCode}>'; buffer.writeln(' static final $refType $fieldName = $constructorType('); buffer.writeln(' name: ${_string(workflow.name)},'); - if (workflow.kind == WorkflowKind.script) { - if (workflow.runValueParameters.isNotEmpty) { + if (!usesNoArgsDefinition) { + if (workflow.kind == WorkflowKind.script) { buffer.writeln(' encodeParams: (params) => {'); - for (final parameter in workflow.runValueParameters) { + for (final parameter in valueParameters) { + buffer.writeln( + ' ${_string(parameter.name)}: ' + '${_encodeValueExpression('params.${parameter.name}', parameter)},', + ); + } + buffer.writeln(' },'); + } else { + buffer.writeln(' encodeParams: (params) => {'); + for (final parameter in valueParameters) { buffer.writeln( ' ${_string(parameter.name)}: ' '${_encodeValueExpression('params.${parameter.name}', parameter)},', @@ -1676,8 +1686,6 @@ class _RegistryEmitter { } buffer.writeln(' },'); } - } else { - buffer.writeln(' encodeParams: (params) => params,'); } if (workflow.resultPayloadCodecTypeCode != null) { final codecField = @@ -1687,167 +1695,6 @@ class _RegistryEmitter { ); } buffer.writeln(' );'); - if (isNoArgsScript) { - final startArgs = _methodSignature( - positional: ['WorkflowCaller caller'], - named: [ - 'String? parentRunId', - 'Duration? ttl', - 'WorkflowCancellationPolicy? cancellationPolicy', - ], - ); - buffer.writeln( - ' static Future start$helperSuffix($startArgs) {', - ); - buffer.writeln( - ' return $fieldName.startWith(caller, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy);', - ); - buffer.writeln(' }'); - final startWithContextArgs = _methodSignature( - positional: ['WorkflowChildCallerContext context'], - named: [ - 'String? parentRunId', - 'Duration? ttl', - 'WorkflowCancellationPolicy? cancellationPolicy', - ], - ); - buffer.writeln( - ' static Future start$helperSuffix' - 'WithContext($startWithContextArgs) {', - ); - buffer.writeln( - ' return $fieldName.startWithContext(context, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy);', - ); - buffer.writeln(' }'); - final startAndWaitArgs = _methodSignature( - positional: ['WorkflowCaller caller'], - named: [ - 'String? parentRunId', - 'Duration? ttl', - 'WorkflowCancellationPolicy? cancellationPolicy', - 'Duration pollInterval = const Duration(milliseconds: 100)', - 'Duration? timeout', - ], - ); - buffer.writeln( - ' static Future?> startAndWait$helperSuffix($startAndWaitArgs) {', - ); - buffer.writeln( - ' return $fieldName.startAndWaitWith(caller, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy, pollInterval: pollInterval, timeout: timeout);', - ); - buffer.writeln(' }'); - final startAndWaitWithContextArgs = _methodSignature( - positional: ['WorkflowChildCallerContext context'], - named: [ - 'String? parentRunId', - 'Duration? ttl', - 'WorkflowCancellationPolicy? cancellationPolicy', - 'Duration pollInterval = const Duration(milliseconds: 100)', - 'Duration? timeout', - ], - ); - buffer.writeln( - ' static Future?> startAndWait$helperSuffix' - 'WithContext($startAndWaitWithContextArgs) {', - ); - buffer.writeln( - ' return $fieldName.startAndWaitWithContext(context, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy, pollInterval: pollInterval, timeout: timeout);', - ); - buffer.writeln(' }'); - } else { - final parameters = workflow.kind == WorkflowKind.script - ? workflow.runValueParameters - : workflow.steps.first.valueParameters; - final callParams = workflow.kind == WorkflowKind.script - ? '(${parameters.map((parameter) => '${parameter.name}: ${parameter.name}').join(', ')})' - : '{${parameters.map((parameter) => '${_string(parameter.name)}: ${_encodeValueExpression(parameter.name, parameter)}').join(', ')}}'; - final startArgs = _methodSignature( - positional: ['WorkflowCaller caller'], - named: [ - ...parameters.map( - (parameter) => 'required ${parameter.typeCode} ${parameter.name}', - ), - 'String? parentRunId', - 'Duration? ttl', - 'WorkflowCancellationPolicy? cancellationPolicy', - ], - ); - buffer.writeln( - ' static Future start$helperSuffix($startArgs) {', - ); - buffer.writeln( - ' return $fieldName.call($callParams, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy).startWith(caller);', - ); - buffer.writeln(' }'); - final startWithContextArgs = _methodSignature( - positional: ['WorkflowChildCallerContext context'], - named: [ - ...parameters.map( - (parameter) => 'required ${parameter.typeCode} ${parameter.name}', - ), - 'String? parentRunId', - 'Duration? ttl', - 'WorkflowCancellationPolicy? cancellationPolicy', - ], - ); - buffer.writeln( - ' static Future start$helperSuffix' - 'WithContext($startWithContextArgs) {', - ); - buffer.writeln( - ' return $fieldName.call($callParams, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy).startWithContext(context);', - ); - buffer.writeln(' }'); - final startAndWaitArgs = _methodSignature( - positional: ['WorkflowCaller caller'], - named: [ - ...parameters.map( - (parameter) => 'required ${parameter.typeCode} ${parameter.name}', - ), - 'String? parentRunId', - 'Duration? ttl', - 'WorkflowCancellationPolicy? cancellationPolicy', - 'Duration pollInterval = const Duration(milliseconds: 100)', - 'Duration? timeout', - ], - ); - buffer.writeln( - ' static Future?> startAndWait$helperSuffix($startAndWaitArgs) {', - ); - buffer.writeln( - ' return $fieldName.call($callParams, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy).startAndWaitWith(caller, pollInterval: pollInterval, timeout: timeout);', - ); - buffer.writeln(' }'); - final startAndWaitWithContextArgs = _methodSignature( - positional: ['WorkflowChildCallerContext context'], - named: [ - ...parameters.map( - (parameter) => 'required ${parameter.typeCode} ${parameter.name}', - ), - 'String? parentRunId', - 'Duration? ttl', - 'WorkflowCancellationPolicy? cancellationPolicy', - 'Duration pollInterval = const Duration(milliseconds: 100)', - 'Duration? timeout', - ], - ); - buffer.writeln( - ' static Future?> startAndWait$helperSuffix' - 'WithContext($startAndWaitWithContextArgs) {', - ); - buffer.writeln( - ' return $fieldName.call($callParams, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy).startAndWaitWithContext(context, pollInterval: pollInterval, timeout: timeout);', - ); - buffer.writeln(' }'); - } - buffer.writeln( - ' static Future?> waitFor$helperSuffix(' - '${_methodSignature(positional: ['WorkflowCaller caller', 'String runId'], named: ['Duration pollInterval = const Duration(milliseconds: 100)', 'Duration? timeout'])}) {', - ); - buffer.writeln( - ' return $fieldName.waitForWith(caller, runId, pollInterval: pollInterval, timeout: timeout);', - ); - buffer.writeln(' }'); } buffer.writeln('}'); buffer.writeln(); @@ -2122,82 +1969,6 @@ class _RegistryEmitter { ); } buffer.writeln(' );'); - if (!task.usesLegacyMapArgs) { - final helperSuffix = _pascalIdentifier(symbol); - final businessArgs = _methodSignature( - positional: ['TaskEnqueuer enqueuer'], - named: [ - ...task.valueParameters.map( - (parameter) => 'required ${parameter.typeCode} ${parameter.name}', - ), - 'Map headers = const {}', - 'TaskOptions? options', - 'DateTime? notBefore', - 'Map? meta', - 'TaskEnqueueOptions? enqueueOptions', - ], - ); - if (usesNoArgsDefinition) { - buffer.writeln( - ' static Future enqueue$helperSuffix($businessArgs) {', - ); - buffer.writeln( - ' return $symbol.enqueueWith(enqueuer, headers: headers, options: options, notBefore: notBefore, meta: meta, enqueueOptions: enqueueOptions);', - ); - } else { - final callArgs = _invocationArgs( - positional: [ - '(${task.valueParameters.map((parameter) => '${parameter.name}: ${parameter.name}').join(', ')})', - ], - named: { - 'headers': 'headers', - 'options': 'options', - 'notBefore': 'notBefore', - 'meta': 'meta', - 'enqueueOptions': 'enqueueOptions', - }, - ); - buffer.writeln( - ' static Future enqueue$helperSuffix($businessArgs) {', - ); - buffer.writeln( - ' return $symbol.call($callArgs).enqueueWith(enqueuer, enqueueOptions: enqueueOptions);', - ); - } - buffer.writeln(' }'); - final waitArgs = _methodSignature( - positional: ['Stem stem'], - named: [ - ...task.valueParameters.map( - (parameter) => 'required ${parameter.typeCode} ${parameter.name}', - ), - 'Map headers = const {}', - 'TaskOptions? options', - 'DateTime? notBefore', - 'Map? meta', - 'TaskEnqueueOptions? enqueueOptions', - 'Duration? timeout', - ], - ); - buffer.writeln( - ' static Future?> enqueueAndWait$helperSuffix($waitArgs) async {', - ); - buffer.writeln(' final taskId = await enqueue$helperSuffix('); - buffer.writeln(' stem,'); - for (final parameter in task.valueParameters) { - buffer.writeln(' ${parameter.name}: ${parameter.name},'); - } - buffer.writeln(' headers: headers,'); - buffer.writeln(' options: options,'); - buffer.writeln(' notBefore: notBefore,'); - buffer.writeln(' meta: meta,'); - buffer.writeln(' enqueueOptions: enqueueOptions,'); - buffer.writeln(' );'); - buffer.writeln( - ' return $symbol.waitFor(stem, taskId, timeout: timeout);', - ); - buffer.writeln(' }'); - } } buffer.writeln('}'); buffer.writeln(); @@ -2383,13 +2154,14 @@ class _RegistryEmitter { } String _workflowArgsTypeCode(_WorkflowInfo workflow) { - if (workflow.kind != WorkflowKind.script) { - return 'Map'; - } - if (workflow.runValueParameters.isEmpty) { + final parameters = + workflow.kind == WorkflowKind.script + ? workflow.runValueParameters + : workflow.steps.first.valueParameters; + if (parameters.isEmpty) { return '()'; } - final fields = workflow.runValueParameters + final fields = parameters .map((parameter) => '${parameter.typeCode} ${parameter.name}') .join(', '); return '({$fields})'; diff --git a/packages/stem_builder/test/stem_registry_builder_test.dart b/packages/stem_builder/test/stem_registry_builder_test.dart index b4b3bcf3..8abb0101 100644 --- a/packages/stem_builder/test/stem_registry_builder_test.dart +++ b/packages/stem_builder/test/stem_registry_builder_test.dart @@ -211,7 +211,6 @@ Future sendEmail( allOf([ contains('StemWorkflowDefinitions'), contains('StemTaskDefinitions'), - contains('WorkflowRef, String>'), contains('NoArgsWorkflowRef'), contains('Flow('), contains('WorkflowScript('), @@ -297,7 +296,7 @@ class DailyBillingWorkflow { 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( allOf([ contains( - 'static final WorkflowRef, Object?> ' + 'static final NoArgsWorkflowRef ' 'helloFlow =', ), contains( @@ -341,21 +340,9 @@ class HelloScriptWorkflow { 'helloScriptWorkflow =', ), contains('NoArgsWorkflowRef('), - contains('static Future startHelloScriptWorkflow('), - contains( - 'static Future startHelloScriptWorkflowWithContext(', - ), - contains( - 'static Future?> ' - 'startAndWaitHelloScriptWorkflow(', - ), - contains( - 'startAndWaitHelloScriptWorkflowWithContext(', - ), - contains( - 'static Future?> ' - 'waitForHelloScriptWorkflow(', - ), + isNot(contains('startHelloScriptWorkflow(')), + isNot(contains('startAndWaitHelloScriptWorkflow(')), + isNot(contains('waitForHelloScriptWorkflow(')), ]), ), }, @@ -386,10 +373,8 @@ Future pingTask() async => 'pong'; allOf([ contains('static final NoArgsTaskDefinition pingTask ='), contains('NoArgsTaskDefinition('), - contains('static Future enqueuePingTask('), - contains( - 'static Future?> enqueueAndWaitPingTask(', - ), + isNot(contains('enqueuePingTask(')), + isNot(contains('enqueueAndWaitPingTask(')), isNot(contains('encodeArgs: (args) => const {}')), ]), ), @@ -427,12 +412,11 @@ Future sendEmail(EmailRequest request) async => request.email; outputs: { 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( allOf([ - contains('static Future enqueueEmailSend('), contains( - 'static Future?> enqueueAndWaitEmailSend(', + 'static final TaskDefinition<({EmailRequest request}), String> emailSend =', ), - contains('required EmailRequest request'), - contains('return emailSend'), + isNot(contains('enqueueEmailSend(')), + isNot(contains('enqueueAndWaitEmailSend(')), ]), ), }, @@ -463,20 +447,49 @@ class SignupWorkflow { outputs: { 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( allOf([ - contains('static Future startSignupWorkflow('), - contains('static Future startSignupWorkflowWithContext('), contains( - 'static Future?> ' - 'startAndWaitSignupWorkflow(', - ), - contains( - 'startAndWaitSignupWorkflowWithContext(', + 'static final WorkflowRef<({String email}), String> signupWorkflow =', ), + isNot(contains('startSignupWorkflow(')), + isNot(contains('startAndWaitSignupWorkflow(')), + isNot(contains('waitForSignupWorkflow(')), + ]), + ), + }, + ); + }); + + test('generates typed workflow refs for annotated flows', () async { + const input = ''' +import 'package:stem/stem.dart'; + +part 'workflows.stem.g.dart'; + +@WorkflowDefn() +class GreetingFlow { + @WorkflowStep() + Future greet(String name) async => 'hello \$name'; +} +'''; + + await testBuilder( + stemRegistryBuilder(BuilderOptions.empty), + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + outputs: { + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( + allOf([ contains( - 'static Future?> ' - 'waitForSignupWorkflow(', + 'static final WorkflowRef<({String name}), String> greetingFlow =', ), - contains('required String email'), + isNot(contains('startGreetingFlow(')), + isNot(contains('startAndWaitGreetingFlow(')), + isNot(contains('waitForGreetingFlow(')), ]), ), }, From 326468a2f1a900be6a446b764633afc618620381 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 18:42:11 -0500 Subject: [PATCH 034/302] Cover mixed checkpoint builder warnings --- .../test/stem_registry_builder_test.dart | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/stem_builder/test/stem_registry_builder_test.dart b/packages/stem_builder/test/stem_registry_builder_test.dart index 8abb0101..67a1589d 100644 --- a/packages/stem_builder/test/stem_registry_builder_test.dart +++ b/packages/stem_builder/test/stem_registry_builder_test.dart @@ -693,6 +693,54 @@ class DuplicateManualCheckpointWorkflow { ); }); + test( + 'warns when manual script.step wraps an annotated checkpoint call', + () async { + const input = ''' +import 'package:stem/stem.dart'; + +part 'workflows.stem.g.dart'; + +@WorkflowDefn(kind: WorkflowKind.script) +class MixedCheckpointWorkflow { + @WorkflowRun() + Future run(WorkflowScriptContext script) async { + await script.step('outer-wrapper', (ctx) => sendEmail('user@example.com')); + } + + @WorkflowStep(name: 'send-email') + Future sendEmail(String email) async {} +} +'''; + + final records = []; + await testBuilders( + [stemRegistryBuilder(BuilderOptions.empty)], + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + onLog: records.add, + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + ); + + expect( + records, + contains( + warningLogOf( + allOf([ + contains('wraps annotated checkpoint "send-email"'), + contains('outer-wrapper'), + contains('avoid nested checkpoints'), + ]), + ), + ), + ); + }, + ); + test( 'decodes serializable @workflow.run parameters from script params', () async { From 015c5d4340f23d3c1b1a52a112380de48b855335 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 18:49:21 -0500 Subject: [PATCH 035/302] Add app-bound typed task helpers --- .site/docs/workflows/annotated-workflows.md | 4 +- .../example/annotated_workflows/bin/main.dart | 4 +- packages/stem/lib/src/bootstrap/stem_app.dart | 159 +++++++++++++++++- .../stem/lib/src/bootstrap/workflow_app.dart | 54 +++++- .../test/bootstrap/module_bootstrap_test.dart | 12 +- .../stem/test/bootstrap/stem_app_test.dart | 10 +- .../stem/test/bootstrap/stem_client_test.dart | 8 +- .../workflow_module_bootstrap_test.dart | 10 +- packages/stem_builder/README.md | 2 +- 9 files changed, 236 insertions(+), 27 deletions(-) diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index c6fc0ca6..edc44cfe 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -56,8 +56,8 @@ final result = await StemWorkflowDefinitions.userSignup.startAndWaitWithApp( Annotated tasks use the same shared typed task surface: ```dart -final result = await StemTaskDefinitions.sendEmailTyped.enqueueAndWaitWith( - workflowApp.app.stem, +final result = await StemTaskDefinitions.sendEmailTyped.enqueueAndWaitWithApp( + workflowApp, ( dispatch: EmailDispatch( email: 'typed@example.com', diff --git a/packages/stem/example/annotated_workflows/bin/main.dart b/packages/stem/example/annotated_workflows/bin/main.dart index 05e9f7fc..378c7c78 100644 --- a/packages/stem/example/annotated_workflows/bin/main.dart +++ b/packages/stem/example/annotated_workflows/bin/main.dart @@ -68,8 +68,8 @@ Future main() async { ); final typedTaskResult = await StemTaskDefinitions.sendEmailTyped - .enqueueAndWaitWith( - app.app.stem, + .enqueueAndWaitWithApp( + app, ( dispatch: const EmailDispatch( email: 'typed@example.com', diff --git a/packages/stem/lib/src/bootstrap/stem_app.dart b/packages/stem/lib/src/bootstrap/stem_app.dart index b3bcd18d..58ea4c0f 100644 --- a/packages/stem/lib/src/bootstrap/stem_app.dart +++ b/packages/stem/lib/src/bootstrap/stem_app.dart @@ -8,6 +8,7 @@ import 'package:stem/src/control/revoke_store.dart'; import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/core/stem.dart'; import 'package:stem/src/core/task_payload_encoder.dart'; +import 'package:stem/src/core/task_result.dart'; import 'package:stem/src/core/unique_task_coordinator.dart'; import 'package:stem/src/routing/routing_config.dart'; import 'package:stem/src/routing/routing_registry.dart'; @@ -16,7 +17,27 @@ import 'package:stem/src/worker/worker.dart'; import 'package:stem_memory/stem_memory.dart' show InMemoryRevokeStore; /// Convenience bootstrap for setting up a Stem runtime with sensible defaults. -class StemApp { +abstract interface class StemTaskApp implements TaskEnqueuer { + /// Waits for a task result by task id using the app's backing result store. + Future?> waitForTask( + String taskId, { + Duration? timeout, + TResult Function(Object? payload)? decode, + }); + + /// Waits for a task result using a typed [definition] for result decoding. + Future?> waitForTaskDefinition< + TArgs, + TResult extends Object? + >( + String taskId, + TaskDefinition definition, { + Duration? timeout, + }); +} + +/// Convenience bootstrap for setting up a Stem runtime with sensible defaults. +class StemApp implements StemTaskApp { StemApp._({ required this.registry, required this.broker, @@ -58,6 +79,54 @@ class StemApp { /// Registers an additional task handler with the underlying registry. void register(TaskHandler handler) => registry.register(handler); + @override + Future enqueue( + String name, { + Map args = const {}, + Map headers = const {}, + TaskOptions options = const TaskOptions(), + Map meta = const {}, + TaskEnqueueOptions? enqueueOptions, + }) { + return stem.enqueue( + name, + args: args, + headers: headers, + options: options, + meta: meta, + enqueueOptions: enqueueOptions, + ); + } + + @override + Future enqueueCall( + TaskCall call, { + TaskEnqueueOptions? enqueueOptions, + }) { + return stem.enqueueCall(call, enqueueOptions: enqueueOptions); + } + + @override + Future?> waitForTask( + String taskId, { + Duration? timeout, + TResult Function(Object? payload)? decode, + }) { + return stem.waitForTask(taskId, timeout: timeout, decode: decode); + } + + @override + Future?> waitForTaskDefinition< + TArgs, + TResult extends Object? + >( + String taskId, + TaskDefinition definition, { + Duration? timeout, + }) { + return stem.waitForTaskDefinition(taskId, definition, timeout: timeout); + } + void _insertAutoDisposers( List Function()> autoDisposers, ) { @@ -402,3 +471,91 @@ class StemApp { ); } } + +/// Adds app-bound enqueue-and-wait helpers for prebuilt [TaskCall] objects. +extension TaskCallStemTaskAppExtension + on TaskCall { + /// Enqueues this call with [app] and waits for its typed result. + Future?> enqueueAndWaitWithApp( + StemTaskApp app, { + TaskEnqueueOptions? enqueueOptions, + Duration? timeout, + }) async { + final taskId = await enqueueWith(app, enqueueOptions: enqueueOptions); + return app.waitForTaskDefinition(taskId, definition, timeout: timeout); + } +} + +/// Adds app-bound helpers for typed [TaskDefinition] values. +extension TaskDefinitionStemTaskAppExtension + on TaskDefinition { + /// Enqueues this definition with [app] and waits for its typed result. + Future?> enqueueAndWaitWithApp( + StemTaskApp app, + TArgs args, { + Map headers = const {}, + TaskOptions? options, + DateTime? notBefore, + Map? meta, + TaskEnqueueOptions? enqueueOptions, + Duration? timeout, + }) { + return call( + args, + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ).enqueueAndWaitWithApp( + app, + enqueueOptions: enqueueOptions, + timeout: timeout, + ); + } + + /// Waits for [taskId] using this definition's decoding rules through [app]. + Future?> waitForApp( + StemTaskApp app, + String taskId, { + Duration? timeout, + }) { + return app.waitForTaskDefinition(taskId, this, timeout: timeout); + } +} + +/// Adds app-bound helpers for no-arg [NoArgsTaskDefinition] values. +extension NoArgsTaskDefinitionStemTaskAppExtension + on NoArgsTaskDefinition { + /// Enqueues this no-arg definition with [app] and waits for its result. + Future?> enqueueAndWaitWithApp( + StemTaskApp app, { + Map headers = const {}, + TaskOptions? options, + DateTime? notBefore, + Map? meta, + TaskEnqueueOptions? enqueueOptions, + Duration? timeout, + }) { + return call( + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ).enqueueAndWaitWithApp( + app, + enqueueOptions: enqueueOptions, + timeout: timeout, + ); + } + + /// Waits for [taskId] using this definition's decoding rules through [app]. + Future?> waitForApp( + StemTaskApp app, + String taskId, { + Duration? timeout, + }) { + return app.waitForTaskDefinition(taskId, asDefinition, timeout: timeout); + } +} diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index 9efba845..7932e222 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -5,9 +5,11 @@ import 'package:stem/src/bootstrap/stem_module.dart'; import 'package:stem/src/bootstrap/stem_stack.dart'; import 'package:stem/src/control/revoke_store.dart'; import 'package:stem/src/core/clock.dart'; -import 'package:stem/src/core/contracts.dart' show TaskHandler; +import 'package:stem/src/core/contracts.dart' + show TaskCall, TaskDefinition, TaskEnqueueOptions, TaskHandler, TaskOptions; import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/core/task_payload_encoder.dart'; +import 'package:stem/src/core/task_result.dart'; import 'package:stem/src/core/unique_task_coordinator.dart'; import 'package:stem/src/workflow/core/event_bus.dart'; import 'package:stem/src/workflow/core/flow.dart'; @@ -29,7 +31,7 @@ import 'package:stem/src/workflow/runtime/workflow_runtime.dart'; /// This wrapper wires together broker/backend infrastructure, registers flows, /// and exposes convenience helpers for scheduling and observing workflow runs /// without having to manage [WorkflowRuntime] directly. -class StemWorkflowApp implements WorkflowCaller { +class StemWorkflowApp implements WorkflowCaller, StemTaskApp { StemWorkflowApp._({ required this.app, required this.runtime, @@ -74,6 +76,54 @@ class StemWorkflowApp implements WorkflowCaller { await app.start(); } + @override + Future enqueue( + String name, { + Map args = const {}, + Map headers = const {}, + TaskOptions options = const TaskOptions(), + Map meta = const {}, + TaskEnqueueOptions? enqueueOptions, + }) { + return app.enqueue( + name, + args: args, + headers: headers, + options: options, + meta: meta, + enqueueOptions: enqueueOptions, + ); + } + + @override + Future enqueueCall( + TaskCall call, { + TaskEnqueueOptions? enqueueOptions, + }) { + return app.enqueueCall(call, enqueueOptions: enqueueOptions); + } + + @override + Future?> waitForTask( + String taskId, { + Duration? timeout, + TResult Function(Object? payload)? decode, + }) { + return app.waitForTask(taskId, timeout: timeout, decode: decode); + } + + @override + Future?> waitForTaskDefinition< + TArgs, + TResult extends Object? + >( + String taskId, + TaskDefinition definition, { + Duration? timeout, + }) { + return app.waitForTaskDefinition(taskId, definition, timeout: timeout); + } + /// Schedules a workflow run. /// /// Lazily starts the runtime on the first invocation so simple examples do diff --git a/packages/stem/test/bootstrap/module_bootstrap_test.dart b/packages/stem/test/bootstrap/module_bootstrap_test.dart index e6c9389e..89b93467 100644 --- a/packages/stem/test/bootstrap/module_bootstrap_test.dart +++ b/packages/stem/test/bootstrap/module_bootstrap_test.dart @@ -10,6 +10,10 @@ void main() { entrypoint: (context, args) async => 'task-ok', runInIsolate: false, ); + final moduleDefinition = TaskDefinition.noArgs( + name: 'module.bootstrap.task', + defaultOptions: const TaskOptions(queue: 'priority'), + ); final app = await StemApp.inMemory( module: StemModule(tasks: [moduleTask]), @@ -19,12 +23,8 @@ void main() { expect(app.registry.resolve('module.bootstrap.task'), same(moduleTask)); expect(app.worker.subscription.queues, ['priority']); - final taskId = await app.stem.enqueue( - 'module.bootstrap.task', - enqueueOptions: const TaskEnqueueOptions(queue: 'priority'), - ); - final result = await app.stem.waitForTask( - taskId, + final result = await moduleDefinition.enqueueAndWaitWithApp( + app, timeout: const Duration(seconds: 2), ); diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index bcc17b40..9d4b8c5c 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -506,6 +506,9 @@ void main() { entrypoint: (context, args) async => 'queued-ok', runInIsolate: false, ); + final helperDefinition = TaskDefinition.noArgs( + name: 'workflow.module.queue-helper', + ); final workflowApp = await StemWorkflowApp.inMemory( module: StemModule(tasks: [helperTask]), ); @@ -516,11 +519,8 @@ void main() { ); await workflowApp.start(); - final taskId = await workflowApp.app.stem.enqueue( - 'workflow.module.queue-helper', - ); - final result = await workflowApp.app.stem.waitForTask( - taskId, + final result = await helperDefinition.enqueueAndWaitWithApp( + workflowApp, timeout: const Duration(seconds: 2), ); expect(result?.value, 'queued-ok'); diff --git a/packages/stem/test/bootstrap/stem_client_test.dart b/packages/stem/test/bootstrap/stem_client_test.dart index 93dfe861..848fdae1 100644 --- a/packages/stem/test/bootstrap/stem_client_test.dart +++ b/packages/stem/test/bootstrap/stem_client_test.dart @@ -142,6 +142,9 @@ void main() { entrypoint: (context, args) async => 'task-ok', runInIsolate: false, ); + final taskDefinition = TaskDefinition.noArgs( + name: 'client.module.queued-task', + ); final app = await client.createWorkflowApp( module: StemModule(tasks: [moduleTask]), ); @@ -152,9 +155,8 @@ void main() { ); await app.start(); - final taskId = await app.app.stem.enqueue('client.module.queued-task'); - final result = await app.app.stem.waitForTask( - taskId, + final result = await taskDefinition.enqueueAndWaitWithApp( + app, timeout: const Duration(seconds: 2), ); diff --git a/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart b/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart index d1a858d8..43143fea 100644 --- a/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart +++ b/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart @@ -9,6 +9,9 @@ void main() { entrypoint: (context, args) async => 'queued-ok', runInIsolate: false, ); + final helperDefinition = TaskDefinition.noArgs( + name: 'workflow.module.queue-helper', + ); final workflowApp = await StemWorkflowApp.inMemory( module: StemModule(tasks: [helperTask]), ); @@ -19,11 +22,8 @@ void main() { ); await workflowApp.start(); - final taskId = await workflowApp.app.stem.enqueue( - 'workflow.module.queue-helper', - ); - final result = await workflowApp.app.stem.waitForTask( - taskId, + final result = await helperDefinition.enqueueAndWaitWithApp( + workflowApp, timeout: const Duration(seconds: 2), ); expect(result?.value, 'queued-ok'); diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index c180680f..92d6991d 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -262,7 +262,7 @@ Annotated tasks also get generated definitions: ```dart final taskId = await StemTaskDefinitions.builderExampleTask .call(const {'kind': 'welcome'}) - .enqueueWith(workflowApp.app.stem); + .enqueueWith(workflowApp); ``` ## Examples From 1bbd78dde3c936a418487e6b6b6f205a7ce40713 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 18:51:48 -0500 Subject: [PATCH 036/302] Add client-bound typed task helpers --- packages/stem/README.md | 2 +- .../stem/lib/src/bootstrap/stem_client.dart | 145 +++++++++++++++++- .../stem/test/bootstrap/stem_client_test.dart | 6 +- 3 files changed, 148 insertions(+), 5 deletions(-) diff --git a/packages/stem/README.md b/packages/stem/README.md index eb93f914..97a71d72 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -64,7 +64,7 @@ Future main() async { final worker = await client.createWorker(); unawaited(worker.start()); - await client.stem.enqueue('demo.hello'); + await client.enqueue('demo.hello'); await Future.delayed(const Duration(seconds: 1)); await worker.shutdown(); diff --git a/packages/stem/lib/src/bootstrap/stem_client.dart b/packages/stem/lib/src/bootstrap/stem_client.dart index 2a519c4c..243f2767 100644 --- a/packages/stem/lib/src/bootstrap/stem_client.dart +++ b/packages/stem/lib/src/bootstrap/stem_client.dart @@ -6,6 +6,7 @@ import 'package:stem/src/bootstrap/workflow_app.dart'; import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/core/stem.dart'; import 'package:stem/src/core/task_payload_encoder.dart'; +import 'package:stem/src/core/task_result.dart'; import 'package:stem/src/core/unique_task_coordinator.dart'; import 'package:stem/src/routing/routing_config.dart'; import 'package:stem/src/routing/routing_registry.dart'; @@ -18,7 +19,7 @@ import 'package:stem/src/workflow/runtime/workflow_introspection.dart'; import 'package:stem/src/workflow/runtime/workflow_registry.dart'; /// Shared entrypoint that owns broker/backend configuration for Stem runtimes. -abstract class StemClient { +abstract class StemClient implements TaskEnqueuer { /// Creates a client using the provided factories and defaults. static Future create({ StemModule? module, @@ -147,6 +148,54 @@ abstract class StemClient { /// Enqueue facade for producers. Stem get stem; + @override + Future enqueue( + String name, { + Map args = const {}, + Map headers = const {}, + TaskOptions options = const TaskOptions(), + Map meta = const {}, + TaskEnqueueOptions? enqueueOptions, + }) { + return stem.enqueue( + name, + args: args, + headers: headers, + options: options, + meta: meta, + enqueueOptions: enqueueOptions, + ); + } + + @override + Future enqueueCall( + TaskCall call, { + TaskEnqueueOptions? enqueueOptions, + }) { + return stem.enqueueCall(call, enqueueOptions: enqueueOptions); + } + + /// Waits for a task result by task id using the client's shared backend. + Future?> waitForTask( + String taskId, { + Duration? timeout, + TResult Function(Object? payload)? decode, + }) { + return stem.waitForTask(taskId, timeout: timeout, decode: decode); + } + + /// Waits for a task result using a typed [definition] for decoding. + Future?> waitForTaskDefinition< + TArgs, + TResult extends Object? + >( + String taskId, + TaskDefinition definition, { + Duration? timeout, + }) { + return stem.waitForTaskDefinition(taskId, definition, timeout: timeout); + } + /// Payload encoder registry used for task args/results. TaskPayloadEncoderRegistry get encoderRegistry; @@ -252,6 +301,100 @@ abstract class StemClient { Future close(); } +/// Adds client-bound enqueue-and-wait helpers for prebuilt [TaskCall] objects. +extension TaskCallStemClientExtension + on TaskCall { + /// Enqueues this call with [client] and waits for its typed result. + Future?> enqueueAndWaitWithClient( + StemClient client, { + TaskEnqueueOptions? enqueueOptions, + Duration? timeout, + }) async { + final taskId = await enqueueWith(client, enqueueOptions: enqueueOptions); + return client.waitForTaskDefinition(taskId, definition, timeout: timeout); + } +} + +/// Adds client-bound helpers for typed [TaskDefinition] values. +extension TaskDefinitionStemClientExtension + on TaskDefinition { + /// Enqueues this definition with [client] and waits for its typed result. + Future?> enqueueAndWaitWithClient( + StemClient client, + TArgs args, { + Map headers = const {}, + TaskOptions? options, + DateTime? notBefore, + Map? meta, + TaskEnqueueOptions? enqueueOptions, + Duration? timeout, + }) { + return call( + args, + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ).enqueueAndWaitWithClient( + client, + enqueueOptions: enqueueOptions, + timeout: timeout, + ); + } + + /// Waits for [taskId] using this definition's decoding rules through + /// [client]. + Future?> waitForClient( + StemClient client, + String taskId, { + Duration? timeout, + }) { + return client.waitForTaskDefinition(taskId, this, timeout: timeout); + } +} + +/// Adds client-bound helpers for no-arg [NoArgsTaskDefinition] values. +extension NoArgsTaskDefinitionStemClientExtension + on NoArgsTaskDefinition { + /// Enqueues this no-arg definition with [client] and waits for its result. + Future?> enqueueAndWaitWithClient( + StemClient client, { + Map headers = const {}, + TaskOptions? options, + DateTime? notBefore, + Map? meta, + TaskEnqueueOptions? enqueueOptions, + Duration? timeout, + }) { + return call( + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ).enqueueAndWaitWithClient( + client, + enqueueOptions: enqueueOptions, + timeout: timeout, + ); + } + + /// Waits for [taskId] using this definition's decoding rules through + /// [client]. + Future?> waitForClient( + StemClient client, + String taskId, { + Duration? timeout, + }) { + return client.waitForTaskDefinition( + taskId, + asDefinition, + timeout: timeout, + ); + } +} + class _DefaultStemClient extends StemClient { _DefaultStemClient({ required this.broker, diff --git a/packages/stem/test/bootstrap/stem_client_test.dart b/packages/stem/test/bootstrap/stem_client_test.dart index 848fdae1..3f16df12 100644 --- a/packages/stem/test/bootstrap/stem_client_test.dart +++ b/packages/stem/test/bootstrap/stem_client_test.dart @@ -280,6 +280,7 @@ void main() { name: 'client.from-url', entrypoint: (context, args) async => 'ok', ); + final definition = TaskDefinition.noArgs(name: 'client.from-url'); final client = await StemClient.fromUrl( 'test://localhost', adapters: [ @@ -298,9 +299,8 @@ void main() { final worker = await client.createWorker(); await worker.start(); try { - final taskId = await client.stem.enqueue('client.from-url'); - final result = await client.stem.waitForTask( - taskId, + final result = await definition.enqueueAndWaitWithClient( + client, timeout: const Duration(seconds: 2), ); expect(result?.value, 'ok'); From 0216409f7ad484fd8ebb6b364dbbd5b0044f9263 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 18:59:47 -0500 Subject: [PATCH 037/302] Make workflow contexts task enqueuers --- .../workflows/context-and-serialization.md | 3 + packages/stem/README.md | 8 +- .../lib/src/workflows/checkout_flow.dart | 46 ++++---- .../workflows/runtime_metadata_views.dart | 2 +- .../lib/src/workflow/core/flow_context.dart | 42 ++++++- .../core/workflow_script_context.dart | 19 ++- .../workflow/runtime/workflow_runtime.dart | 35 ++++++ .../test/unit/workflow/flow_context_test.dart | 75 ++++++++++++ .../unit/workflow/workflow_resume_test.dart | 109 +++++++++++++++++- .../test/workflow/workflow_runtime_test.dart | 4 +- packages/stem_builder/README.md | 6 + 11 files changed, 315 insertions(+), 34 deletions(-) diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index b13ad94f..01499450 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -40,6 +40,9 @@ Depending on the context type, you can access: - `idempotencyKey(...)` - `workflows` for typed child-workflow starts from durable step/checkpoint contexts +- direct task enqueue APIs because `FlowContext`, + `WorkflowScriptStepContext`, and `TaskInvocationContext` all implement + `TaskEnqueuer` - task metadata like `id`, `attempt`, `meta` Child workflow starts belong in durable boundaries: diff --git a/packages/stem/README.md b/packages/stem/README.md index 97a71d72..5d311157 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -571,6 +571,12 @@ Context injection works at every runtime layer: - script checkpoints can take `WorkflowScriptStepContext` - tasks can take `TaskInvocationContext` +Durable workflow contexts enqueue tasks directly: + +- `FlowContext.enqueue(...)` +- `WorkflowScriptStepContext.enqueue(...)` +- typed task definitions can target those contexts via `enqueueWith(...)` + Child workflows belong in durable execution boundaries: - use @@ -689,7 +695,7 @@ for side effects: flow.step('emit-side-effects', (ctx) async { final order = ctx.previousResult as Map; - await ctx.enqueuer!.enqueue( + await ctx.enqueue( 'ecommerce.audit.log', args: { 'event': 'order.checked_out', diff --git a/packages/stem/example/ecommerce/lib/src/workflows/checkout_flow.dart b/packages/stem/example/ecommerce/lib/src/workflows/checkout_flow.dart index fe979430..4ced4f4d 100644 --- a/packages/stem/example/ecommerce/lib/src/workflows/checkout_flow.dart +++ b/packages/stem/example/ecommerce/lib/src/workflows/checkout_flow.dart @@ -71,31 +71,29 @@ Flow> buildCheckoutFlow(EcommerceRepository repository) { final orderId = order['id']?.toString() ?? ''; final cartId = order['cartId']?.toString() ?? ''; - if (ctx.enqueuer != null) { - await ctx.enqueuer!.enqueue( - 'ecommerce.audit.log', - args: { - 'event': 'order.checked_out', - 'entityId': orderId, - 'detail': 'cart=$cartId', - }, - options: const TaskOptions(queue: 'default'), - meta: { - 'workflow': checkoutWorkflowName, - 'step': 'emit-side-effects', - }, - ); + await ctx.enqueue( + 'ecommerce.audit.log', + args: { + 'event': 'order.checked_out', + 'entityId': orderId, + 'detail': 'cart=$cartId', + }, + options: const TaskOptions(queue: 'default'), + meta: { + 'workflow': checkoutWorkflowName, + 'step': 'emit-side-effects', + }, + ); - await ctx.enqueuer!.enqueue( - 'ecommerce.shipping.reserve', - args: {'orderId': orderId, 'carrier': 'acme-post'}, - options: const TaskOptions(queue: 'default'), - meta: { - 'workflow': checkoutWorkflowName, - 'step': 'emit-side-effects', - }, - ); - } + await ctx.enqueue( + 'ecommerce.shipping.reserve', + args: {'orderId': orderId, 'carrier': 'acme-post'}, + options: const TaskOptions(queue: 'default'), + meta: { + 'workflow': checkoutWorkflowName, + 'step': 'emit-side-effects', + }, + ); return order; }); diff --git a/packages/stem/example/workflows/runtime_metadata_views.dart b/packages/stem/example/workflows/runtime_metadata_views.dart index f08ae774..f729da42 100644 --- a/packages/stem/example/workflows/runtime_metadata_views.dart +++ b/packages/stem/example/workflows/runtime_metadata_views.dart @@ -34,7 +34,7 @@ Future main() async { name: 'example.runtime.features', build: (flow) { flow.step('dispatch-task', (ctx) async { - await ctx.enqueuer!.enqueue( + await ctx.enqueue( 'example.noop', args: const {'payload': true}, meta: const {'origin': 'runtime_metadata_views'}, diff --git a/packages/stem/lib/src/workflow/core/flow_context.dart b/packages/stem/lib/src/workflow/core/flow_context.dart index b11d4c11..6d7af510 100644 --- a/packages/stem/lib/src/workflow/core/flow_context.dart +++ b/packages/stem/lib/src/workflow/core/flow_context.dart @@ -14,7 +14,7 @@ import 'package:stem/src/workflow/core/workflow_ref.dart'; /// [iteration] indicates how many times the step has already completed when /// `autoVersion` is enabled, allowing handlers to branch per loop iteration or /// derive unique identifiers. -class FlowContext implements WorkflowChildCallerContext { +class FlowContext implements WorkflowChildCallerContext, TaskEnqueuer { /// Creates a workflow step context. FlowContext({ required this.workflow, @@ -152,4 +152,44 @@ class FlowContext implements WorkflowChildCallerContext { : scope; return '$workflow/$runId/$effectiveScope'; } + + /// Enqueues a task using the workflow-scoped enqueuer. + /// + /// Workflow metadata propagation is handled by the runtime-provided + /// enqueuer implementation. + @override + Future enqueue( + String name, { + Map args = const {}, + Map headers = const {}, + Map meta = const {}, + TaskOptions options = const TaskOptions(), + TaskEnqueueOptions? enqueueOptions, + }) async { + final delegate = enqueuer; + if (delegate == null) { + throw StateError('FlowContext has no enqueuer configured'); + } + return delegate.enqueue( + name, + args: args, + headers: headers, + meta: meta, + options: options, + enqueueOptions: enqueueOptions, + ); + } + + /// Enqueues a typed task call using the workflow-scoped enqueuer. + @override + Future enqueueCall( + TaskCall call, { + TaskEnqueueOptions? enqueueOptions, + }) async { + final delegate = enqueuer; + if (delegate == null) { + throw StateError('FlowContext has no enqueuer configured'); + } + return delegate.enqueueCall(call, enqueueOptions: enqueueOptions); + } } diff --git a/packages/stem/lib/src/workflow/core/workflow_script_context.dart b/packages/stem/lib/src/workflow/core/workflow_script_context.dart index 58842f7b..888383e4 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script_context.dart @@ -29,7 +29,8 @@ abstract class WorkflowScriptContext { /// Context provided to each script checkpoint invocation. Mirrors /// [FlowContext] but tailored for the facade helpers. -abstract class WorkflowScriptStepContext implements WorkflowChildCallerContext { +abstract class WorkflowScriptStepContext + implements WorkflowChildCallerContext, TaskEnqueuer { /// Name of the workflow currently executing. String get workflow; @@ -75,4 +76,20 @@ abstract class WorkflowScriptStepContext implements WorkflowChildCallerContext { /// Optional typed workflow caller for spawning child workflows. @override WorkflowCaller? get workflows; + + @override + Future enqueue( + String name, { + Map args = const {}, + Map headers = const {}, + Map meta = const {}, + TaskOptions options = const TaskOptions(), + TaskEnqueueOptions? enqueueOptions, + }); + + @override + Future enqueueCall( + TaskCall call, { + TaskEnqueueOptions? enqueueOptions, + }); } diff --git a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart index ca9042c4..3655dd7d 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart @@ -1851,6 +1851,41 @@ class _WorkflowScriptStepContextImpl implements WorkflowScriptStepContext { @override String get workflow => execution.workflow; + + @override + Future enqueue( + String name, { + Map args = const {}, + Map headers = const {}, + Map meta = const {}, + TaskOptions options = const TaskOptions(), + TaskEnqueueOptions? enqueueOptions, + }) async { + final delegate = enqueuer; + if (delegate == null) { + throw StateError('WorkflowScriptStepContext has no enqueuer configured'); + } + return delegate.enqueue( + name, + args: args, + headers: headers, + meta: meta, + options: options, + enqueueOptions: enqueueOptions, + ); + } + + @override + Future enqueueCall( + TaskCall call, { + TaskEnqueueOptions? enqueueOptions, + }) async { + final delegate = enqueuer; + if (delegate == null) { + throw StateError('WorkflowScriptStepContext has no enqueuer configured'); + } + return delegate.enqueueCall(call, enqueueOptions: enqueueOptions); + } } /// Enqueuer that prefixes workflow step metadata onto spawned tasks. diff --git a/packages/stem/test/unit/workflow/flow_context_test.dart b/packages/stem/test/unit/workflow/flow_context_test.dart index 7cab213d..edc748e3 100644 --- a/packages/stem/test/unit/workflow/flow_context_test.dart +++ b/packages/stem/test/unit/workflow/flow_context_test.dart @@ -2,6 +2,8 @@ import 'package:stem/src/workflow/core/flow_context.dart'; import 'package:stem/src/workflow/core/flow_step.dart'; import 'package:stem/src/workflow/core/workflow_clock.dart'; import 'package:stem/src/workflow/core/workflow_ref.dart'; +import 'package:stem/stem.dart' + show TaskCall, TaskEnqueueOptions, TaskEnqueuer, TaskOptions; import 'package:test/test.dart'; void main() { @@ -122,4 +124,77 @@ void main() { ); }, ); + + test('FlowContext.enqueue delegates to the configured enqueuer', () async { + final enqueuer = _RecordingEnqueuer(); + final context = FlowContext( + workflow: 'demo', + runId: 'run-6', + stepName: 'dispatch', + params: const {}, + previousResult: null, + stepIndex: 0, + enqueuer: enqueuer, + ); + + final taskId = await context.enqueue( + 'tasks.child', + args: const {'value': 42}, + meta: const {'source': 'flow'}, + ); + + expect(taskId, equals('recorded-1')); + expect(enqueuer.lastName, equals('tasks.child')); + expect(enqueuer.lastArgs, equals({'value': 42})); + expect(enqueuer.lastMeta, containsPair('source', 'flow')); + }); + + test('FlowContext.enqueue throws when no enqueuer is configured', () { + final context = FlowContext( + workflow: 'demo', + runId: 'run-7', + stepName: 'dispatch', + params: const {}, + previousResult: null, + stepIndex: 0, + ); + + expect(() => context.enqueue('tasks.child'), throwsStateError); + }); +} + +class _RecordingEnqueuer implements TaskEnqueuer { + String? lastName; + Map? lastArgs; + Map? lastMeta; + + @override + Future enqueue( + String name, { + Map args = const {}, + Map headers = const {}, + Map meta = const {}, + TaskOptions options = const TaskOptions(), + TaskEnqueueOptions? enqueueOptions, + }) async { + lastName = name; + lastArgs = Map.from(args); + lastMeta = Map.from(meta); + return 'recorded-1'; + } + + @override + Future enqueueCall( + TaskCall call, { + TaskEnqueueOptions? enqueueOptions, + }) { + return enqueue( + call.name, + args: call.encodeArgs(), + headers: call.headers, + meta: call.meta, + options: call.resolveOptions(), + enqueueOptions: enqueueOptions ?? call.enqueueOptions, + ); + } } diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart index 1cfaba99..f8c6ea6c 100644 --- a/packages/stem/test/unit/workflow/workflow_resume_test.dart +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -216,6 +216,34 @@ void main() { final resumedValue = resumed.waitForEventRef(event); expect(resumedValue?.message, 'approved'); }); + + test( + 'WorkflowScriptStepContext.enqueue delegates to the configured enqueuer', + () async { + final enqueuer = _RecordingTaskEnqueuer(); + final context = _FakeWorkflowScriptStepContext(enqueuer: enqueuer); + + final taskId = await context.enqueue( + 'tasks.child', + args: const {'value': 42}, + meta: const {'source': 'script'}, + ); + + expect(taskId, equals('recorded-1')); + expect(enqueuer.lastName, equals('tasks.child')); + expect(enqueuer.lastArgs, equals({'value': 42})); + expect(enqueuer.lastMeta, containsPair('source', 'script')); + }, + ); + + test( + 'WorkflowScriptStepContext.enqueue throws when no enqueuer is configured', + () { + final context = _FakeWorkflowScriptStepContext(); + + expect(() => context.enqueue('tasks.child'), throwsStateError); + }, + ); } class _ResumePayload { @@ -245,15 +273,19 @@ _ResumePayload _decodeResumePayload(Object? payload) { } class _FakeWorkflowScriptStepContext implements WorkflowScriptStepContext { - _FakeWorkflowScriptStepContext({Object? resumeData}) - : _resumeData = resumeData; + _FakeWorkflowScriptStepContext({ + Object? resumeData, + TaskEnqueuer? enqueuer, + }) : _resumeData = resumeData, + _enqueuer = enqueuer; Object? _resumeData; + final TaskEnqueuer? _enqueuer; final List awaitedTopics = []; final List sleepCalls = []; @override - TaskEnqueuer? get enqueuer => null; + TaskEnqueuer? get enqueuer => _enqueuer; @override Never? get workflows => null; @@ -303,4 +335,75 @@ class _FakeWorkflowScriptStepContext implements WorkflowScriptStepContext { _resumeData = null; return value; } + + @override + Future enqueue( + String name, { + Map args = const {}, + Map headers = const {}, + Map meta = const {}, + TaskOptions options = const TaskOptions(), + TaskEnqueueOptions? enqueueOptions, + }) async { + final delegate = _enqueuer; + if (delegate == null) { + throw StateError('WorkflowScriptStepContext has no enqueuer configured'); + } + return delegate.enqueue( + name, + args: args, + headers: headers, + meta: meta, + options: options, + enqueueOptions: enqueueOptions, + ); + } + + @override + Future enqueueCall( + TaskCall call, { + TaskEnqueueOptions? enqueueOptions, + }) async { + final delegate = _enqueuer; + if (delegate == null) { + throw StateError('WorkflowScriptStepContext has no enqueuer configured'); + } + return delegate.enqueueCall(call, enqueueOptions: enqueueOptions); + } +} + +class _RecordingTaskEnqueuer implements TaskEnqueuer { + String? lastName; + Map? lastArgs; + Map? lastMeta; + + @override + Future enqueue( + String name, { + Map args = const {}, + Map headers = const {}, + Map meta = const {}, + TaskOptions options = const TaskOptions(), + TaskEnqueueOptions? enqueueOptions, + }) async { + lastName = name; + lastArgs = Map.from(args); + lastMeta = Map.from(meta); + return 'recorded-1'; + } + + @override + Future enqueueCall( + TaskCall call, { + TaskEnqueueOptions? enqueueOptions, + }) { + return enqueue( + call.name, + args: call.encodeArgs(), + headers: call.headers, + meta: call.meta, + options: call.resolveOptions(), + enqueueOptions: enqueueOptions ?? call.enqueueOptions, + ); + } } diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index 8b2cf01e..d823953d 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -1188,9 +1188,7 @@ void main() { name: 'meta.workflow', build: (flow) { flow.step('dispatch', (context) async { - final enqueuer = context.enqueuer; - expect(enqueuer, isNotNull); - await enqueuer!.enqueue( + await context.enqueue( taskName, meta: const {'custom': 'value'}, ); diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index 92d6991d..74dfaebd 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -103,6 +103,12 @@ Supported context injection points: - script checkpoints: `WorkflowScriptStepContext` - tasks: `TaskInvocationContext` +Durable workflow contexts enqueue tasks directly: + +- `FlowContext.enqueue(...)` +- `WorkflowScriptStepContext.enqueue(...)` +- typed task definitions can target those contexts via `enqueueWith(...)` + Child workflows should be started from durable boundaries: - `StemWorkflowDefinitions.someWorkflow.startWithContext(context, (...))` From e0d8311059b4e2ff9505e8839d65014eaa4f7ac5 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 19:02:09 -0500 Subject: [PATCH 038/302] Add direct typed workflow event helpers --- .../docs/workflows/suspensions-and-events.md | 3 +- packages/stem/README.md | 3 +- packages/stem/example/durable_watchers.dart | 4 +- .../example/workflows/sleep_and_event.dart | 2 +- .../stem/lib/src/bootstrap/workflow_app.dart | 14 ++++++ .../workflow/workflow_runtime_ref_test.dart | 43 +++++++++++++++++++ .../test/workflow/workflow_runtime_test.dart | 4 +- 7 files changed, 66 insertions(+), 7 deletions(-) diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index 40a8bc89..ddd5494e 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -66,7 +66,8 @@ wire format. `emitValue(...)` is a DTO/codec convenience layer, not a new transport shape. When the topic and codec travel together in your codebase, prefer a typed -`WorkflowEventRef` and `emitEvent(...)` / `waitForEventRef(...)`. +`WorkflowEventRef` and `event.emitWithApp(...)` / +`event.emitWithRuntime(...)` together with `waitForEventRef(...)`. ## Inspect waiting runs diff --git a/packages/stem/README.md b/packages/stem/README.md index 5d311157..3b52ca21 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -1004,7 +1004,8 @@ backend metadata under `stem.unique.duplicates`. `takeResumeData()` / `takeResumeValue(codec: ...)` when the run resumes. - When you have a DTO event, emit it through `runtime.emitValue(...)` / `workflowApp.emitValue(...)` with a `PayloadCodec`, or bundle the topic - and codec once in a `WorkflowEventRef` and use `emitEvent(...)` / + and codec once in a `WorkflowEventRef` and use + `event.emitWithApp(...)` / `event.emitWithRuntime(...)` together with `waitForEventRef(...)`. Event payloads still serialize onto the existing `Map` wire format. - Only return values you want persisted. If a handler returns `null`, the diff --git a/packages/stem/example/durable_watchers.dart b/packages/stem/example/durable_watchers.dart index 1f537733..1d0372b5 100644 --- a/packages/stem/example/durable_watchers.dart +++ b/packages/stem/example/durable_watchers.dart @@ -57,8 +57,8 @@ Future main() async { print('Watcher metadata: ${watcher.data}'); } - await app.emitEvent( - shipmentReadyEvent, + await shipmentReadyEvent.emitWithApp( + app, const _ShipmentReadyEvent(trackingId: 'ZX-42'), ); diff --git a/packages/stem/example/workflows/sleep_and_event.dart b/packages/stem/example/workflows/sleep_and_event.dart index 46e730fe..8a8aa608 100644 --- a/packages/stem/example/workflows/sleep_and_event.dart +++ b/packages/stem/example/workflows/sleep_and_event.dart @@ -47,7 +47,7 @@ Future main() async { await Future.delayed(const Duration(milliseconds: 50)); } - await app.emitEvent(demoEvent, {'message': 'event received'}); + await demoEvent.emitWithApp(app, {'message': 'event received'}); final result = await sleepAndEventRef.waitFor(app, runId); print('Workflow $runId resumed and completed with: ${result?.value}'); diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index 7932e222..f981e788 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -657,6 +657,20 @@ StemWorkerConfig _resolveWorkflowWorkerConfig( return workerConfig.copyWith(subscription: inferredSubscription); } +/// Convenience helpers for emitting typed workflow events through an app or +/// runtime. +extension WorkflowEventRefDispatchExtension on WorkflowEventRef { + /// Emits this typed event with [app]. + Future emitWithApp(StemWorkflowApp app, T value) { + return app.emitEvent(this, value); + } + + /// Emits this typed event with [runtime]. + Future emitWithRuntime(WorkflowRuntime runtime, T value) { + return runtime.emitEvent(this, value); + } +} + /// Convenience helpers for typed workflow start calls. extension WorkflowStartCallAppExtension on WorkflowStartCall { diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index ee42d3d2..c5a3a5de 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -35,6 +35,11 @@ const _greetingResultCodec = PayloadCodec<_GreetingResult>( decode: _decodeGreetingResult, ); +const _userUpdatedEvent = WorkflowEventRef<_GreetingParams>( + topic: 'runtime.ref.event', + codec: _greetingParamsCodec, +); + Object? _encodeGreetingParams(_GreetingParams value) => value.toJson(); _GreetingParams _decodeGreetingParams(Object? payload) { @@ -227,5 +232,43 @@ void main() { await workflowApp.shutdown(); } }); + + test('typed workflow events emit directly from the event ref', () async { + final flow = Flow( + name: 'runtime.ref.event.flow', + build: (builder) { + builder.step('wait', (ctx) async { + final payload = ctx.waitForEventRef(_userUpdatedEvent); + if (payload == null) { + return null; + } + return 'hello ${payload.name}'; + }); + }, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + await workflowApp.start(); + + final runId = await flow.ref0().startWithApp(workflowApp); + await workflowApp.runtime.executeRun(runId); + + await _userUpdatedEvent.emitWithApp( + workflowApp, + const _GreetingParams(name: 'event'), + ); + await workflowApp.runtime.executeRun(runId); + + final result = await workflowApp.waitForCompletion( + runId, + timeout: const Duration(seconds: 2), + ); + + expect(result?.value, 'hello event'); + } finally { + await workflowApp.shutdown(); + } + }); }); } diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index d823953d..42622b0d 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -632,8 +632,8 @@ void main() { expect(suspended?.status, WorkflowStatus.suspended); expect(suspended?.waitTopic, event.topic); - await runtime.emitEvent( - event, + await event.emitWithRuntime( + runtime, const _UserUpdatedEvent(id: 'user-typed-2'), ); await runtime.executeRun(runId); From bad2b0b4bfa32e6d91e001af9f57311c2ec3d0d6 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 19:07:00 -0500 Subject: [PATCH 039/302] Make workflow contexts workflow callers --- .site/docs/workflows/annotated-workflows.md | 4 +- packages/stem/CHANGELOG.md | 4 ++ packages/stem/README.md | 4 +- .../example/annotated_workflows/README.md | 4 +- .../annotated_workflows/lib/definitions.dart | 4 +- .../lib/src/workflow/core/flow_context.dart | 60 +++++++++++++++- .../core/workflow_script_context.dart | 27 +++++++- .../workflow/runtime/workflow_runtime.dart | 58 ++++++++++++++++ .../test/unit/workflow/flow_context_test.dart | 10 ++- .../unit/workflow/workflow_resume_test.dart | 68 ++++++++++++++++++- .../test/workflow/workflow_runtime_test.dart | 12 ++-- packages/stem_builder/CHANGELOG.md | 3 + packages/stem_builder/README.md | 4 +- 13 files changed, 236 insertions(+), 26 deletions(-) diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index edc44cfe..30e2d349 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -137,9 +137,9 @@ This keeps one authoring model: When a workflow needs to start another workflow, do it from a durable boundary: -- `StemWorkflowDefinitions.someWorkflow.startAndWaitWithContext(context, (...))` +- `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, (...))` inside flow steps -- `StemWorkflowDefinitions.someWorkflow.startAndWaitWithContext(context, (...))` +- `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, (...))` inside checkpoint methods Avoid starting child workflows from the raw `WorkflowScriptContext` body. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index f18e6d89..52a48bdf 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -4,6 +4,10 @@ - Added `StemModule`, typed `WorkflowRef`/`WorkflowStartCall` helpers, and bundle-first `StemWorkflowApp`/`StemClient` composition for generated workflow and task definitions. - Added `PayloadCodec`, typed workflow resume helpers, codec-backed workflow checkpoint/result persistence, typed task result waiting, and typed workflow event emit helpers for DTO-shaped payloads. +- Made `FlowContext` and `WorkflowScriptStepContext` implement `WorkflowCaller` + directly so child workflows can start and wait through + `WorkflowStartCall.startWith(...)` / `startAndWaitWith(...)` without special + context-only helper variants. - Simplified generated annotated task usage so `StemTaskDefinitions.*` is the canonical surface, reusing shared `TaskCall.enqueueWith(...)` and `TaskDefinition.waitFor(...)` helpers instead of emitting separate generated diff --git a/packages/stem/README.md b/packages/stem/README.md index 3b52ca21..6b1efb9a 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -580,10 +580,10 @@ Durable workflow contexts enqueue tasks directly: Child workflows belong in durable execution boundaries: - use - `StemWorkflowDefinitions.someWorkflow.startAndWaitWithContext(context, (...))` + `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, (...))` inside flow steps - use - `StemWorkflowDefinitions.someWorkflow.startAndWaitWithContext(context, (...))` + `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, (...))` inside script checkpoints - do not start child workflows from the raw `WorkflowScriptContext` body unless you are deliberately managing replay/idempotency yourself diff --git a/packages/stem/example/annotated_workflows/README.md b/packages/stem/example/annotated_workflows/README.md index d7ea4e21..b4c766c5 100644 --- a/packages/stem/example/annotated_workflows/README.md +++ b/packages/stem/example/annotated_workflows/README.md @@ -6,7 +6,7 @@ with the `stem_builder` bundle generator. It now demonstrates the generated script-proxy behavior explicitly: - a flow step using `FlowContext` - a flow step starting and waiting on a child workflow through - `StemWorkflowDefinitions.*.startAndWaitWithContext(context, (...))` + `StemWorkflowDefinitions.*.startAndWaitWith(context, (...))` - `run(WelcomeRequest request)` calls annotated checkpoint methods directly - `prepareWelcome(...)` calls other annotated checkpoints - `deliverWelcome(...)` calls another annotated checkpoint from inside an @@ -15,7 +15,7 @@ It now demonstrates the generated script-proxy behavior explicitly: (`WorkflowScriptContext? context` / `WorkflowScriptStepContext? context`) to expose `runId`, `workflow`, `stepName`, `stepIndex`, and idempotency keys - a script checkpoint starting and waiting on a child workflow through - `StemWorkflowDefinitions.*.startAndWaitWithContext(context, (...))` + `StemWorkflowDefinitions.*.startAndWaitWith(context, (...))` - a plain script workflow that returns a codec-backed DTO result and persists a codec-backed DTO checkpoint value - a typed `@TaskDefn` using optional named `TaskInvocationContext? context` diff --git a/packages/stem/example/annotated_workflows/lib/definitions.dart b/packages/stem/example/annotated_workflows/lib/definitions.dart index 5394b378..0e05cd57 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.dart @@ -195,7 +195,7 @@ class AnnotatedFlowWorkflow { return null; } final childResult = await StemWorkflowDefinitions.script - .startAndWaitWithContext( + .startAndWaitWith( ctx, (request: const WelcomeRequest(email: 'flow-child@example.com')), timeout: const Duration(seconds: 2), @@ -280,7 +280,7 @@ class AnnotatedContextScriptWorkflow { final normalizedEmail = await normalizeEmail(request.email); final subject = await buildWelcomeSubject(normalizedEmail); final childResult = await StemWorkflowDefinitions.script - .startAndWaitWithContext( + .startAndWaitWith( ctx, (request: WelcomeRequest(email: normalizedEmail)), timeout: const Duration(seconds: 2), diff --git a/packages/stem/lib/src/workflow/core/flow_context.dart b/packages/stem/lib/src/workflow/core/flow_context.dart index 6d7af510..9768420d 100644 --- a/packages/stem/lib/src/workflow/core/flow_context.dart +++ b/packages/stem/lib/src/workflow/core/flow_context.dart @@ -1,7 +1,9 @@ import 'package:stem/src/core/contracts.dart'; +import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; import 'package:stem/src/workflow/core/flow_step.dart'; import 'package:stem/src/workflow/core/workflow_clock.dart'; import 'package:stem/src/workflow/core/workflow_ref.dart'; +import 'package:stem/src/workflow/core/workflow_result.dart'; /// Context provided to each workflow step invocation. /// @@ -14,7 +16,8 @@ import 'package:stem/src/workflow/core/workflow_ref.dart'; /// [iteration] indicates how many times the step has already completed when /// `autoVersion` is enabled, allowing handlers to branch per loop iteration or /// derive unique identifiers. -class FlowContext implements WorkflowChildCallerContext, TaskEnqueuer { +class FlowContext + implements WorkflowChildCallerContext, TaskEnqueuer, WorkflowCaller { /// Creates a workflow step context. FlowContext({ required this.workflow, @@ -192,4 +195,59 @@ class FlowContext implements WorkflowChildCallerContext, TaskEnqueuer { } return delegate.enqueueCall(call, enqueueOptions: enqueueOptions); } + + /// Starts a typed child workflow using the workflow-scoped caller. + @override + Future startWorkflowRef( + WorkflowRef definition, + TParams params, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) async { + final caller = workflows; + if (caller == null) { + throw StateError('FlowContext has no workflow caller configured'); + } + return caller.startWorkflowRef( + definition, + params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + + /// Starts a prebuilt child workflow call using the workflow-scoped caller. + @override + Future startWorkflowCall( + WorkflowStartCall call, + ) async { + final caller = workflows; + if (caller == null) { + throw StateError('FlowContext has no workflow caller configured'); + } + return caller.startWorkflowCall(call); + } + + /// Waits for a typed child workflow run using the workflow-scoped caller. + @override + Future?> + waitForWorkflowRef( + String runId, + WorkflowRef definition, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) async { + final caller = workflows; + if (caller == null) { + throw StateError('FlowContext has no workflow caller configured'); + } + return caller.waitForWorkflowRef( + runId, + definition, + pollInterval: pollInterval, + timeout: timeout, + ); + } } diff --git a/packages/stem/lib/src/workflow/core/workflow_script_context.dart b/packages/stem/lib/src/workflow/core/workflow_script_context.dart index 888383e4..a4a66ddb 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script_context.dart @@ -2,7 +2,9 @@ import 'dart:async'; import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/workflow/core/flow_context.dart' show FlowContext; +import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; import 'package:stem/src/workflow/core/workflow_ref.dart'; +import 'package:stem/src/workflow/core/workflow_result.dart'; /// Runtime context exposed to workflow scripts. Implementations are provided by /// the workflow runtime so scripts can execute with durable semantics. @@ -30,7 +32,7 @@ abstract class WorkflowScriptContext { /// Context provided to each script checkpoint invocation. Mirrors /// [FlowContext] but tailored for the facade helpers. abstract class WorkflowScriptStepContext - implements WorkflowChildCallerContext, TaskEnqueuer { + implements WorkflowChildCallerContext, TaskEnqueuer, WorkflowCaller { /// Name of the workflow currently executing. String get workflow; @@ -92,4 +94,27 @@ abstract class WorkflowScriptStepContext TaskCall call, { TaskEnqueueOptions? enqueueOptions, }); + + @override + Future startWorkflowRef( + WorkflowRef definition, + TParams params, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }); + + @override + Future startWorkflowCall( + WorkflowStartCall call, + ); + + @override + Future?> + waitForWorkflowRef( + String runId, + WorkflowRef definition, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }); } diff --git a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart index 3655dd7d..55c6a317 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart @@ -1886,6 +1886,64 @@ class _WorkflowScriptStepContextImpl implements WorkflowScriptStepContext { } return delegate.enqueueCall(call, enqueueOptions: enqueueOptions); } + + @override + Future startWorkflowRef( + WorkflowRef definition, + TParams params, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) async { + final caller = workflows; + if (caller == null) { + throw StateError( + 'WorkflowScriptStepContext has no workflow caller configured', + ); + } + return caller.startWorkflowRef( + definition, + params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + + @override + Future startWorkflowCall( + WorkflowStartCall call, + ) async { + final caller = workflows; + if (caller == null) { + throw StateError( + 'WorkflowScriptStepContext has no workflow caller configured', + ); + } + return caller.startWorkflowCall(call); + } + + @override + Future?> + waitForWorkflowRef( + String runId, + WorkflowRef definition, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) async { + final caller = workflows; + if (caller == null) { + throw StateError( + 'WorkflowScriptStepContext has no workflow caller configured', + ); + } + return caller.waitForWorkflowRef( + runId, + definition, + pollInterval: pollInterval, + timeout: timeout, + ); + } } /// Enqueuer that prefixes workflow step metadata onto spawned tasks. diff --git a/packages/stem/test/unit/workflow/flow_context_test.dart b/packages/stem/test/unit/workflow/flow_context_test.dart index edc748e3..715ce3da 100644 --- a/packages/stem/test/unit/workflow/flow_context_test.dart +++ b/packages/stem/test/unit/workflow/flow_context_test.dart @@ -76,9 +76,7 @@ void main() { }, ); - test( - 'startWithContext throws when child workflow support is unavailable', - () { + test('startWith throws when workflow caller support is unavailable', () { final context = FlowContext( workflow: 'demo', runId: 'run-4', @@ -93,14 +91,14 @@ void main() { ); expect( - () => childRef.startWithContext(context, const {'value': 'x'}), + () => childRef.startWith(context, const {'value': 'x'}), throwsStateError, ); }, ); test( - 'startAndWaitWithContext throws when child workflow support is unavailable', + 'startAndWaitWith throws when workflow caller support is unavailable', () { final context = FlowContext( workflow: 'demo', @@ -116,7 +114,7 @@ void main() { ); expect( - () => childRef.startAndWaitWithContext( + () => childRef.startAndWaitWith( context, const {'value': 'x'}, ), diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart index f8c6ea6c..9c5b5a42 100644 --- a/packages/stem/test/unit/workflow/workflow_resume_test.dart +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -2,7 +2,10 @@ import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/workflow/core/flow_context.dart'; import 'package:stem/src/workflow/core/flow_step.dart'; +import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; import 'package:stem/src/workflow/core/workflow_event_ref.dart'; +import 'package:stem/src/workflow/core/workflow_ref.dart'; +import 'package:stem/src/workflow/core/workflow_result.dart'; import 'package:stem/src/workflow/core/workflow_resume.dart'; import 'package:stem/src/workflow/core/workflow_script_context.dart'; import 'package:test/test.dart'; @@ -276,11 +279,14 @@ class _FakeWorkflowScriptStepContext implements WorkflowScriptStepContext { _FakeWorkflowScriptStepContext({ Object? resumeData, TaskEnqueuer? enqueuer, + WorkflowCaller? workflows, }) : _resumeData = resumeData, - _enqueuer = enqueuer; + _enqueuer = enqueuer, + _workflows = workflows; Object? _resumeData; final TaskEnqueuer? _enqueuer; + final WorkflowCaller? _workflows; final List awaitedTopics = []; final List sleepCalls = []; @@ -288,7 +294,7 @@ class _FakeWorkflowScriptStepContext implements WorkflowScriptStepContext { TaskEnqueuer? get enqueuer => _enqueuer; @override - Never? get workflows => null; + WorkflowCaller? get workflows => _workflows; @override int get iteration => 0; @@ -370,6 +376,64 @@ class _FakeWorkflowScriptStepContext implements WorkflowScriptStepContext { } return delegate.enqueueCall(call, enqueueOptions: enqueueOptions); } + + @override + Future startWorkflowRef( + WorkflowRef definition, + TParams params, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) async { + final caller = _workflows; + if (caller == null) { + throw StateError( + 'WorkflowScriptStepContext has no workflow caller configured', + ); + } + return caller.startWorkflowRef( + definition, + params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + + @override + Future startWorkflowCall( + WorkflowStartCall call, + ) async { + final caller = _workflows; + if (caller == null) { + throw StateError( + 'WorkflowScriptStepContext has no workflow caller configured', + ); + } + return caller.startWorkflowCall(call); + } + + @override + Future?> + waitForWorkflowRef( + String runId, + WorkflowRef definition, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) async { + final caller = _workflows; + if (caller == null) { + throw StateError( + 'WorkflowScriptStepContext has no workflow caller configured', + ); + } + return caller.waitForWorkflowRef( + runId, + definition, + pollInterval: pollInterval, + timeout: timeout, + ); + } } class _RecordingTaskEnqueuer implements TaskEnqueuer { diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index 42622b0d..d02dec46 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -158,7 +158,7 @@ void main() { flow.step('spawn', (context) async { return childRef .call(const {'value': 'spawned'}) - .startWithContext(context); + .startWith(context); }); }, ).definition, @@ -205,7 +205,7 @@ void main() { return script.step('spawn', (context) async { return childRef .call(const {'value': 'script-child'}) - .startWithContext(context); + .startWith(context); }); }, ).definition, @@ -225,7 +225,7 @@ void main() { }); test( - 'flow context workflows startAndWaitWithContext waits for child result', + 'flow contexts can startAndWait for child workflows directly', () async { final childRef = WorkflowRef, String>( name: 'child.runtime.wait.flow', @@ -251,7 +251,7 @@ void main() { flow.step('spawn', (context) async { final childResult = await childRef .call(const {'value': 'spawned'}) - .startAndWaitWithContext( + .startAndWaitWith( context, timeout: const Duration(seconds: 2), ); @@ -277,7 +277,7 @@ void main() { ); test( - 'script checkpoint workflows startAndWaitWithContext waits for child result', + 'script checkpoints can startAndWait for child workflows directly', () async { final childRef = WorkflowRef, String>( name: 'child.runtime.wait.script', @@ -306,7 +306,7 @@ void main() { ) async { final childResult = await childRef .call(const {'value': 'script-child'}) - .startAndWaitWithContext( + .startAndWaitWith( context, timeout: const Duration(seconds: 2), ); diff --git a/packages/stem_builder/CHANGELOG.md b/packages/stem_builder/CHANGELOG.md index 20d7578e..b604d0ea 100644 --- a/packages/stem_builder/CHANGELOG.md +++ b/packages/stem_builder/CHANGELOG.md @@ -7,6 +7,9 @@ shared `TaskCall.enqueueWith(...)` and `TaskDefinition.waitFor(...)` surface from `stem`, reducing duplicate happy paths in generated task code. - Added builder diagnostics for duplicate or conflicting annotated workflow checkpoint names and refreshed generated examples around typed workflow refs. +- Refreshed generated child-workflow examples and docs to use the unified + `startWith(...)` / `startAndWaitWith(...)` helper surface inside durable + workflow contexts. - Added typed workflow starter generation and app helper output for annotated workflow/task definitions. - Switched generated output to per-file `part` generation using `.stem.g.dart` diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index 74dfaebd..c55b0f69 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -111,9 +111,9 @@ Durable workflow contexts enqueue tasks directly: Child workflows should be started from durable boundaries: -- `StemWorkflowDefinitions.someWorkflow.startWithContext(context, (...))` +- `StemWorkflowDefinitions.someWorkflow.startWith(context, (...))` inside flow steps -- `StemWorkflowDefinitions.someWorkflow.startAndWaitWithContext(context, (...))` +- `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, (...))` inside script checkpoints Avoid starting child workflows directly from the raw From 1c8bfa12761bf760f749563d3369ac1cacf1c367 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 19:09:54 -0500 Subject: [PATCH 040/302] Remove context-specific workflow start helpers --- .../workflows/context-and-serialization.md | 11 +- packages/stem/CHANGELOG.md | 4 + .../lib/src/workflow/core/flow_context.dart | 4 +- .../lib/src/workflow/core/workflow_ref.dart | 111 ------------------ .../core/workflow_script_context.dart | 3 +- 5 files changed, 13 insertions(+), 120 deletions(-) diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index 01499450..0c701ca6 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -38,8 +38,9 @@ Depending on the context type, you can access: - `takeResumeData()` for event-driven resumes - `takeResumeValue(codec: ...)` for typed event-driven resumes - `idempotencyKey(...)` -- `workflows` for typed child-workflow starts from durable step/checkpoint - contexts +- direct child-workflow start helpers such as + `StemWorkflowDefinitions.someWorkflow.startWith(context, (...))` and + `startAndWaitWith(context, (...))` - direct task enqueue APIs because `FlowContext`, `WorkflowScriptStepContext`, and `TaskInvocationContext` all implement `TaskEnqueuer` @@ -47,8 +48,10 @@ Depending on the context type, you can access: Child workflow starts belong in durable boundaries: -- `FlowContext.workflows` inside flow steps -- `WorkflowScriptStepContext.workflows` inside script checkpoints +- `StemWorkflowDefinitions.someWorkflow.startWith(context, (...))` inside flow + steps +- `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, (...))` + inside script checkpoints Do not treat the raw `WorkflowScriptContext` body as a safe place for child starts or other replay-sensitive side effects. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 52a48bdf..13796105 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -8,6 +8,10 @@ directly so child workflows can start and wait through `WorkflowStartCall.startWith(...)` / `startAndWaitWith(...)` without special context-only helper variants. +- Removed the redundant `startWithContext(...)` / + `startAndWaitWithContext(...)` workflow-ref helpers and the separate + `WorkflowChildCallerContext` contract so child workflow starts consistently + target the direct `WorkflowCaller` surface. - Simplified generated annotated task usage so `StemTaskDefinitions.*` is the canonical surface, reusing shared `TaskCall.enqueueWith(...)` and `TaskDefinition.waitFor(...)` helpers instead of emitting separate generated diff --git a/packages/stem/lib/src/workflow/core/flow_context.dart b/packages/stem/lib/src/workflow/core/flow_context.dart index 9768420d..9f8992d9 100644 --- a/packages/stem/lib/src/workflow/core/flow_context.dart +++ b/packages/stem/lib/src/workflow/core/flow_context.dart @@ -16,8 +16,7 @@ import 'package:stem/src/workflow/core/workflow_result.dart'; /// [iteration] indicates how many times the step has already completed when /// `autoVersion` is enabled, allowing handlers to branch per loop iteration or /// derive unique identifiers. -class FlowContext - implements WorkflowChildCallerContext, TaskEnqueuer, WorkflowCaller { +class FlowContext implements TaskEnqueuer, WorkflowCaller { /// Creates a workflow step context. FlowContext({ required this.workflow, @@ -59,7 +58,6 @@ class FlowContext final TaskEnqueuer? enqueuer; /// Optional typed workflow caller for spawning child workflows. - @override final WorkflowCaller? workflows; final WorkflowClock _clock; diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index c5fe3521..8ab7b1b5 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -111,22 +111,6 @@ class WorkflowRef { ).startWith(caller); } - /// Starts this workflow ref directly with a workflow child-caller [context]. - Future startWithContext( - WorkflowChildCallerContext context, - TParams params, { - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return call( - params, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ).startWithContext(context); - } - /// Starts this workflow ref with [caller] and waits for the result. Future?> startAndWaitWith( WorkflowCaller caller, @@ -148,29 +132,6 @@ class WorkflowRef { timeout: timeout, ); } - - /// Starts this workflow ref with a workflow child-caller [context] and - /// waits for the result. - Future?> startAndWaitWithContext( - WorkflowChildCallerContext context, - TParams params, { - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return call( - params, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ).startAndWaitWithContext( - context, - pollInterval: pollInterval, - timeout: timeout, - ); - } } /// Typed producer-facing reference for workflows that take no input params. @@ -223,20 +184,6 @@ class NoArgsWorkflowRef { ).startWith(caller); } - /// Starts this workflow ref directly with a workflow child-caller [context]. - Future startWithContext( - WorkflowChildCallerContext context, { - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return call( - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ).startWithContext(context); - } - /// Starts this workflow ref with [caller] and waits for the result. Future?> startAndWaitWith( WorkflowCaller caller, { @@ -257,27 +204,6 @@ class NoArgsWorkflowRef { ); } - /// Starts this workflow ref with a workflow child-caller [context] and waits - /// for the result. - Future?> startAndWaitWithContext( - WorkflowChildCallerContext context, { - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return call( - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ).startAndWaitWithContext( - context, - pollInterval: pollInterval, - timeout: timeout, - ); - } - /// Decodes a final workflow result payload. TResult decode(Object? payload) => asRef.decode(payload); @@ -323,12 +249,6 @@ abstract interface class WorkflowCaller { }); } -/// Shared contract for contexts that can spawn child workflows. -abstract interface class WorkflowChildCallerContext { - /// Optional typed workflow caller for spawning child workflows. - WorkflowCaller? get workflows; -} - /// Typed start request built from a [WorkflowRef]. class WorkflowStartCall { const WorkflowStartCall._({ @@ -384,17 +304,6 @@ extension WorkflowStartCallExtension return caller.startWorkflowCall(this); } - /// Starts this typed workflow call with a workflow child-caller [context]. - Future startWithContext(WorkflowChildCallerContext context) { - final caller = context.workflows; - if (caller == null) { - throw StateError( - 'This workflow context does not support starting child workflows.', - ); - } - return startWith(caller); - } - /// Starts this typed workflow call with [caller] and waits for the result. Future?> startAndWaitWith( WorkflowCaller caller, { @@ -409,26 +318,6 @@ extension WorkflowStartCallExtension timeout: timeout, ); } - - /// Starts this typed workflow call with a workflow child-caller [context] - /// and waits for the result. - Future?> startAndWaitWithContext( - WorkflowChildCallerContext context, { - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - final caller = context.workflows; - if (caller == null) { - throw StateError( - 'This workflow context does not support starting child workflows.', - ); - } - return startAndWaitWith( - caller, - pollInterval: pollInterval, - timeout: timeout, - ); - } } /// Convenience helpers for waiting on typed workflow refs using a generic diff --git a/packages/stem/lib/src/workflow/core/workflow_script_context.dart b/packages/stem/lib/src/workflow/core/workflow_script_context.dart index a4a66ddb..25c0a2b5 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script_context.dart @@ -32,7 +32,7 @@ abstract class WorkflowScriptContext { /// Context provided to each script checkpoint invocation. Mirrors /// [FlowContext] but tailored for the facade helpers. abstract class WorkflowScriptStepContext - implements WorkflowChildCallerContext, TaskEnqueuer, WorkflowCaller { + implements TaskEnqueuer, WorkflowCaller { /// Name of the workflow currently executing. String get workflow; @@ -76,7 +76,6 @@ abstract class WorkflowScriptStepContext TaskEnqueuer? get enqueuer; /// Optional typed workflow caller for spawning child workflows. - @override WorkflowCaller? get workflows; @override From 54005bbfccbf60b62c4b478daeeb1d69d3cf23fc Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 19:13:47 -0500 Subject: [PATCH 041/302] Unify typed workflow event dispatch helpers --- .../docs/workflows/suspensions-and-events.md | 4 ++-- packages/stem/CHANGELOG.md | 3 +++ packages/stem/README.md | 7 +++--- packages/stem/example/durable_watchers.dart | 2 +- .../example/workflows/sleep_and_event.dart | 2 +- .../stem/lib/src/bootstrap/workflow_app.dart | 18 +++------------ .../src/workflow/core/workflow_event_ref.dart | 22 +++++++++++++++++++ .../workflow/runtime/workflow_runtime.dart | 4 +++- .../workflow/workflow_runtime_ref_test.dart | 2 +- .../test/workflow/workflow_runtime_test.dart | 2 +- 10 files changed, 40 insertions(+), 26 deletions(-) diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index ddd5494e..7c0ade2e 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -66,8 +66,8 @@ wire format. `emitValue(...)` is a DTO/codec convenience layer, not a new transport shape. When the topic and codec travel together in your codebase, prefer a typed -`WorkflowEventRef` and `event.emitWithApp(...)` / -`event.emitWithRuntime(...)` together with `waitForEventRef(...)`. +`WorkflowEventRef` and `event.emitWith(...)` together with +`waitForEventRef(...)`. ## Inspect waiting runs diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 13796105..6787a97a 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -12,6 +12,9 @@ `startAndWaitWithContext(...)` workflow-ref helpers and the separate `WorkflowChildCallerContext` contract so child workflow starts consistently target the direct `WorkflowCaller` surface. +- Added `WorkflowEventEmitter` plus the unified `WorkflowEventRef.emitWith(...)` + helper so typed workflow events no longer branch between app-specific and + runtime-specific dispatch helpers. - Simplified generated annotated task usage so `StemTaskDefinitions.*` is the canonical surface, reusing shared `TaskCall.enqueueWith(...)` and `TaskDefinition.waitFor(...)` helpers instead of emitting separate generated diff --git a/packages/stem/README.md b/packages/stem/README.md index 6b1efb9a..3d1f3c84 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -1004,10 +1004,9 @@ backend metadata under `stem.unique.duplicates`. `takeResumeData()` / `takeResumeValue(codec: ...)` when the run resumes. - When you have a DTO event, emit it through `runtime.emitValue(...)` / `workflowApp.emitValue(...)` with a `PayloadCodec`, or bundle the topic - and codec once in a `WorkflowEventRef` and use - `event.emitWithApp(...)` / `event.emitWithRuntime(...)` together with - `waitForEventRef(...)`. Event payloads still serialize onto the existing - `Map` wire format. + and codec once in a `WorkflowEventRef` and use `event.emitWith(...)` + together with `waitForEventRef(...)`. Event payloads still serialize onto the + existing `Map` wire format. - Only return values you want persisted. If a handler returns `null`, the runtime treats it as "no result yet" and will run the step again on resume. - Derive outbound idempotency tokens with `ctx.idempotencyKey('charge')` so diff --git a/packages/stem/example/durable_watchers.dart b/packages/stem/example/durable_watchers.dart index 1d0372b5..fcd3ca84 100644 --- a/packages/stem/example/durable_watchers.dart +++ b/packages/stem/example/durable_watchers.dart @@ -57,7 +57,7 @@ Future main() async { print('Watcher metadata: ${watcher.data}'); } - await shipmentReadyEvent.emitWithApp( + await shipmentReadyEvent.emitWith( app, const _ShipmentReadyEvent(trackingId: 'ZX-42'), ); diff --git a/packages/stem/example/workflows/sleep_and_event.dart b/packages/stem/example/workflows/sleep_and_event.dart index 8a8aa608..623b3e9b 100644 --- a/packages/stem/example/workflows/sleep_and_event.dart +++ b/packages/stem/example/workflows/sleep_and_event.dart @@ -47,7 +47,7 @@ Future main() async { await Future.delayed(const Duration(milliseconds: 50)); } - await demoEvent.emitWithApp(app, {'message': 'event received'}); + await demoEvent.emitWith(app, {'message': 'event received'}); final result = await sleepAndEventRef.waitFor(app, runId); print('Workflow $runId resumed and completed with: ${result?.value}'); diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index f981e788..fad3d13c 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -31,7 +31,7 @@ import 'package:stem/src/workflow/runtime/workflow_runtime.dart'; /// This wrapper wires together broker/backend infrastructure, registers flows, /// and exposes convenience helpers for scheduling and observing workflow runs /// without having to manage [WorkflowRuntime] directly. -class StemWorkflowApp implements WorkflowCaller, StemTaskApp { +class StemWorkflowApp implements WorkflowCaller, WorkflowEventEmitter, StemTaskApp { StemWorkflowApp._({ required this.app, required this.runtime, @@ -213,6 +213,7 @@ class StemWorkflowApp implements WorkflowCaller, StemTaskApp { /// Emits a typed event to resume runs waiting on [topic]. /// /// This is a convenience wrapper over [WorkflowRuntime.emitValue]. + @override Future emitValue( String topic, T value, { @@ -222,6 +223,7 @@ class StemWorkflowApp implements WorkflowCaller, StemTaskApp { } /// Emits a typed event through a [WorkflowEventRef]. + @override Future emitEvent(WorkflowEventRef event, T value) { return runtime.emitEvent(event, value); } @@ -657,20 +659,6 @@ StemWorkerConfig _resolveWorkflowWorkerConfig( return workerConfig.copyWith(subscription: inferredSubscription); } -/// Convenience helpers for emitting typed workflow events through an app or -/// runtime. -extension WorkflowEventRefDispatchExtension on WorkflowEventRef { - /// Emits this typed event with [app]. - Future emitWithApp(StemWorkflowApp app, T value) { - return app.emitEvent(this, value); - } - - /// Emits this typed event with [runtime]. - Future emitWithRuntime(WorkflowRuntime runtime, T value) { - return runtime.emitEvent(this, value); - } -} - /// Convenience helpers for typed workflow start calls. extension WorkflowStartCallAppExtension on WorkflowStartCall { diff --git a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart index 86cae4bf..dd3d746b 100644 --- a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart @@ -1,5 +1,19 @@ import 'package:stem/src/core/payload_codec.dart'; +/// Shared typed workflow-event dispatch surface used by apps and runtimes. +abstract interface class WorkflowEventEmitter { + /// Emits a typed external event that serializes onto the durable map-based + /// workflow event transport. + Future emitValue( + String topic, + T value, { + PayloadCodec? codec, + }); + + /// Emits a typed external event using a [WorkflowEventRef]. + Future emitEvent(WorkflowEventRef event, T value); +} + /// Typed reference to a workflow resume event topic. /// /// This bundles the durable topic name with an optional payload codec so @@ -18,3 +32,11 @@ class WorkflowEventRef { /// Optional codec for encoding and decoding event payloads. final PayloadCodec? codec; } + +/// Convenience helpers for dispatching typed workflow events. +extension WorkflowEventRefExtension on WorkflowEventRef { + /// Emits this typed event with the provided [emitter]. + Future emitWith(WorkflowEventEmitter emitter, T value) { + return emitter.emitEvent(this, value); + } +} diff --git a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart index 55c6a317..0b935094 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart @@ -68,7 +68,7 @@ const int _leaseConflictMaxRetries = 1000000; /// The runtime is durable: each step is re-executed from the top after a /// suspension or worker crash. Handlers must therefore be idempotent and rely /// on persisted step outputs or resume payloads to detect prior progress. -class WorkflowRuntime implements WorkflowCaller { +class WorkflowRuntime implements WorkflowCaller, WorkflowEventEmitter { /// Creates a workflow runtime backed by a [Stem] instance and /// [WorkflowStore]. WorkflowRuntime({ @@ -356,6 +356,7 @@ class WorkflowRuntime implements WorkflowCaller { /// When [codec] is provided, [value] is encoded before being emitted. The /// encoded value must be a `Map` because workflow watcher /// resolution and event transport are currently map-shaped. + @override Future emitValue( String topic, T value, { @@ -366,6 +367,7 @@ class WorkflowRuntime implements WorkflowCaller { } /// Emits a typed external event using a [WorkflowEventRef]. + @override Future emitEvent(WorkflowEventRef event, T value) { return emitValue(event.topic, value, codec: event.codec); } diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index c5a3a5de..b6f7fb20 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -254,7 +254,7 @@ void main() { final runId = await flow.ref0().startWithApp(workflowApp); await workflowApp.runtime.executeRun(runId); - await _userUpdatedEvent.emitWithApp( + await _userUpdatedEvent.emitWith( workflowApp, const _GreetingParams(name: 'event'), ); diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index d02dec46..85644fb3 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -632,7 +632,7 @@ void main() { expect(suspended?.status, WorkflowStatus.suspended); expect(suspended?.waitTopic, event.topic); - await event.emitWithRuntime( + await event.emitWith( runtime, const _UserUpdatedEvent(id: 'user-typed-2'), ); From ec9d023bb1d9fb7e751f6c6f2d570800dc29b3b8 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 19:18:08 -0500 Subject: [PATCH 042/302] Collapse workflow helper APIs onto generic callers --- .site/docs/core-concepts/stem-builder.md | 2 +- .site/docs/workflows/annotated-workflows.md | 2 +- .site/docs/workflows/starting-and-waiting.md | 4 +- packages/stem/CHANGELOG.md | 5 + packages/stem/README.md | 8 +- .../example/annotated_workflows/bin/main.dart | 6 +- .../example/docs_snippets/lib/workflows.dart | 4 +- packages/stem/example/durable_watchers.dart | 2 +- packages/stem/example/ecommerce/README.md | 2 +- .../stem/example/ecommerce/lib/src/app.dart | 4 +- packages/stem/example/persistent_sleep.dart | 2 +- .../example/workflows/basic_in_memory.dart | 2 +- .../workflows/cancellation_policy.dart | 2 +- .../example/workflows/custom_factories.dart | 2 +- .../example/workflows/sleep_and_event.dart | 2 +- .../stem/example/workflows/sqlite_store.dart | 2 +- .../example/workflows/versioned_rewind.dart | 2 +- .../stem/lib/src/bootstrap/workflow_app.dart | 264 ------------------ .../lib/src/workflow/core/workflow_ref.dart | 8 +- .../stem/test/bootstrap/stem_app_test.dart | 6 +- .../stem/test/bootstrap/stem_client_test.dart | 4 +- ...workflow_runtime_call_extensions_test.dart | 12 +- .../workflow/workflow_runtime_ref_test.dart | 16 +- packages/stem_builder/README.md | 2 +- packages/stem_builder/example/README.md | 4 +- packages/stem_builder/example/bin/main.dart | 2 +- .../example/bin/runtime_metadata_views.dart | 4 +- 27 files changed, 58 insertions(+), 317 deletions(-) diff --git a/.site/docs/core-concepts/stem-builder.md b/.site/docs/core-concepts/stem-builder.md index 7b47bd7d..d14ad35e 100644 --- a/.site/docs/core-concepts/stem-builder.md +++ b/.site/docs/core-concepts/stem-builder.md @@ -87,7 +87,7 @@ final workflowApp = await StemWorkflowApp.fromUrl( ); await workflowApp.start(); -final result = await StemWorkflowDefinitions.userSignup.startAndWaitWithApp( +final result = await StemWorkflowDefinitions.userSignup.startAndWaitWith( workflowApp, (email: 'user@example.com'), ); diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index 30e2d349..68c866da 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -47,7 +47,7 @@ Use the generated workflow refs when you want a single typed handle for start and wait operations: ```dart -final result = await StemWorkflowDefinitions.userSignup.startAndWaitWithApp( +final result = await StemWorkflowDefinitions.userSignup.startAndWaitWith( workflowApp, (email: 'user@example.com'), ); diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index b8e74ff4..02b35370 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -45,7 +45,7 @@ final approvalsRef = approvalsFlow.refWithCodec<({ApprovalDraft draft})>( ), ); -final runId = await approvalsRef.startWithApp( +final runId = await approvalsRef.startWith( workflowApp, (draft: const ApprovalDraft(documentId: 'doc-42')), ); @@ -79,7 +79,7 @@ When you use `stem_builder`, generated workflow refs remove the raw workflow-name strings and give you one typed handle for both start and wait: ```dart -final result = await StemWorkflowDefinitions.userSignup.startAndWaitWithApp( +final result = await StemWorkflowDefinitions.userSignup.startAndWaitWith( workflowApp, (email: 'user@example.com'), ); diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 6787a97a..896c90fc 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -15,6 +15,11 @@ - Added `WorkflowEventEmitter` plus the unified `WorkflowEventRef.emitWith(...)` helper so typed workflow events no longer branch between app-specific and runtime-specific dispatch helpers. +- Removed the redundant app/runtime-specific workflow helper wrappers + (`startWithApp`, `startWithRuntime`, `startAndWaitWithApp`, + `startAndWaitWithRuntime`, `waitForWithRuntime`) so workflow refs and start + calls consistently use the generic `startWith(...)`, `startAndWaitWith(...)`, + and `waitFor(...)` surface. - Simplified generated annotated task usage so `StemTaskDefinitions.*` is the canonical surface, reusing shared `TaskCall.enqueueWith(...)` and `TaskDefinition.waitFor(...)` helpers instead of emitting separate generated diff --git a/packages/stem/README.md b/packages/stem/README.md index 3d1f3c84..907fc6f2 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -314,7 +314,7 @@ final app = await StemWorkflowApp.inMemory( flows: [demoWorkflow], ); -final runId = await demoWorkflowRef.startWithApp(app); +final runId = await demoWorkflowRef.startWith(app); final result = await demoWorkflowRef.waitFor(app, runId); print(result?.value); // 'hello world' print(result?.state.status); // WorkflowStatus.completed @@ -453,7 +453,7 @@ final app = await StemWorkflowApp.fromUrl( tasks: const [], ); -final runId = await approvalsRef.startWithApp( +final runId = await approvalsRef.startWith( app, (draft: const ApprovalDraft(documentId: 'doc-42')), ); @@ -472,7 +472,7 @@ the no-args ref: ```dart final healthcheckRef = healthcheckFlow.ref0(); -final runId = await healthcheckRef.startWithApp(app); +final runId = await healthcheckRef.startWith(app); ``` #### Manual `WorkflowScript` @@ -634,7 +634,7 @@ final app = await StemWorkflowApp.fromUrl( module: stemModule, ); -final result = await StemWorkflowDefinitions.userSignup.startAndWaitWithApp( +final result = await StemWorkflowDefinitions.userSignup.startAndWaitWith( app, (email: 'user@example.com'), ); diff --git a/packages/stem/example/annotated_workflows/bin/main.dart b/packages/stem/example/annotated_workflows/bin/main.dart index 378c7c78..8357217b 100644 --- a/packages/stem/example/annotated_workflows/bin/main.dart +++ b/packages/stem/example/annotated_workflows/bin/main.dart @@ -8,7 +8,7 @@ Future main() async { final app = await client.createWorkflowApp(module: stemModule); await app.start(); - final flowRunId = await StemWorkflowDefinitions.flow.startWithApp(app); + final flowRunId = await StemWorkflowDefinitions.flow.startWith(app); final flowResult = await StemWorkflowDefinitions.flow.waitFor( app, flowRunId, @@ -20,7 +20,7 @@ Future main() async { '${jsonEncode(flowResult?.value?['childResult'])}', ); - final scriptResult = await StemWorkflowDefinitions.script.startAndWaitWithApp( + final scriptResult = await StemWorkflowDefinitions.script.startAndWaitWith( app, ( request: const WelcomeRequest(email: ' SomeEmail@Example.com '), @@ -46,7 +46,7 @@ Future main() async { print('Script detail: ${jsonEncode(scriptDetail?.toJson())}'); final contextResult = await StemWorkflowDefinitions.contextScript - .startAndWaitWithApp( + .startAndWaitWith( app, ( request: const WelcomeRequest(email: ' ContextEmail@Example.com '), diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index 8355902c..6fa23718 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -140,7 +140,7 @@ final retryDefinition = retryScript.definition; // #region workflows-run Future runWorkflow(StemWorkflowApp workflowApp) async { - final runId = await ApprovalsFlow.ref.startWithApp( + final runId = await ApprovalsFlow.ref.startWith( workflowApp, ( draft: const ApprovalDraft(documentId: 'doc-42'), @@ -291,7 +291,7 @@ Future main() async { final app = await StemWorkflowApp.inMemory(flows: [demoFlow]); await app.start(); - final runId = await demoFlowRef.startWithApp(app); + final runId = await demoFlowRef.startWith(app); final result = await demoFlowRef.waitFor( app, runId, diff --git a/packages/stem/example/durable_watchers.dart b/packages/stem/example/durable_watchers.dart index fcd3ca84..ac938067 100644 --- a/packages/stem/example/durable_watchers.dart +++ b/packages/stem/example/durable_watchers.dart @@ -44,7 +44,7 @@ Future main() async { final runId = await shipmentWorkflowRef .call(const {'orderId': 'A-123'}) - .startWithApp(app); + .startWith(app); // Drive the run until it suspends on the watcher. await app.runtime.executeRun(runId); diff --git a/packages/stem/example/ecommerce/README.md b/packages/stem/example/ecommerce/README.md index fb6f1630..d52d278d 100644 --- a/packages/stem/example/ecommerce/README.md +++ b/packages/stem/example/ecommerce/README.md @@ -35,7 +35,7 @@ From those annotations, this example uses generated APIs: - `stemModule` (generated workflow/task bundle) - `StemWorkflowDefinitions.addToCart` -- `StemWorkflowDefinitions.addToCart.startAndWaitWithApp(...)` +- `StemWorkflowDefinitions.addToCart.startAndWaitWith(...)` - `StemTaskDefinitions.ecommerceAuditLog` - direct task definition helpers like `StemTaskDefinitions.ecommerceAuditLog.enqueueWith(...)` diff --git a/packages/stem/example/ecommerce/lib/src/app.dart b/packages/stem/example/ecommerce/lib/src/app.dart index 9779edf2..7a03b842 100644 --- a/packages/stem/example/ecommerce/lib/src/app.dart +++ b/packages/stem/example/ecommerce/lib/src/app.dart @@ -94,7 +94,7 @@ class EcommerceServer { final quantity = _toInt(payload['quantity']); final result = await StemWorkflowDefinitions.addToCart - .startAndWaitWithApp( + .startAndWaitWith( workflowApp, (cartId: cartId, sku: sku, quantity: quantity), timeout: const Duration(seconds: 4), @@ -133,7 +133,7 @@ class EcommerceServer { try { final runId = await checkoutWorkflow .call((cartId: cartId)) - .startWithApp(workflowApp); + .startWith(workflowApp); final result = await checkoutWorkflow.waitFor( workflowApp, diff --git a/packages/stem/example/persistent_sleep.dart b/packages/stem/example/persistent_sleep.dart index bae3eda8..38a70e24 100644 --- a/packages/stem/example/persistent_sleep.dart +++ b/packages/stem/example/persistent_sleep.dart @@ -26,7 +26,7 @@ Future main() async { flows: [sleepLoop], ); - final runId = await sleepLoopRef.startWithApp(app); + final runId = await sleepLoopRef.startWith(app); await app.runtime.executeRun(runId); // After the delay elapses, the runtime should resume without the step diff --git a/packages/stem/example/workflows/basic_in_memory.dart b/packages/stem/example/workflows/basic_in_memory.dart index 52adfb4e..e47d0337 100644 --- a/packages/stem/example/workflows/basic_in_memory.dart +++ b/packages/stem/example/workflows/basic_in_memory.dart @@ -16,7 +16,7 @@ Future main() async { flows: [basicHello], ); - final runId = await basicHelloRef.startWithApp(app); + final runId = await basicHelloRef.startWith(app); final result = await basicHelloRef.waitFor(app, runId); print('Workflow $runId finished with result: ${result?.value}'); diff --git a/packages/stem/example/workflows/cancellation_policy.dart b/packages/stem/example/workflows/cancellation_policy.dart index edd55916..f8507ea5 100644 --- a/packages/stem/example/workflows/cancellation_policy.dart +++ b/packages/stem/example/workflows/cancellation_policy.dart @@ -37,7 +37,7 @@ Future main() async { maxSuspendDuration: Duration(seconds: 2), ), ) - .startWithApp(app); + .startWith(app); // Wait a bit longer than the policy allows so the auto-cancel can trigger. await Future.delayed(const Duration(seconds: 4)); diff --git a/packages/stem/example/workflows/custom_factories.dart b/packages/stem/example/workflows/custom_factories.dart index 339af6b7..0602aba9 100644 --- a/packages/stem/example/workflows/custom_factories.dart +++ b/packages/stem/example/workflows/custom_factories.dart @@ -23,7 +23,7 @@ Future main() async { ); try { - final runId = await redisWorkflowRef.startWithApp(app); + final runId = await redisWorkflowRef.startWith(app); final result = await redisWorkflowRef.waitFor(app, runId); print('Workflow $runId finished with result: ${result?.value}'); } finally { diff --git a/packages/stem/example/workflows/sleep_and_event.dart b/packages/stem/example/workflows/sleep_and_event.dart index 623b3e9b..5756473a 100644 --- a/packages/stem/example/workflows/sleep_and_event.dart +++ b/packages/stem/example/workflows/sleep_and_event.dart @@ -35,7 +35,7 @@ Future main() async { flows: [sleepAndEvent], ); - final runId = await sleepAndEventRef.startWithApp(app); + final runId = await sleepAndEventRef.startWith(app); // Wait until the workflow is suspended before emitting the event to avoid // losing the signal. diff --git a/packages/stem/example/workflows/sqlite_store.dart b/packages/stem/example/workflows/sqlite_store.dart index cccfaf56..9091ba9f 100644 --- a/packages/stem/example/workflows/sqlite_store.dart +++ b/packages/stem/example/workflows/sqlite_store.dart @@ -22,7 +22,7 @@ Future main() async { ); try { - final runId = await sqliteExampleRef.startWithApp(app); + final runId = await sqliteExampleRef.startWith(app); final result = await sqliteExampleRef.waitFor(app, runId); print('Workflow $runId finished with result: ${result?.value}'); } finally { diff --git a/packages/stem/example/workflows/versioned_rewind.dart b/packages/stem/example/workflows/versioned_rewind.dart index 8310ebdb..f010a887 100644 --- a/packages/stem/example/workflows/versioned_rewind.dart +++ b/packages/stem/example/workflows/versioned_rewind.dart @@ -19,7 +19,7 @@ Future main() async { flows: [versionedWorkflow], ); - final runId = await versionedWorkflowRef.startWithApp(app); + final runId = await versionedWorkflowRef.startWith(app); await app.runtime.executeRun(runId); // Rewind and execute again to append a new iteration checkpoint. diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index fad3d13c..c253c3f4 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -658,267 +658,3 @@ StemWorkerConfig _resolveWorkflowWorkerConfig( } return workerConfig.copyWith(subscription: inferredSubscription); } - -/// Convenience helpers for typed workflow start calls. -extension WorkflowStartCallAppExtension - on WorkflowStartCall { - /// Starts this workflow call with [app]. - Future startWithApp(StemWorkflowApp app) { - return startWith(app); - } - - /// Starts this workflow call with [app] and waits for the typed result. - Future?> startAndWaitWithApp( - StemWorkflowApp app, { - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) async { - final runId = await app.startWorkflowCall(this); - return definition.waitFor( - app, - runId, - pollInterval: pollInterval, - timeout: timeout, - ); - } - - /// Starts this workflow call with [runtime]. - Future startWithRuntime(WorkflowRuntime runtime) { - return startWith(runtime); - } - - /// Starts this workflow call with [runtime] and waits for the typed result. - Future?> startAndWaitWithRuntime( - WorkflowRuntime runtime, { - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) async { - final runId = await runtime.startWorkflowCall(this); - return runtime.waitForWorkflowRef( - runId, - definition, - pollInterval: pollInterval, - timeout: timeout, - ); - } -} - -/// Convenience helpers for waiting on workflow results using a typed reference. -extension WorkflowRefAppExtension - on WorkflowRef { - /// Starts this workflow ref with [app]. - Future startWithApp( - StemWorkflowApp app, - TParams params, { - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return startWith( - app, - params, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ); - } - - /// Starts this workflow ref with [app] and waits for the result. - Future?> startAndWaitWithApp( - StemWorkflowApp app, - TParams params, { - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return startAndWaitWith( - app, - params, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - pollInterval: pollInterval, - timeout: timeout, - ); - } - - /// Starts this workflow ref with [runtime]. - Future startWithRuntime( - WorkflowRuntime runtime, - TParams params, { - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return startWith( - runtime, - params, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ); - } - - /// Starts this workflow ref with [runtime] and waits for the result. - Future?> startAndWaitWithRuntime( - WorkflowRuntime runtime, - TParams params, { - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return startAndWaitWith( - runtime, - params, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - pollInterval: pollInterval, - timeout: timeout, - ); - } - - /// Waits for [runId] using this workflow reference's decode rules. - Future?> waitFor( - StemWorkflowApp app, - String runId, { - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return app.waitForWorkflowRef( - runId, - this, - pollInterval: pollInterval, - timeout: timeout, - ); - } - - /// Waits for [runId] using this workflow reference and [runtime]. - Future?> waitForWithRuntime( - WorkflowRuntime runtime, - String runId, { - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return runtime.waitForWorkflowRef( - runId, - this, - pollInterval: pollInterval, - timeout: timeout, - ); - } -} - -/// Convenience helpers for waiting on workflow results using a no-args ref. -extension NoArgsWorkflowRefAppExtension - on NoArgsWorkflowRef { - /// Starts this no-args workflow ref with [app]. - Future startWithApp( - StemWorkflowApp app, { - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return startWith( - app, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ); - } - - /// Starts this no-args workflow ref with [app] and waits for the result. - Future?> startAndWaitWithApp( - StemWorkflowApp app, { - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) async { - final runId = await startWithApp( - app, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ); - return waitFor( - app, - runId, - pollInterval: pollInterval, - timeout: timeout, - ); - } - - /// Starts this no-args workflow ref with [runtime]. - Future startWithRuntime( - WorkflowRuntime runtime, { - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return startWith( - runtime, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ); - } - - /// Starts this no-args workflow ref with [runtime] and waits for the result. - Future?> startAndWaitWithRuntime( - WorkflowRuntime runtime, { - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) async { - final runId = await startWithRuntime( - runtime, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ); - return waitForWithRuntime( - runtime, - runId, - pollInterval: pollInterval, - timeout: timeout, - ); - } - - /// Waits for [runId] using this workflow reference's decode rules. - Future?> waitFor( - StemWorkflowApp app, - String runId, { - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return asRef.waitFor( - app, - runId, - pollInterval: pollInterval, - timeout: timeout, - ); - } - - /// Waits for [runId] using this workflow reference and [runtime]. - Future?> waitForWithRuntime( - WorkflowRuntime runtime, - String runId, { - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return asRef.waitForWithRuntime( - runtime, - runId, - pollInterval: pollInterval, - timeout: timeout, - ); - } -} diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 8ab7b1b5..833fc477 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -208,13 +208,13 @@ class NoArgsWorkflowRef { TResult decode(Object? payload) => asRef.decode(payload); /// Waits for [runId] using this workflow reference's decode rules. - Future?> waitForWith( + Future?> waitFor( WorkflowCaller caller, String runId, { Duration pollInterval = const Duration(milliseconds: 100), Duration? timeout, }) { - return asRef.waitForWith( + return asRef.waitFor( caller, runId, pollInterval: pollInterval, @@ -311,7 +311,7 @@ extension WorkflowStartCallExtension Duration? timeout, }) async { final runId = await startWith(caller); - return definition.waitForWith( + return definition.waitFor( caller, runId, pollInterval: pollInterval, @@ -325,7 +325,7 @@ extension WorkflowStartCallExtension extension WorkflowRefExtension on WorkflowRef { /// Waits for [runId] using this workflow reference's decode rules. - Future?> waitForWith( + Future?> waitFor( WorkflowCaller caller, String runId, { Duration pollInterval = const Duration(milliseconds: 100), diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index 9d4b8c5c..e4629120 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -574,7 +574,7 @@ void main() { .call( const {'name': 'stem'}, ) - .startWithApp(workflowApp); + .startWith(workflowApp); final result = await workflowRef.waitFor( workflowApp, runId, @@ -614,7 +614,7 @@ void main() { final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); try { - final runId = await workflowRef.call().startWithApp(workflowApp); + final runId = await workflowRef.call().startWith(workflowApp); final result = await workflowRef.waitFor( workflowApp, runId, @@ -676,7 +676,7 @@ void main() { final workflowApp = await StemWorkflowApp.inMemory(scripts: [script]); try { - final runId = await workflowRef.call().startWithApp(workflowApp); + final runId = await workflowRef.call().startWith(workflowApp); final result = await workflowRef.waitFor( workflowApp, runId, diff --git a/packages/stem/test/bootstrap/stem_client_test.dart b/packages/stem/test/bootstrap/stem_client_test.dart index 3f16df12..051fffd9 100644 --- a/packages/stem/test/bootstrap/stem_client_test.dart +++ b/packages/stem/test/bootstrap/stem_client_test.dart @@ -244,7 +244,7 @@ void main() { await client.close(); }); - test('StemClient workflow app supports startAndWaitWithApp', () async { + test('StemClient workflow app supports startAndWaitWith', () async { final client = await StemClient.inMemory(); final flow = Flow( name: 'client.workflow.start-and-wait', @@ -267,7 +267,7 @@ void main() { .call( const {'name': 'one-shot'}, ) - .startAndWaitWithApp(app, timeout: const Duration(seconds: 2)); + .startAndWaitWith(app, timeout: const Duration(seconds: 2)); expect(result?.value, 'ok:one-shot'); diff --git a/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart b/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart index 380ef1c9..8ade4dca 100644 --- a/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart @@ -4,7 +4,7 @@ import 'package:test/test.dart'; void main() { group('runtime workflow call extensions', () { test( - 'startWith/startAndWaitWithRuntime and waitForWithRuntime use typed workflow refs', + 'startWith/startAndWaitWith/waitFor use typed workflow refs', () async { final flow = Flow( name: 'runtime.extension.flow', @@ -27,7 +27,7 @@ void main() { final runId = await workflowRef .call(const {'name': 'runtime'}) .startWith(workflowApp.runtime); - final waited = await workflowRef.waitForWithRuntime( + final waited = await workflowRef.waitFor( workflowApp.runtime, runId, timeout: const Duration(seconds: 2), @@ -37,7 +37,7 @@ void main() { final oneShot = await workflowRef .call(const {'name': 'inline'}) - .startAndWaitWithRuntime( + .startAndWaitWith( workflowApp.runtime, timeout: const Duration(seconds: 2), ); @@ -70,11 +70,11 @@ void main() { try { await workflowApp.start(); - final runId = await workflowRef.startWithRuntime( + final runId = await workflowRef.startWith( workflowApp.runtime, const {'name': 'runtime'}, ); - final waited = await workflowRef.waitForWithRuntime( + final waited = await workflowRef.waitFor( workflowApp.runtime, runId, timeout: const Duration(seconds: 2), @@ -82,7 +82,7 @@ void main() { expect(waited?.value, 'hello runtime'); - final oneShot = await workflowRef.startAndWaitWithRuntime( + final oneShot = await workflowRef.startAndWaitWith( workflowApp.runtime, const {'name': 'inline'}, timeout: const Duration(seconds: 2), diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index b6f7fb20..52645f51 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -74,7 +74,7 @@ void main() { try { await workflowApp.start(); - final runId = await workflowRef.startWithRuntime( + final runId = await workflowRef.startWith( workflowApp.runtime, const {'name': 'runtime'}, ); @@ -118,11 +118,11 @@ void main() { try { await workflowApp.start(); - final runId = await workflowRef.startWithRuntime( + final runId = await workflowRef.startWith( workflowApp.runtime, const {'name': 'runtime'}, ); - final waited = await workflowRef.waitForWithRuntime( + final waited = await workflowRef.waitFor( workflowApp.runtime, runId, timeout: const Duration(seconds: 2), @@ -152,7 +152,7 @@ void main() { try { await workflowApp.start(); - final result = await workflowRef.startAndWaitWithRuntime( + final result = await workflowRef.startAndWaitWith( workflowApp.runtime, const _GreetingParams(name: 'codec'), timeout: const Duration(seconds: 2), @@ -183,7 +183,7 @@ void main() { try { await workflowApp.start(); - final result = await workflowRef.startAndWaitWithRuntime( + final result = await workflowRef.startAndWaitWith( workflowApp.runtime, const _GreetingParams(name: 'codec'), timeout: const Duration(seconds: 2), @@ -217,11 +217,11 @@ void main() { try { await workflowApp.start(); - final flowResult = await flowRef.startAndWaitWithApp( + final flowResult = await flowRef.startAndWaitWith( workflowApp, timeout: const Duration(seconds: 2), ); - final scriptResult = await scriptRef.startAndWaitWithRuntime( + final scriptResult = await scriptRef.startAndWaitWith( workflowApp.runtime, timeout: const Duration(seconds: 2), ); @@ -251,7 +251,7 @@ void main() { try { await workflowApp.start(); - final runId = await flow.ref0().startWithApp(workflowApp); + final runId = await flow.ref0().startWith(workflowApp); await workflowApp.runtime.executeRun(runId); await _userUpdatedEvent.emitWith( diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index c55b0f69..54119fc8 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -196,7 +196,7 @@ final workflowApp = await StemWorkflowApp.fromUrl( module: stemModule, ); -final result = await StemWorkflowDefinitions.userSignup.startAndWaitWithApp( +final result = await StemWorkflowDefinitions.userSignup.startAndWaitWith( workflowApp, (email: 'user@example.com'), ); diff --git a/packages/stem_builder/example/README.md b/packages/stem_builder/example/README.md index a8910abb..2cac4b72 100644 --- a/packages/stem_builder/example/README.md +++ b/packages/stem_builder/example/README.md @@ -5,8 +5,8 @@ This example demonstrates: - Annotated workflow/task definitions - Generated `stemModule` - Generated typed workflow refs (no manual workflow-name strings): - - `StemWorkflowDefinitions.flow.startWithRuntime(runtime, (...))` - - `StemWorkflowDefinitions.userSignup.startWithRuntime(runtime, (...))` + - `StemWorkflowDefinitions.flow.startWith(runtime, (...))` + - `StemWorkflowDefinitions.userSignup.startWith(runtime, (...))` - Generated typed task definitions that use the shared `TaskCall` / `TaskDefinition.waitFor(...)` APIs - Generated zero-arg task definitions with direct helpers from core: diff --git a/packages/stem_builder/example/bin/main.dart b/packages/stem_builder/example/bin/main.dart index beb7832b..4ad4e21c 100644 --- a/packages/stem_builder/example/bin/main.dart +++ b/packages/stem_builder/example/bin/main.dart @@ -26,7 +26,7 @@ Future main() async { print('\nRuntime manifest:'); print(const JsonEncoder.withIndent(' ').convert(runtimeManifest)); - final runId = await StemWorkflowDefinitions.flow.startWithRuntime( + final runId = await StemWorkflowDefinitions.flow.startWith( runtime, (name: 'Stem Builder'), ); diff --git a/packages/stem_builder/example/bin/runtime_metadata_views.dart b/packages/stem_builder/example/bin/runtime_metadata_views.dart index f53e8b99..98c711ce 100644 --- a/packages/stem_builder/example/bin/runtime_metadata_views.dart +++ b/packages/stem_builder/example/bin/runtime_metadata_views.dart @@ -25,14 +25,14 @@ Future main() async { ), ); - final flowRunId = await StemWorkflowDefinitions.flow.startWithRuntime( + final flowRunId = await StemWorkflowDefinitions.flow.startWith( runtime, (name: 'runtime metadata'), ); await runtime.executeRun(flowRunId); final scriptRunId = await StemWorkflowDefinitions.userSignup - .startWithRuntime( + .startWith( runtime, (email: 'dev@stem.dev'), ); From c071df13c619fb6d2258ced1141cb373c0d5676f Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 19:25:32 -0500 Subject: [PATCH 043/302] Collapse task helpers onto generic callers --- .site/docs/core-concepts/producer.md | 2 +- .site/docs/core-concepts/tasks.md | 6 +- .site/docs/workflows/annotated-workflows.md | 2 +- packages/stem/CHANGELOG.md | 2 +- packages/stem/README.md | 14 +-- .../example/annotated_workflows/bin/main.dart | 2 +- .../example/docs_snippets/lib/producer.dart | 2 +- .../stem/example/docs_snippets/lib/tasks.dart | 2 +- packages/stem/example/ecommerce/README.md | 2 +- packages/stem/example/stem_example.dart | 2 +- .../task_context_mixed/lib/shared.dart | 4 +- .../stem/example/task_usage_patterns.dart | 6 +- packages/stem/lib/src/bootstrap/stem_app.dart | 107 +----------------- .../stem/lib/src/bootstrap/stem_client.dart | 97 +--------------- packages/stem/lib/src/core/stem.dart | 72 ++++++++---- packages/stem/lib/stem.dart | 2 +- .../test/bootstrap/module_bootstrap_test.dart | 2 +- .../stem/test/bootstrap/stem_app_test.dart | 2 +- .../stem/test/bootstrap/stem_client_test.dart | 4 +- .../workflow_module_bootstrap_test.dart | 2 +- .../stem/test/unit/core/stem_core_test.dart | 24 ++-- .../unit/core/task_context_enqueue_test.dart | 2 +- .../unit/core/task_enqueue_builder_test.dart | 4 +- ...task_context_enqueue_integration_test.dart | 2 +- .../test/workflow/workflow_runtime_test.dart | 2 +- packages/stem_builder/CHANGELOG.md | 2 +- packages/stem_builder/README.md | 6 +- packages/stem_builder/example/README.md | 2 +- packages/stem_builder/example/bin/main.dart | 2 +- 29 files changed, 103 insertions(+), 277 deletions(-) diff --git a/.site/docs/core-concepts/producer.md b/.site/docs/core-concepts/producer.md index 6b5b647c..6f2dc312 100644 --- a/.site/docs/core-concepts/producer.md +++ b/.site/docs/core-concepts/producer.md @@ -51,7 +51,7 @@ options, scheduling): Typed helpers are also available on `Canvas` (`definition.toSignature`) so group/chain/chord APIs produce strongly typed `TaskResult` streams. Need to tweak headers/meta/queue at call sites? Wrap the definition in a -`TaskEnqueueBuilder` and invoke `await builder.enqueueWith(stem);`. +`TaskEnqueueBuilder` and invoke `await builder.enqueue(stem);`. ## Enqueue options diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 63c33d74..1db07780 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -39,7 +39,7 @@ checks for required arguments and result types. A definition bundles the task name, argument encoder, optional metadata, and default `TaskOptions`. Build a call with `.call(args)` or `TaskEnqueueBuilder` and hand it to `Stem.enqueueCall` or `Canvas` helpers. For the common path, use the direct -`definition.enqueueWith(stem, args)` / `definition.enqueueAndWaitWith(...)` +`definition.enqueue(stem, args)` / `definition.enqueueAndWait(...)` helpers and drop down to `.call(args)` only when you need a reusable prebuilt request: @@ -58,8 +58,8 @@ codec still needs to encode to `Map` because task args are published as a map. For tasks with no producer inputs, use `TaskDefinition.noArgs(...)` -instead. That gives you direct `enqueueWith(...)` / -`enqueueAndWaitWith(...)` helpers without passing a fake empty map and the same +instead. That gives you direct `enqueue(...)` / +`enqueueAndWait(...)` helpers without passing a fake empty map and the same `waitFor(...)` decoding surface as normal typed definitions. ## Configuring Retries diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index 68c866da..6596daf2 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -56,7 +56,7 @@ final result = await StemWorkflowDefinitions.userSignup.startAndWaitWith( Annotated tasks use the same shared typed task surface: ```dart -final result = await StemTaskDefinitions.sendEmailTyped.enqueueAndWaitWithApp( +final result = await StemTaskDefinitions.sendEmailTyped.enqueueAndWait( workflowApp, ( dispatch: EmailDispatch( diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 896c90fc..f41e9efd 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -21,7 +21,7 @@ calls consistently use the generic `startWith(...)`, `startAndWaitWith(...)`, and `waitFor(...)` surface. - Simplified generated annotated task usage so `StemTaskDefinitions.*` is the - canonical surface, reusing shared `TaskCall.enqueueWith(...)` and + canonical surface, reusing shared `TaskCall.enqueue(...)` and `TaskDefinition.waitFor(...)` helpers instead of emitting separate generated enqueue/wait extension APIs. - Added `WorkflowStartCall.startWith(...)` so workflow refs can dispatch diff --git a/packages/stem/README.md b/packages/stem/README.md index 907fc6f2..cc503ea9 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -206,7 +206,7 @@ Future main() async { ); unawaited(worker.start()); - await HelloTask.definition.enqueueWith( + await HelloTask.definition.enqueue( stem, const HelloArgs(name: 'Stem'), ); @@ -229,7 +229,7 @@ For typed task calls, the definition and call objects now expose the common producer operations directly: ```dart -final taskId = await HelloTask.definition.enqueueWith( +final taskId = await HelloTask.definition.enqueue( stem, const HelloArgs(name: 'Stem'), ); @@ -245,7 +245,7 @@ final healthcheckDefinition = TaskDefinition.noArgs( name: 'demo.healthcheck', ); -await healthcheckDefinition.enqueueWith(stem); +await healthcheckDefinition.enqueue(stem); ``` If a no-arg task returns a DTO, pass `resultCodec:` so waiting helpers decode @@ -261,7 +261,7 @@ final taskId = await TaskEnqueueBuilder( ..header('x-tenant', 'tenant-a') ..priority(5) ..delay(const Duration(seconds: 30)) - .enqueueWith(stem); + .enqueue(stem); ``` ### Enqueue from inside a task @@ -575,7 +575,7 @@ Durable workflow contexts enqueue tasks directly: - `FlowContext.enqueue(...)` - `WorkflowScriptStepContext.enqueue(...)` -- typed task definitions can target those contexts via `enqueueWith(...)` +- typed task definitions can target those contexts via `enqueue(...)` Child workflows belong in durable execution boundaries: @@ -759,7 +759,7 @@ decoded payload, and a timeout flag: ```dart final taskId = await ChargeCustomer.definition .call(ChargeArgs(orderId: '123')) - .enqueueWith(stem); + .enqueue(stem); final charge = await ChargeCustomer.definition.waitFor( stem, @@ -775,7 +775,7 @@ if (charge?.isSucceeded == true) { Generated annotated tasks use the same surface: ```dart -final receipt = await StemTaskDefinitions.sendEmailTyped.enqueueAndWaitWith( +final receipt = await StemTaskDefinitions.sendEmailTyped.enqueueAndWait( stem, ( dispatch: EmailDispatch( diff --git a/packages/stem/example/annotated_workflows/bin/main.dart b/packages/stem/example/annotated_workflows/bin/main.dart index 8357217b..cd0e3f90 100644 --- a/packages/stem/example/annotated_workflows/bin/main.dart +++ b/packages/stem/example/annotated_workflows/bin/main.dart @@ -68,7 +68,7 @@ Future main() async { ); final typedTaskResult = await StemTaskDefinitions.sendEmailTyped - .enqueueAndWaitWithApp( + .enqueueAndWait( app, ( dispatch: const EmailDispatch( diff --git a/packages/stem/example/docs_snippets/lib/producer.dart b/packages/stem/example/docs_snippets/lib/producer.dart index 970ee1f1..a07a380b 100644 --- a/packages/stem/example/docs_snippets/lib/producer.dart +++ b/packages/stem/example/docs_snippets/lib/producer.dart @@ -131,7 +131,7 @@ Future enqueueTyped() async { final app = await StemApp.inMemory(tasks: [GenerateReportTask()]); await app.start(); - final taskId = await GenerateReportTask.definition.enqueueWith( + final taskId = await GenerateReportTask.definition.enqueue( app.stem, const ReportPayload(reportId: 'monthly-2025-10'), options: const TaskOptions(priority: 5), diff --git a/packages/stem/example/docs_snippets/lib/tasks.dart b/packages/stem/example/docs_snippets/lib/tasks.dart index f7e3e7e3..71131d1a 100644 --- a/packages/stem/example/docs_snippets/lib/tasks.dart +++ b/packages/stem/example/docs_snippets/lib/tasks.dart @@ -102,7 +102,7 @@ Future runTypedDefinitionExample() async { tasks: [PublishInvoiceTask()], ); - final taskId = await PublishInvoiceTask.definition.enqueueWith( + final taskId = await PublishInvoiceTask.definition.enqueue( stem, const InvoicePayload(invoiceId: 'inv_42'), ); diff --git a/packages/stem/example/ecommerce/README.md b/packages/stem/example/ecommerce/README.md index d52d278d..59250867 100644 --- a/packages/stem/example/ecommerce/README.md +++ b/packages/stem/example/ecommerce/README.md @@ -38,7 +38,7 @@ From those annotations, this example uses generated APIs: - `StemWorkflowDefinitions.addToCart.startAndWaitWith(...)` - `StemTaskDefinitions.ecommerceAuditLog` - direct task definition helpers like - `StemTaskDefinitions.ecommerceAuditLog.enqueueWith(...)` + `StemTaskDefinitions.ecommerceAuditLog.enqueue(...)` The manual checkout flow also derives a typed ref from its `Flow` definition: diff --git a/packages/stem/example/stem_example.dart b/packages/stem/example/stem_example.dart index 9c1ce9ed..d5c0aaf2 100644 --- a/packages/stem/example/stem_example.dart +++ b/packages/stem/example/stem_example.dart @@ -64,7 +64,7 @@ Future main() async { await stem.enqueue('demo.hello', args: {'name': 'Stem'}); // Typed helper with TaskDefinition for compile-time safety. - await HelloTask.definition(const HelloArgs(name: 'Stem')).enqueueWith(stem); + await HelloTask.definition(const HelloArgs(name: 'Stem')).enqueue(stem); await Future.delayed(const Duration(seconds: 1)); await worker.shutdown(); await broker.close(); diff --git a/packages/stem/example/task_context_mixed/lib/shared.dart b/packages/stem/example/task_context_mixed/lib/shared.dart index a3130379..961c436a 100644 --- a/packages/stem/example/task_context_mixed/lib/shared.dart +++ b/packages/stem/example/task_context_mixed/lib/shared.dart @@ -218,7 +218,7 @@ class InlineCoordinatorTask extends TaskHandler { ), ); - final auditId = await auditDefinition.enqueueWith( + final auditId = await auditDefinition.enqueue( context, AuditArgs( runId: runId, @@ -285,7 +285,7 @@ FutureOr inlineEntrypoint( '[inline_entrypoint] id=${context.id} attempt=${context.attempt} runId=$runId meta=${context.meta}', ); - await auditDefinition.enqueueWith( + await auditDefinition.enqueue( context, AuditArgs( runId: runId, diff --git a/packages/stem/example/task_usage_patterns.dart b/packages/stem/example/task_usage_patterns.dart index 29415e3c..982f9259 100644 --- a/packages/stem/example/task_usage_patterns.dart +++ b/packages/stem/example/task_usage_patterns.dart @@ -37,7 +37,7 @@ class ParentTask extends TaskHandler { ), ); - await childDefinition.enqueueWith(context, const ChildArgs('typed')); + await childDefinition.enqueue(context, const ChildArgs('typed')); } } @@ -99,7 +99,7 @@ Future main() async { await stem.enqueue('tasks.parent', args: const {}); await stem.enqueue('tasks.invocation_parent', args: const {}); - final directTaskId = await childDefinition.enqueueWith( + final directTaskId = await childDefinition.enqueue( stem, const ChildArgs('direct-call'), ); @@ -112,7 +112,7 @@ Future main() async { // ignore: avoid_print print('[direct] result=${directResult?.value}'); - final inlineResult = await childDefinition.enqueueAndWaitWith( + final inlineResult = await childDefinition.enqueueAndWait( stem, const ChildArgs('inline-wait'), timeout: const Duration(seconds: 1), diff --git a/packages/stem/lib/src/bootstrap/stem_app.dart b/packages/stem/lib/src/bootstrap/stem_app.dart index 58ea4c0f..218b795b 100644 --- a/packages/stem/lib/src/bootstrap/stem_app.dart +++ b/packages/stem/lib/src/bootstrap/stem_app.dart @@ -17,24 +17,7 @@ import 'package:stem/src/worker/worker.dart'; import 'package:stem_memory/stem_memory.dart' show InMemoryRevokeStore; /// Convenience bootstrap for setting up a Stem runtime with sensible defaults. -abstract interface class StemTaskApp implements TaskEnqueuer { - /// Waits for a task result by task id using the app's backing result store. - Future?> waitForTask( - String taskId, { - Duration? timeout, - TResult Function(Object? payload)? decode, - }); - - /// Waits for a task result using a typed [definition] for result decoding. - Future?> waitForTaskDefinition< - TArgs, - TResult extends Object? - >( - String taskId, - TaskDefinition definition, { - Duration? timeout, - }); -} +abstract interface class StemTaskApp implements TaskResultCaller {} /// Convenience bootstrap for setting up a Stem runtime with sensible defaults. class StemApp implements StemTaskApp { @@ -471,91 +454,3 @@ class StemApp implements StemTaskApp { ); } } - -/// Adds app-bound enqueue-and-wait helpers for prebuilt [TaskCall] objects. -extension TaskCallStemTaskAppExtension - on TaskCall { - /// Enqueues this call with [app] and waits for its typed result. - Future?> enqueueAndWaitWithApp( - StemTaskApp app, { - TaskEnqueueOptions? enqueueOptions, - Duration? timeout, - }) async { - final taskId = await enqueueWith(app, enqueueOptions: enqueueOptions); - return app.waitForTaskDefinition(taskId, definition, timeout: timeout); - } -} - -/// Adds app-bound helpers for typed [TaskDefinition] values. -extension TaskDefinitionStemTaskAppExtension - on TaskDefinition { - /// Enqueues this definition with [app] and waits for its typed result. - Future?> enqueueAndWaitWithApp( - StemTaskApp app, - TArgs args, { - Map headers = const {}, - TaskOptions? options, - DateTime? notBefore, - Map? meta, - TaskEnqueueOptions? enqueueOptions, - Duration? timeout, - }) { - return call( - args, - headers: headers, - options: options, - notBefore: notBefore, - meta: meta, - enqueueOptions: enqueueOptions, - ).enqueueAndWaitWithApp( - app, - enqueueOptions: enqueueOptions, - timeout: timeout, - ); - } - - /// Waits for [taskId] using this definition's decoding rules through [app]. - Future?> waitForApp( - StemTaskApp app, - String taskId, { - Duration? timeout, - }) { - return app.waitForTaskDefinition(taskId, this, timeout: timeout); - } -} - -/// Adds app-bound helpers for no-arg [NoArgsTaskDefinition] values. -extension NoArgsTaskDefinitionStemTaskAppExtension - on NoArgsTaskDefinition { - /// Enqueues this no-arg definition with [app] and waits for its result. - Future?> enqueueAndWaitWithApp( - StemTaskApp app, { - Map headers = const {}, - TaskOptions? options, - DateTime? notBefore, - Map? meta, - TaskEnqueueOptions? enqueueOptions, - Duration? timeout, - }) { - return call( - headers: headers, - options: options, - notBefore: notBefore, - meta: meta, - enqueueOptions: enqueueOptions, - ).enqueueAndWaitWithApp( - app, - enqueueOptions: enqueueOptions, - timeout: timeout, - ); - } - - /// Waits for [taskId] using this definition's decoding rules through [app]. - Future?> waitForApp( - StemTaskApp app, - String taskId, { - Duration? timeout, - }) { - return app.waitForTaskDefinition(taskId, asDefinition, timeout: timeout); - } -} diff --git a/packages/stem/lib/src/bootstrap/stem_client.dart b/packages/stem/lib/src/bootstrap/stem_client.dart index 243f2767..530e0fa9 100644 --- a/packages/stem/lib/src/bootstrap/stem_client.dart +++ b/packages/stem/lib/src/bootstrap/stem_client.dart @@ -19,7 +19,7 @@ import 'package:stem/src/workflow/runtime/workflow_introspection.dart'; import 'package:stem/src/workflow/runtime/workflow_registry.dart'; /// Shared entrypoint that owns broker/backend configuration for Stem runtimes. -abstract class StemClient implements TaskEnqueuer { +abstract class StemClient implements TaskResultCaller { /// Creates a client using the provided factories and defaults. static Future create({ StemModule? module, @@ -176,6 +176,7 @@ abstract class StemClient implements TaskEnqueuer { } /// Waits for a task result by task id using the client's shared backend. + @override Future?> waitForTask( String taskId, { Duration? timeout, @@ -185,6 +186,7 @@ abstract class StemClient implements TaskEnqueuer { } /// Waits for a task result using a typed [definition] for decoding. + @override Future?> waitForTaskDefinition< TArgs, TResult extends Object? @@ -301,99 +303,6 @@ abstract class StemClient implements TaskEnqueuer { Future close(); } -/// Adds client-bound enqueue-and-wait helpers for prebuilt [TaskCall] objects. -extension TaskCallStemClientExtension - on TaskCall { - /// Enqueues this call with [client] and waits for its typed result. - Future?> enqueueAndWaitWithClient( - StemClient client, { - TaskEnqueueOptions? enqueueOptions, - Duration? timeout, - }) async { - final taskId = await enqueueWith(client, enqueueOptions: enqueueOptions); - return client.waitForTaskDefinition(taskId, definition, timeout: timeout); - } -} - -/// Adds client-bound helpers for typed [TaskDefinition] values. -extension TaskDefinitionStemClientExtension - on TaskDefinition { - /// Enqueues this definition with [client] and waits for its typed result. - Future?> enqueueAndWaitWithClient( - StemClient client, - TArgs args, { - Map headers = const {}, - TaskOptions? options, - DateTime? notBefore, - Map? meta, - TaskEnqueueOptions? enqueueOptions, - Duration? timeout, - }) { - return call( - args, - headers: headers, - options: options, - notBefore: notBefore, - meta: meta, - enqueueOptions: enqueueOptions, - ).enqueueAndWaitWithClient( - client, - enqueueOptions: enqueueOptions, - timeout: timeout, - ); - } - - /// Waits for [taskId] using this definition's decoding rules through - /// [client]. - Future?> waitForClient( - StemClient client, - String taskId, { - Duration? timeout, - }) { - return client.waitForTaskDefinition(taskId, this, timeout: timeout); - } -} - -/// Adds client-bound helpers for no-arg [NoArgsTaskDefinition] values. -extension NoArgsTaskDefinitionStemClientExtension - on NoArgsTaskDefinition { - /// Enqueues this no-arg definition with [client] and waits for its result. - Future?> enqueueAndWaitWithClient( - StemClient client, { - Map headers = const {}, - TaskOptions? options, - DateTime? notBefore, - Map? meta, - TaskEnqueueOptions? enqueueOptions, - Duration? timeout, - }) { - return call( - headers: headers, - options: options, - notBefore: notBefore, - meta: meta, - enqueueOptions: enqueueOptions, - ).enqueueAndWaitWithClient( - client, - enqueueOptions: enqueueOptions, - timeout: timeout, - ); - } - - /// Waits for [taskId] using this definition's decoding rules through - /// [client]. - Future?> waitForClient( - StemClient client, - String taskId, { - Duration? timeout, - }) { - return client.waitForTaskDefinition( - taskId, - asDefinition, - timeout: timeout, - ); - } -} class _DefaultStemClient extends StemClient { _DefaultStemClient({ diff --git a/packages/stem/lib/src/core/stem.dart b/packages/stem/lib/src/core/stem.dart index 2d257e98..c9c05a98 100644 --- a/packages/stem/lib/src/core/stem.dart +++ b/packages/stem/lib/src/core/stem.dart @@ -74,8 +74,28 @@ import 'package:stem/src/routing/routing_registry.dart'; import 'package:stem/src/security/signing.dart'; import 'package:stem/src/signals/emitter.dart'; +/// Shared typed task-dispatch surface used by producers, apps, and contexts. +abstract interface class TaskResultCaller implements TaskEnqueuer { + /// Waits for a task result by task id. + Future?> waitForTask( + String taskId, { + Duration? timeout, + TResult Function(Object? payload)? decode, + }); + + /// Waits for [taskId] using a typed [definition] for result decoding. + Future?> waitForTaskDefinition< + TArgs, + TResult extends Object? + >( + String taskId, + TaskDefinition definition, { + Duration? timeout, + }); +} + /// Facade used by producer applications to enqueue tasks. -class Stem implements TaskEnqueuer { +class Stem implements TaskResultCaller { /// Creates a Stem producer facade with the provided dependencies. Stem({ required this.broker, @@ -431,6 +451,7 @@ class Stem implements TaskEnqueuer { /// Waits for [taskId] to reach a terminal state and returns a typed view of /// the final [TaskStatus]. Requires [backend] to be configured; otherwise a /// [StateError] is thrown. + @override Future?> waitForTask( String taskId, { Duration? timeout, @@ -503,6 +524,7 @@ class Stem implements TaskEnqueuer { } /// Waits for [taskId] using the decoding rules from a [TaskDefinition]. + @override Future?> waitForTaskDefinition( String taskId, @@ -1008,7 +1030,7 @@ class Stem implements TaskEnqueuer { extension TaskEnqueueBuilderExtension on TaskEnqueueBuilder { /// Builds the call and enqueues it with the provided [enqueuer] instance. - Future enqueueWith(TaskEnqueuer enqueuer) { + Future enqueue(TaskEnqueuer enqueuer) { final call = build(); final scopeMeta = TaskEnqueueScope.currentMeta(); if (scopeMeta == null || scopeMeta.isEmpty) { @@ -1029,7 +1051,7 @@ extension TaskCallExtension /// Ambient [TaskEnqueueScope] metadata is merged the same way as the fluent /// [TaskEnqueueBuilder] helper so producers and task contexts behave /// consistently. - Future enqueueWith( + Future enqueue( TaskEnqueuer enqueuer, { TaskEnqueueOptions? enqueueOptions, }) { @@ -1044,14 +1066,14 @@ extension TaskCallExtension ); } - /// Enqueues this call on [stem] and waits for the typed task result. - Future?> enqueueAndWaitWith( - Stem stem, { + /// Enqueues this call on [caller] and waits for the typed task result. + Future?> enqueueAndWait( + TaskResultCaller caller, { TaskEnqueueOptions? enqueueOptions, Duration? timeout, }) async { - final taskId = await enqueueWith(stem, enqueueOptions: enqueueOptions); - return definition.waitFor(stem, taskId, timeout: timeout); + final taskId = await enqueue(caller, enqueueOptions: enqueueOptions); + return definition.waitFor(caller, taskId, timeout: timeout); } } @@ -1059,7 +1081,7 @@ extension TaskCallExtension extension TaskDefinitionExtension on TaskDefinition { /// Enqueues this typed task definition directly with [enqueuer]. - Future enqueueWith( + Future enqueue( TaskEnqueuer enqueuer, TArgs args, { Map headers = const {}, @@ -1075,12 +1097,12 @@ extension TaskDefinitionExtension notBefore: notBefore, meta: meta, enqueueOptions: enqueueOptions, - ).enqueueWith(enqueuer, enqueueOptions: enqueueOptions); + ).enqueue(enqueuer, enqueueOptions: enqueueOptions); } /// Enqueues this typed task definition and waits for its typed result. - Future?> enqueueAndWaitWith( - Stem stem, + Future?> enqueueAndWait( + TaskResultCaller caller, TArgs args, { Map headers = const {}, TaskOptions? options, @@ -1096,8 +1118,8 @@ extension TaskDefinitionExtension notBefore: notBefore, meta: meta, enqueueOptions: enqueueOptions, - ).enqueueAndWaitWith( - stem, + ).enqueueAndWait( + caller, enqueueOptions: enqueueOptions, timeout: timeout, ); @@ -1105,11 +1127,11 @@ extension TaskDefinitionExtension /// Waits for [taskId] using this definition's decoding rules. Future?> waitFor( - Stem stem, + TaskResultCaller caller, String taskId, { Duration? timeout, }) { - return stem.waitForTaskDefinition(taskId, this, timeout: timeout); + return caller.waitForTaskDefinition(taskId, this, timeout: timeout); } } @@ -1117,7 +1139,7 @@ extension TaskDefinitionExtension extension NoArgsTaskDefinitionExtension on NoArgsTaskDefinition { /// Enqueues this no-arg task definition with [enqueuer]. - Future enqueueWith( + Future enqueue( TaskEnqueuer enqueuer, { Map headers = const {}, TaskOptions? options, @@ -1131,21 +1153,21 @@ extension NoArgsTaskDefinitionExtension notBefore: notBefore, meta: meta, enqueueOptions: enqueueOptions, - ).enqueueWith(enqueuer, enqueueOptions: enqueueOptions); + ).enqueue(enqueuer, enqueueOptions: enqueueOptions); } /// Waits for [taskId] using this definition's decoding rules. Future?> waitFor( - Stem stem, + TaskResultCaller caller, String taskId, { Duration? timeout, }) { - return stem.waitForTaskDefinition(taskId, asDefinition, timeout: timeout); + return caller.waitForTaskDefinition(taskId, asDefinition, timeout: timeout); } /// Enqueues this no-arg task definition and waits for the typed result. - Future?> enqueueAndWaitWith( - Stem stem, { + Future?> enqueueAndWait( + TaskResultCaller caller, { Map headers = const {}, TaskOptions? options, DateTime? notBefore, @@ -1153,14 +1175,14 @@ extension NoArgsTaskDefinitionExtension TaskEnqueueOptions? enqueueOptions, Duration? timeout, }) async { - final taskId = await enqueueWith( - stem, + final taskId = await enqueue( + caller, headers: headers, options: options, notBefore: notBefore, meta: meta, enqueueOptions: enqueueOptions, ); - return waitFor(stem, taskId, timeout: timeout); + return waitFor(caller, taskId, timeout: timeout); } } diff --git a/packages/stem/lib/stem.dart b/packages/stem/lib/stem.dart index 7fd64d28..5dc81813 100644 --- a/packages/stem/lib/stem.dart +++ b/packages/stem/lib/stem.dart @@ -59,7 +59,7 @@ /// final taskId = await addDefinition.call({ /// 'a': 10, /// 'b': 20, -/// }).enqueueWith(stem); +/// }).enqueue(stem); /// final result = await stem.waitForTask(taskId); /// /// print('Sum is: ${result?.value}'); // Sum is: 30 diff --git a/packages/stem/test/bootstrap/module_bootstrap_test.dart b/packages/stem/test/bootstrap/module_bootstrap_test.dart index 89b93467..42c5b125 100644 --- a/packages/stem/test/bootstrap/module_bootstrap_test.dart +++ b/packages/stem/test/bootstrap/module_bootstrap_test.dart @@ -23,7 +23,7 @@ void main() { expect(app.registry.resolve('module.bootstrap.task'), same(moduleTask)); expect(app.worker.subscription.queues, ['priority']); - final result = await moduleDefinition.enqueueAndWaitWithApp( + final result = await moduleDefinition.enqueueAndWait( app, timeout: const Duration(seconds: 2), ); diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index e4629120..80bf0389 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -519,7 +519,7 @@ void main() { ); await workflowApp.start(); - final result = await helperDefinition.enqueueAndWaitWithApp( + final result = await helperDefinition.enqueueAndWait( workflowApp, timeout: const Duration(seconds: 2), ); diff --git a/packages/stem/test/bootstrap/stem_client_test.dart b/packages/stem/test/bootstrap/stem_client_test.dart index 051fffd9..1de9b2ab 100644 --- a/packages/stem/test/bootstrap/stem_client_test.dart +++ b/packages/stem/test/bootstrap/stem_client_test.dart @@ -155,7 +155,7 @@ void main() { ); await app.start(); - final result = await taskDefinition.enqueueAndWaitWithApp( + final result = await taskDefinition.enqueueAndWait( app, timeout: const Duration(seconds: 2), ); @@ -299,7 +299,7 @@ void main() { final worker = await client.createWorker(); await worker.start(); try { - final result = await definition.enqueueAndWaitWithClient( + final result = await definition.enqueueAndWait( client, timeout: const Duration(seconds: 2), ); diff --git a/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart b/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart index 43143fea..e16219c3 100644 --- a/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart +++ b/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart @@ -22,7 +22,7 @@ void main() { ); await workflowApp.start(); - final result = await helperDefinition.enqueueAndWaitWithApp( + final result = await helperDefinition.enqueueAndWait( workflowApp, timeout: const Duration(seconds: 2), ); diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index ae8d53db..ef2bfb47 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -197,7 +197,7 @@ void main() { defaultOptions: const TaskOptions(queue: 'typed'), ); - final id = await definition.enqueueWith(stem); + final id = await definition.enqueue(stem); expect(id, isNotEmpty); expect(broker.published.single.envelope.name, 'sample.no_args'); @@ -219,7 +219,7 @@ void main() { resultCodec: _codecReceiptCodec, ); - final id = await definition.enqueueWith(stem); + final id = await definition.enqueue(stem); expect( backend.records.single.meta[stemResultEncoderMetaKey], @@ -231,7 +231,7 @@ void main() { }); group('TaskCall helpers', () { - test('TaskDefinition.enqueueWith enqueues typed args directly', () async { + test('TaskDefinition.enqueue enqueues typed args directly', () async { final broker = _RecordingBroker(); final backend = _RecordingBackend(); final stem = Stem(broker: broker, backend: backend); @@ -242,7 +242,7 @@ void main() { ); final taskId = await TaskEnqueueScope.run({'traceId': 'scope-1'}, () { - return definition.enqueueWith(stem, (value: 'ok')); + return definition.enqueue(stem, (value: 'ok')); }); expect(taskId, isNotEmpty); @@ -257,7 +257,7 @@ void main() { ); }); - test('enqueueWith enqueues typed calls with scoped metadata', () async { + test('enqueue enqueues typed calls with scoped metadata', () async { final broker = _RecordingBroker(); final backend = _RecordingBackend(); final stem = Stem(broker: broker, backend: backend); @@ -268,7 +268,7 @@ void main() { ); final taskId = await TaskEnqueueScope.run({'traceId': 'scope-1'}, () { - return definition.call((value: 'ok')).enqueueWith(stem); + return definition.call((value: 'ok')).enqueue(stem); }); expect(taskId, isNotEmpty); @@ -280,7 +280,7 @@ void main() { ); }); - test('enqueueAndWaitWith returns typed results', () async { + test('enqueueAndWait returns typed results', () async { final broker = _RecordingBroker(); final backend = _RecordingBackend(); final stem = Stem(broker: broker, backend: backend); @@ -301,13 +301,13 @@ void main() { final result = await definition .call((value: 'ok')) - .enqueueAndWaitWith(stem, timeout: const Duration(seconds: 1)); + .enqueueAndWait(stem, timeout: const Duration(seconds: 1)); expect(result?.isSucceeded, isTrue); expect(result?.value, 'done'); }); - test('TaskDefinition.enqueueAndWaitWith returns typed results', () async { + test('TaskDefinition.enqueueAndWait returns typed results', () async { final broker = _RecordingBroker(); final backend = _RecordingBackend(); final stem = Stem(broker: broker, backend: backend); @@ -326,7 +326,7 @@ void main() { }), ); - final result = await definition.enqueueAndWaitWith( + final result = await definition.enqueueAndWait( stem, (value: 'ok'), timeout: const Duration(seconds: 1), @@ -379,7 +379,7 @@ void main() { expect(result?.rawPayload, isA<_CodecReceipt>()); }); - test('enqueueAndWaitWith supports no-arg task definitions', () async { + test('enqueueAndWait supports no-arg task definitions', () async { final broker = _RecordingBroker(); final backend = _RecordingBackend(); final stem = Stem(broker: broker, backend: backend); @@ -395,7 +395,7 @@ void main() { }), ); - final result = await definition.enqueueAndWaitWith( + final result = await definition.enqueueAndWait( stem, timeout: const Duration(seconds: 1), ); diff --git a/packages/stem/test/unit/core/task_context_enqueue_test.dart b/packages/stem/test/unit/core/task_context_enqueue_test.dart index f7d6aa52..1feb7eb5 100644 --- a/packages/stem/test/unit/core/task_context_enqueue_test.dart +++ b/packages/stem/test/unit/core/task_context_enqueue_test.dart @@ -202,7 +202,7 @@ void main() { args: const _ExampleArgs('hello'), ); - await builder.queue('priority').priority(7).enqueueWith(context); + await builder.queue('priority').priority(7).enqueue(context); final record = enqueuer.last!; expect(record.name, equals('tasks.typed')); diff --git a/packages/stem/test/unit/core/task_enqueue_builder_test.dart b/packages/stem/test/unit/core/task_enqueue_builder_test.dart index fc936223..e774493d 100644 --- a/packages/stem/test/unit/core/task_enqueue_builder_test.dart +++ b/packages/stem/test/unit/core/task_enqueue_builder_test.dart @@ -98,12 +98,12 @@ void main() { }); test( - 'NoArgsTaskDefinition.enqueueWith uses the TaskEnqueuer surface', + 'NoArgsTaskDefinition.enqueue uses the TaskEnqueuer surface', () async { final definition = TaskDefinition.noArgs(name: 'demo.no_args'); final enqueuer = _RecordingTaskEnqueuer(); - final taskId = await definition.enqueueWith( + final taskId = await definition.enqueue( enqueuer, headers: const {'h': 'v'}, meta: const {'m': 1}, diff --git a/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart b/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart index 4e442ba6..3c9b57c5 100644 --- a/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart +++ b/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart @@ -451,6 +451,6 @@ FutureOr _isolateEnqueueEntrypoint( definition: _childDefinition, args: const _ChildArgs('from-isolate'), ); - await builder.enqueueWith(context); + await builder.enqueue(context); return null; } diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index 85644fb3..6667fea8 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -1367,7 +1367,7 @@ void main() { await TaskEnqueueBuilder( definition: definition, args: const {}, - ).meta('origin', 'builder').enqueueWith(stem); + ).meta('origin', 'builder').enqueue(stem); return 'done'; }); }, diff --git a/packages/stem_builder/CHANGELOG.md b/packages/stem_builder/CHANGELOG.md index b604d0ea..03347868 100644 --- a/packages/stem_builder/CHANGELOG.md +++ b/packages/stem_builder/CHANGELOG.md @@ -4,7 +4,7 @@ - Switched generated output to a bundle-first surface with `stemModule`, `StemWorkflowDefinitions`, `StemTaskDefinitions`, generated typed wait helpers, and payload codec generation for DTO-backed workflow/task APIs. - Removed generated task-specific enqueue/wait extension APIs in favor of the - shared `TaskCall.enqueueWith(...)` and `TaskDefinition.waitFor(...)` surface + shared `TaskCall.enqueue(...)` and `TaskDefinition.waitFor(...)` surface from `stem`, reducing duplicate happy paths in generated task code. - Added builder diagnostics for duplicate or conflicting annotated workflow checkpoint names and refreshed generated examples around typed workflow refs. - Refreshed generated child-workflow examples and docs to use the unified diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index 54119fc8..d4cc658b 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -107,7 +107,7 @@ Durable workflow contexts enqueue tasks directly: - `FlowContext.enqueue(...)` - `WorkflowScriptStepContext.enqueue(...)` -- typed task definitions can target those contexts via `enqueueWith(...)` +- typed task definitions can target those contexts via `enqueue(...)` Child workflows should be started from durable boundaries: @@ -172,7 +172,7 @@ dart run build_runner build The generated part exports a bundle plus typed refs/definitions so you can avoid raw workflow-name and task-name strings (for example `StemWorkflowDefinitions.userSignup.startWith(workflowApp, (email: 'user@example.com'))` -or `StemTaskDefinitions.builderExamplePing.enqueueWith(stem)`). +or `StemTaskDefinitions.builderExamplePing.enqueue(stem)`). Generated output includes: @@ -268,7 +268,7 @@ Annotated tasks also get generated definitions: ```dart final taskId = await StemTaskDefinitions.builderExampleTask .call(const {'kind': 'welcome'}) - .enqueueWith(workflowApp); + .enqueue(workflowApp); ``` ## Examples diff --git a/packages/stem_builder/example/README.md b/packages/stem_builder/example/README.md index 2cac4b72..294d7443 100644 --- a/packages/stem_builder/example/README.md +++ b/packages/stem_builder/example/README.md @@ -10,7 +10,7 @@ This example demonstrates: - Generated typed task definitions that use the shared `TaskCall` / `TaskDefinition.waitFor(...)` APIs - Generated zero-arg task definitions with direct helpers from core: - - `StemTaskDefinitions.builderExamplePing.enqueueAndWaitWith(stem)` + - `StemTaskDefinitions.builderExamplePing.enqueueAndWait(stem)` - Generated workflow manifest via `stemModule.workflowManifest` - Running generated definitions through `StemWorkflowApp` - Runtime manifest + run/step metadata views via `WorkflowRuntime` diff --git a/packages/stem_builder/example/bin/main.dart b/packages/stem_builder/example/bin/main.dart index 4ad4e21c..cc8b7e75 100644 --- a/packages/stem_builder/example/bin/main.dart +++ b/packages/stem_builder/example/bin/main.dart @@ -45,7 +45,7 @@ Future main() async { try { await taskApp.start(); final taskResult = await StemTaskDefinitions.builderExamplePing - .enqueueAndWaitWith( + .enqueueAndWait( taskApp.stem, timeout: const Duration(seconds: 2), ); From 6c3bb8498f975148c13294c25c924fcf93ae2b79 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 19:36:42 -0500 Subject: [PATCH 044/302] Flatten single-argument generated task and workflow refs --- .site/docs/core-concepts/stem-builder.md | 2 +- .site/docs/workflows/annotated-workflows.md | 18 +++---- .../workflows/context-and-serialization.md | 8 +-- .site/docs/workflows/flows-and-scripts.md | 4 +- .site/docs/workflows/starting-and-waiting.md | 20 ++----- packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 34 ++++-------- .../example/annotated_workflows/README.md | 4 +- .../example/annotated_workflows/bin/main.dart | 20 +++---- .../annotated_workflows/lib/definitions.dart | 4 +- .../lib/definitions.stem.g.dart | 53 +++++++++---------- .../example/docs_snippets/lib/workflows.dart | 22 ++------ .../stem/example/ecommerce/lib/src/app.dart | 4 +- .../lib/src/workflows/checkout_flow.dart | 6 +-- packages/stem_builder/CHANGELOG.md | 3 ++ packages/stem_builder/README.md | 10 ++-- packages/stem_builder/example/bin/main.dart | 2 +- .../example/bin/runtime_metadata_views.dart | 4 +- .../example/lib/definitions.stem.g.dart | 15 +++--- .../lib/src/stem_registry_builder.dart | 50 ++++++++++++----- .../test/stem_registry_builder_test.dart | 22 ++++---- 21 files changed, 143 insertions(+), 165 deletions(-) diff --git a/.site/docs/core-concepts/stem-builder.md b/.site/docs/core-concepts/stem-builder.md index d14ad35e..1ff22f0c 100644 --- a/.site/docs/core-concepts/stem-builder.md +++ b/.site/docs/core-concepts/stem-builder.md @@ -89,7 +89,7 @@ final workflowApp = await StemWorkflowApp.fromUrl( await workflowApp.start(); final result = await StemWorkflowDefinitions.userSignup.startAndWaitWith( workflowApp, - (email: 'user@example.com'), + 'user@example.com', ); ``` diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index 6596daf2..3e56a546 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -49,7 +49,7 @@ and wait operations: ```dart final result = await StemWorkflowDefinitions.userSignup.startAndWaitWith( workflowApp, - (email: 'user@example.com'), + 'user@example.com', ); ``` @@ -58,13 +58,11 @@ Annotated tasks use the same shared typed task surface: ```dart final result = await StemTaskDefinitions.sendEmailTyped.enqueueAndWait( workflowApp, - ( - dispatch: EmailDispatch( - email: 'typed@example.com', - subject: 'Welcome', - body: 'Codec-backed DTO payloads', - tags: ['welcome'], - ), + EmailDispatch( + email: 'typed@example.com', + subject: 'Welcome', + body: 'Codec-backed DTO payloads', + tags: ['welcome'], ), ); ``` @@ -137,9 +135,9 @@ This keeps one authoring model: When a workflow needs to start another workflow, do it from a durable boundary: -- `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, (...))` +- `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, value)` inside flow steps -- `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, (...))` +- `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, value)` inside checkpoint methods Avoid starting child workflows from the raw `WorkflowScriptContext` body. diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index 0c701ca6..b1eb0d24 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -39,8 +39,8 @@ Depending on the context type, you can access: - `takeResumeValue(codec: ...)` for typed event-driven resumes - `idempotencyKey(...)` - direct child-workflow start helpers such as - `StemWorkflowDefinitions.someWorkflow.startWith(context, (...))` and - `startAndWaitWith(context, (...))` + `StemWorkflowDefinitions.someWorkflow.startWith(context, value)` and + `startAndWaitWith(context, value)` - direct task enqueue APIs because `FlowContext`, `WorkflowScriptStepContext`, and `TaskInvocationContext` all implement `TaskEnqueuer` @@ -48,9 +48,9 @@ Depending on the context type, you can access: Child workflow starts belong in durable boundaries: -- `StemWorkflowDefinitions.someWorkflow.startWith(context, (...))` inside flow +- `StemWorkflowDefinitions.someWorkflow.startWith(context, value)` inside flow steps -- `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, (...))` +- `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, value)` inside script checkpoints Do not treat the raw `WorkflowScriptContext` body as a safe place for child diff --git a/.site/docs/workflows/flows-and-scripts.md b/.site/docs/workflows/flows-and-scripts.md index 6774d008..cbaedbf1 100644 --- a/.site/docs/workflows/flows-and-scripts.md +++ b/.site/docs/workflows/flows-and-scripts.md @@ -30,8 +30,8 @@ is that for script workflows those are **checkpoints**, not the plan itself. Manual flows can also derive a typed workflow ref from the definition: ```dart -final approvalsRef = approvalsFlow.ref<({Map draft})>( - encodeParams: (params) => {'draft': params.draft}, +final approvalsRef = approvalsFlow.ref>( + encodeParams: (draft) => {'draft': draft}, ); ``` diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 02b35370..5aa01a61 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -31,23 +31,13 @@ const approvalDraftCodec = PayloadCodec( ), ); -final approvalsRef = approvalsFlow.refWithCodec<({ApprovalDraft draft})>( - paramsCodec: PayloadCodec<({ApprovalDraft draft})>( - encode: (value) => { - 'draft': approvalDraftCodec.encode(value.draft), - }, - decode: (payload) { - final map = Map.from(payload as Map); - return ( - draft: approvalDraftCodec.decode(map['draft']) as ApprovalDraft, - ); - }, - ), +final approvalsRef = approvalsFlow.refWithCodec( + paramsCodec: approvalDraftCodec, ); final runId = await approvalsRef.startWith( workflowApp, - (draft: const ApprovalDraft(documentId: 'doc-42')), + const ApprovalDraft(documentId: 'doc-42'), ); final result = await approvalsRef.waitFor(workflowApp, runId); @@ -81,7 +71,7 @@ workflow-name strings and give you one typed handle for both start and wait: ```dart final result = await StemWorkflowDefinitions.userSignup.startAndWaitWith( workflowApp, - (email: 'user@example.com'), + 'user@example.com', ); ``` @@ -91,7 +81,7 @@ The same definitions work on `WorkflowRuntime` by passing the runtime as the ```dart final runId = await StemWorkflowDefinitions.userSignup.startWith( runtime, - (email: 'user@example.com'), + 'user@example.com', ); ``` diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index f41e9efd..48a5eb26 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Flattened single-argument generated workflow/task refs and helper calls so + one-field annotated workflows/tasks now use direct values instead of + synthetic named-record wrappers in generated APIs, examples, and docs. - Added `StemModule`, typed `WorkflowRef`/`WorkflowStartCall` helpers, and bundle-first `StemWorkflowApp`/`StemClient` composition for generated workflow and task definitions. - Added `PayloadCodec`, typed workflow resume helpers, codec-backed workflow checkpoint/result persistence, typed task result waiting, and typed workflow event emit helpers for DTO-shaped payloads. - Made `FlowContext` and `WorkflowScriptStepContext` implement `WorkflowCaller` diff --git a/packages/stem/README.md b/packages/stem/README.md index cc503ea9..aaa3e44b 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -433,18 +433,8 @@ const approvalDraftCodec = PayloadCodec( ), ); -final approvalsRef = approvalsFlow.refWithCodec<({ApprovalDraft draft})>( - paramsCodec: PayloadCodec<({ApprovalDraft draft})>( - encode: (value) => { - 'draft': approvalDraftCodec.encode(value.draft), - }, - decode: (payload) { - final map = Map.from(payload as Map); - return ( - draft: approvalDraftCodec.decode(map['draft']) as ApprovalDraft, - ); - }, - ), +final approvalsRef = approvalsFlow.refWithCodec( + paramsCodec: approvalDraftCodec, ); final app = await StemWorkflowApp.fromUrl( @@ -455,7 +445,7 @@ final app = await StemWorkflowApp.fromUrl( final runId = await approvalsRef.startWith( app, - (draft: const ApprovalDraft(documentId: 'doc-42')), + const ApprovalDraft(documentId: 'doc-42'), ); final result = await approvalsRef.waitFor(app, runId); @@ -580,10 +570,10 @@ Durable workflow contexts enqueue tasks directly: Child workflows belong in durable execution boundaries: - use - `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, (...))` + `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, value)` inside flow steps - use - `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, (...))` + `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, value)` inside script checkpoints - do not start child workflows from the raw `WorkflowScriptContext` body unless you are deliberately managing replay/idempotency yourself @@ -636,7 +626,7 @@ final app = await StemWorkflowApp.fromUrl( final result = await StemWorkflowDefinitions.userSignup.startAndWaitWith( app, - (email: 'user@example.com'), + 'user@example.com', ); print(result?.value); await app.close(); @@ -777,13 +767,11 @@ Generated annotated tasks use the same surface: ```dart final receipt = await StemTaskDefinitions.sendEmailTyped.enqueueAndWait( stem, - ( - dispatch: EmailDispatch( - email: 'typed@example.com', - subject: 'Welcome', - body: 'Codec-backed DTO payloads', - tags: ['welcome'], - ), + EmailDispatch( + email: 'typed@example.com', + subject: 'Welcome', + body: 'Codec-backed DTO payloads', + tags: ['welcome'], ), ); print(receipt?.value?.deliveryId); diff --git a/packages/stem/example/annotated_workflows/README.md b/packages/stem/example/annotated_workflows/README.md index b4c766c5..e5f88ff4 100644 --- a/packages/stem/example/annotated_workflows/README.md +++ b/packages/stem/example/annotated_workflows/README.md @@ -6,7 +6,7 @@ with the `stem_builder` bundle generator. It now demonstrates the generated script-proxy behavior explicitly: - a flow step using `FlowContext` - a flow step starting and waiting on a child workflow through - `StemWorkflowDefinitions.*.startAndWaitWith(context, (...))` + `StemWorkflowDefinitions.*.startAndWaitWith(context, value)` - `run(WelcomeRequest request)` calls annotated checkpoint methods directly - `prepareWelcome(...)` calls other annotated checkpoints - `deliverWelcome(...)` calls another annotated checkpoint from inside an @@ -15,7 +15,7 @@ It now demonstrates the generated script-proxy behavior explicitly: (`WorkflowScriptContext? context` / `WorkflowScriptStepContext? context`) to expose `runId`, `workflow`, `stepName`, `stepIndex`, and idempotency keys - a script checkpoint starting and waiting on a child workflow through - `StemWorkflowDefinitions.*.startAndWaitWith(context, (...))` + `StemWorkflowDefinitions.*.startAndWaitWith(context, value)` - a plain script workflow that returns a codec-backed DTO result and persists a codec-backed DTO checkpoint value - a typed `@TaskDefn` using optional named `TaskInvocationContext? context` diff --git a/packages/stem/example/annotated_workflows/bin/main.dart b/packages/stem/example/annotated_workflows/bin/main.dart index cd0e3f90..45a8b6ed 100644 --- a/packages/stem/example/annotated_workflows/bin/main.dart +++ b/packages/stem/example/annotated_workflows/bin/main.dart @@ -22,9 +22,7 @@ Future main() async { final scriptResult = await StemWorkflowDefinitions.script.startAndWaitWith( app, - ( - request: const WelcomeRequest(email: ' SomeEmail@Example.com '), - ), + const WelcomeRequest(email: ' SomeEmail@Example.com '), timeout: const Duration(seconds: 2), ); print('Script result: ${jsonEncode(scriptResult?.value?.toJson())}'); @@ -48,9 +46,7 @@ Future main() async { final contextResult = await StemWorkflowDefinitions.contextScript .startAndWaitWith( app, - ( - request: const WelcomeRequest(email: ' ContextEmail@Example.com '), - ), + const WelcomeRequest(email: ' ContextEmail@Example.com '), timeout: const Duration(seconds: 2), ); print('Context script result: ${jsonEncode(contextResult?.value?.toJson())}'); @@ -70,13 +66,11 @@ Future main() async { final typedTaskResult = await StemTaskDefinitions.sendEmailTyped .enqueueAndWait( app, - ( - dispatch: const EmailDispatch( - email: 'typed@example.com', - subject: 'Welcome', - body: 'Codec-backed DTO payloads', - tags: ['welcome', 'transactional', 'annotated'], - ), + const EmailDispatch( + email: 'typed@example.com', + subject: 'Welcome', + body: 'Codec-backed DTO payloads', + tags: ['welcome', 'transactional', 'annotated'], ), meta: const {'origin': 'annotated_workflows_example'}, timeout: const Duration(seconds: 2), diff --git a/packages/stem/example/annotated_workflows/lib/definitions.dart b/packages/stem/example/annotated_workflows/lib/definitions.dart index 0e05cd57..7e6552b8 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.dart @@ -197,7 +197,7 @@ class AnnotatedFlowWorkflow { final childResult = await StemWorkflowDefinitions.script .startAndWaitWith( ctx, - (request: const WelcomeRequest(email: 'flow-child@example.com')), + const WelcomeRequest(email: 'flow-child@example.com'), timeout: const Duration(seconds: 2), ); return { @@ -282,7 +282,7 @@ class AnnotatedContextScriptWorkflow { final childResult = await StemWorkflowDefinitions.script .startAndWaitWith( ctx, - (request: WelcomeRequest(email: normalizedEmail)), + WelcomeRequest(email: normalizedEmail), timeout: const Duration(seconds: 2), ); return ContextCaptureResult( diff --git a/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart b/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart index 82648eac..a3a4d7be 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart @@ -226,22 +226,22 @@ final List _stemScripts = [ abstract final class StemWorkflowDefinitions { static final NoArgsWorkflowRef?> flow = NoArgsWorkflowRef?>(name: "annotated.flow"); - static final WorkflowRef<({WelcomeRequest request}), WelcomeWorkflowResult> - script = WorkflowRef<({WelcomeRequest request}), WelcomeWorkflowResult>( - name: "annotated.script", - encodeParams: (params) => { - "request": StemPayloadCodecs.welcomeRequest.encode(params.request), - }, - decodeResult: StemPayloadCodecs.welcomeWorkflowResult.decode, - ); - static final WorkflowRef<({WelcomeRequest request}), ContextCaptureResult> - contextScript = WorkflowRef<({WelcomeRequest request}), ContextCaptureResult>( - name: "annotated.context_script", - encodeParams: (params) => { - "request": StemPayloadCodecs.welcomeRequest.encode(params.request), - }, - decodeResult: StemPayloadCodecs.contextCaptureResult.decode, - ); + static final WorkflowRef script = + WorkflowRef( + name: "annotated.script", + encodeParams: (params) => { + "request": StemPayloadCodecs.welcomeRequest.encode(params), + }, + decodeResult: StemPayloadCodecs.welcomeWorkflowResult.decode, + ); + static final WorkflowRef contextScript = + WorkflowRef( + name: "annotated.context_script", + encodeParams: (params) => { + "request": StemPayloadCodecs.welcomeRequest.encode(params), + }, + decodeResult: StemPayloadCodecs.contextCaptureResult.decode, + ); } Object? _stemRequireArg(Map args, String name) { @@ -278,17 +278,16 @@ abstract final class StemTaskDefinitions { defaultOptions: const TaskOptions(maxRetries: 1), metadata: const TaskMetadata(), ); - static final TaskDefinition<({EmailDispatch dispatch}), EmailDeliveryReceipt> - sendEmailTyped = - TaskDefinition<({EmailDispatch dispatch}), EmailDeliveryReceipt>( - name: "send_email_typed", - encodeArgs: (args) => { - "dispatch": StemPayloadCodecs.emailDispatch.encode(args.dispatch), - }, - defaultOptions: const TaskOptions(maxRetries: 1), - metadata: const TaskMetadata(), - decodeResult: StemPayloadCodecs.emailDeliveryReceipt.decode, - ); + static final TaskDefinition + sendEmailTyped = TaskDefinition( + name: "send_email_typed", + encodeArgs: (args) => { + "dispatch": StemPayloadCodecs.emailDispatch.encode(args), + }, + defaultOptions: const TaskOptions(maxRetries: 1), + metadata: const TaskMetadata(), + decodeResult: StemPayloadCodecs.emailDeliveryReceipt.decode, + ); } final List> _stemTasks = >[ diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index 6fa23718..9d47a922 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -88,22 +88,8 @@ class ApprovalsFlow { }, ); - static final ref = flow.refWithCodec<({ApprovalDraft draft})>( - paramsCodec: PayloadCodec<({ApprovalDraft draft})>( - encode: _encodeApprovalStart, - decode: _decodeApprovalStart, - ), - ); -} - -Object? _encodeApprovalStart(({ApprovalDraft draft}) value) { - return {'draft': approvalDraftCodec.encode(value.draft)}; -} - -({ApprovalDraft draft}) _decodeApprovalStart(Object? payload) { - final map = Map.from(payload as Map); - return ( - draft: approvalDraftCodec.decode(map['draft']) as ApprovalDraft, + static final ref = flow.refWithCodec( + paramsCodec: approvalDraftCodec, ); } @@ -142,9 +128,7 @@ final retryDefinition = retryScript.definition; Future runWorkflow(StemWorkflowApp workflowApp) async { final runId = await ApprovalsFlow.ref.startWith( workflowApp, - ( - draft: const ApprovalDraft(documentId: 'doc-42'), - ), + const ApprovalDraft(documentId: 'doc-42'), cancellationPolicy: const WorkflowCancellationPolicy( maxRunDuration: Duration(hours: 2), maxSuspendDuration: Duration(minutes: 30), diff --git a/packages/stem/example/ecommerce/lib/src/app.dart b/packages/stem/example/ecommerce/lib/src/app.dart index 7a03b842..991910cf 100644 --- a/packages/stem/example/ecommerce/lib/src/app.dart +++ b/packages/stem/example/ecommerce/lib/src/app.dart @@ -131,9 +131,7 @@ class EcommerceServer { }) ..post('/checkout/', (Request request, String cartId) async { try { - final runId = await checkoutWorkflow - .call((cartId: cartId)) - .startWith(workflowApp); + final runId = await checkoutWorkflow.call(cartId).startWith(workflowApp); final result = await checkoutWorkflow.waitFor( workflowApp, diff --git a/packages/stem/example/ecommerce/lib/src/workflows/checkout_flow.dart b/packages/stem/example/ecommerce/lib/src/workflows/checkout_flow.dart index 4ced4f4d..f4546b41 100644 --- a/packages/stem/example/ecommerce/lib/src/workflows/checkout_flow.dart +++ b/packages/stem/example/ecommerce/lib/src/workflows/checkout_flow.dart @@ -4,11 +4,11 @@ import '../domain/repository.dart'; const checkoutWorkflowName = 'ecommerce.checkout'; -WorkflowRef<({String cartId}), Map> checkoutWorkflowRef( +WorkflowRef> checkoutWorkflowRef( Flow> flow, ) { - return flow.ref<({String cartId})>( - encodeParams: (params) => {'cartId': params.cartId}, + return flow.ref( + encodeParams: (cartId) => {'cartId': cartId}, ); } diff --git a/packages/stem_builder/CHANGELOG.md b/packages/stem_builder/CHANGELOG.md index 03347868..d7ebb7f6 100644 --- a/packages/stem_builder/CHANGELOG.md +++ b/packages/stem_builder/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.0 +- Flattened generated single-argument workflow/task definitions so one-field + annotated workflows/tasks emit direct typed refs instead of named-record + wrappers. - Switched generated output to a bundle-first surface with `stemModule`, `StemWorkflowDefinitions`, `StemTaskDefinitions`, generated typed wait helpers, and payload codec generation for DTO-backed workflow/task APIs. - Removed generated task-specific enqueue/wait extension APIs in favor of the shared `TaskCall.enqueue(...)` and `TaskDefinition.waitFor(...)` surface diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index d4cc658b..445c8ac2 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -111,9 +111,9 @@ Durable workflow contexts enqueue tasks directly: Child workflows should be started from durable boundaries: -- `StemWorkflowDefinitions.someWorkflow.startWith(context, (...))` +- `StemWorkflowDefinitions.someWorkflow.startWith(context, value)` inside flow steps -- `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, (...))` +- `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, value)` inside script checkpoints Avoid starting child workflows directly from the raw @@ -171,7 +171,7 @@ dart run build_runner build The generated part exports a bundle plus typed refs/definitions so you can avoid raw workflow-name and task-name strings (for example -`StemWorkflowDefinitions.userSignup.startWith(workflowApp, (email: 'user@example.com'))` +`StemWorkflowDefinitions.userSignup.startWith(workflowApp, 'user@example.com')` or `StemTaskDefinitions.builderExamplePing.enqueue(stem)`). Generated output includes: @@ -198,7 +198,7 @@ final workflowApp = await StemWorkflowApp.fromUrl( final result = await StemWorkflowDefinitions.userSignup.startAndWaitWith( workflowApp, - (email: 'user@example.com'), + 'user@example.com', ); ``` @@ -258,7 +258,7 @@ The generated workflow refs work on `WorkflowRuntime` too: final runtime = workflowApp.runtime; final runId = await StemWorkflowDefinitions.userSignup.startWith( runtime, - (email: 'user@example.com'), + 'user@example.com', ); await runtime.executeRun(runId); ``` diff --git a/packages/stem_builder/example/bin/main.dart b/packages/stem_builder/example/bin/main.dart index cc8b7e75..da4403b8 100644 --- a/packages/stem_builder/example/bin/main.dart +++ b/packages/stem_builder/example/bin/main.dart @@ -28,7 +28,7 @@ Future main() async { final runId = await StemWorkflowDefinitions.flow.startWith( runtime, - (name: 'Stem Builder'), + 'Stem Builder', ); await runtime.executeRun(runId); final result = await StemWorkflowDefinitions.flow.waitFor( diff --git a/packages/stem_builder/example/bin/runtime_metadata_views.dart b/packages/stem_builder/example/bin/runtime_metadata_views.dart index 98c711ce..e42151d5 100644 --- a/packages/stem_builder/example/bin/runtime_metadata_views.dart +++ b/packages/stem_builder/example/bin/runtime_metadata_views.dart @@ -27,14 +27,14 @@ Future main() async { final flowRunId = await StemWorkflowDefinitions.flow.startWith( runtime, - (name: 'runtime metadata'), + 'runtime metadata', ); await runtime.executeRun(flowRunId); final scriptRunId = await StemWorkflowDefinitions.userSignup .startWith( runtime, - (email: 'dev@stem.dev'), + 'dev@stem.dev', ); await runtime.executeRun(scriptRunId); diff --git a/packages/stem_builder/example/lib/definitions.stem.g.dart b/packages/stem_builder/example/lib/definitions.stem.g.dart index 81780cc9..ede891a6 100644 --- a/packages/stem_builder/example/lib/definitions.stem.g.dart +++ b/packages/stem_builder/example/lib/definitions.stem.g.dart @@ -73,15 +73,14 @@ final List _stemScripts = [ ]; abstract final class StemWorkflowDefinitions { - static final WorkflowRef<({String name}), String> flow = - WorkflowRef<({String name}), String>( - name: "builder.example.flow", - encodeParams: (params) => {"name": params.name}, - ); - static final WorkflowRef<({String email}), Map> userSignup = - WorkflowRef<({String email}), Map>( + static final WorkflowRef flow = WorkflowRef( + name: "builder.example.flow", + encodeParams: (params) => {"name": params}, + ); + static final WorkflowRef> userSignup = + WorkflowRef>( name: "builder.example.user_signup", - encodeParams: (params) => {"email": params.email}, + encodeParams: (params) => {"email": params}, ); } diff --git a/packages/stem_builder/lib/src/stem_registry_builder.dart b/packages/stem_builder/lib/src/stem_registry_builder.dart index 051f6f83..7c907f22 100644 --- a/packages/stem_builder/lib/src/stem_registry_builder.dart +++ b/packages/stem_builder/lib/src/stem_registry_builder.dart @@ -1656,6 +1656,7 @@ class _RegistryEmitter { ? workflow.runValueParameters : workflow.steps.first.valueParameters; final usesNoArgsDefinition = valueParameters.isEmpty; + final singleParameter = _singleValueParameter(valueParameters); final refType = usesNoArgsDefinition ? 'NoArgsWorkflowRef<${workflow.resultTypeCode}>' @@ -1667,25 +1668,21 @@ class _RegistryEmitter { buffer.writeln(' static final $refType $fieldName = $constructorType('); buffer.writeln(' name: ${_string(workflow.name)},'); if (!usesNoArgsDefinition) { - if (workflow.kind == WorkflowKind.script) { - buffer.writeln(' encodeParams: (params) => {'); - for (final parameter in valueParameters) { - buffer.writeln( - ' ${_string(parameter.name)}: ' - '${_encodeValueExpression('params.${parameter.name}', parameter)},', - ); - } - buffer.writeln(' },'); + buffer.writeln(' encodeParams: (params) => {'); + if (singleParameter != null) { + buffer.writeln( + ' ${_string(singleParameter.name)}: ' + '${_encodeValueExpression('params', singleParameter)},', + ); } else { - buffer.writeln(' encodeParams: (params) => {'); for (final parameter in valueParameters) { buffer.writeln( ' ${_string(parameter.name)}: ' '${_encodeValueExpression('params.${parameter.name}', parameter)},', ); } - buffer.writeln(' },'); } + buffer.writeln(' },'); } if (workflow.resultPayloadCodecTypeCode != null) { final codecField = @@ -1933,6 +1930,7 @@ class _RegistryEmitter { final argsTypeCode = _taskArgsTypeCode(task); final usesNoArgsDefinition = !task.usesLegacyMapArgs && task.valueParameters.isEmpty; + final singleParameter = _singleValueParameter(task.valueParameters); if (usesNoArgsDefinition) { buffer.writeln( ' static final NoArgsTaskDefinition<${task.resultTypeCode}> $symbol = NoArgsTaskDefinition<${task.resultTypeCode}>(', @@ -1947,11 +1945,18 @@ class _RegistryEmitter { buffer.writeln(' encodeArgs: (args) => args,'); } else if (task.valueParameters.isNotEmpty) { buffer.writeln(' encodeArgs: (args) => {'); - for (final parameter in task.valueParameters) { + if (singleParameter != null) { buffer.writeln( - ' ${_string(parameter.name)}: ' - '${_encodeValueExpression('args.${parameter.name}', parameter)},', + ' ${_string(singleParameter.name)}: ' + '${_encodeValueExpression('args', singleParameter)},', ); + } else { + for (final parameter in task.valueParameters) { + buffer.writeln( + ' ${_string(parameter.name)}: ' + '${_encodeValueExpression('args.${parameter.name}', parameter)},', + ); + } } buffer.writeln(' },'); } @@ -2147,6 +2152,10 @@ class _RegistryEmitter { if (task.valueParameters.isEmpty) { return '()'; } + final singleParameter = _singleValueParameter(task.valueParameters); + if (singleParameter != null) { + return singleParameter.typeCode; + } final fields = task.valueParameters .map((parameter) => '${parameter.typeCode} ${parameter.name}') .join(', '); @@ -2161,12 +2170,25 @@ class _RegistryEmitter { if (parameters.isEmpty) { return '()'; } + final singleParameter = _singleValueParameter(parameters); + if (singleParameter != null) { + return singleParameter.typeCode; + } final fields = parameters .map((parameter) => '${parameter.typeCode} ${parameter.name}') .join(', '); return '({$fields})'; } + _ValueParameterInfo? _singleValueParameter( + List<_ValueParameterInfo> parameters, + ) { + if (parameters.length != 1) { + return null; + } + return parameters.single; + } + String _qualify(String alias, String symbol) { if (alias.isEmpty) return symbol; return '$alias.$symbol'; diff --git a/packages/stem_builder/test/stem_registry_builder_test.dart b/packages/stem_builder/test/stem_registry_builder_test.dart index 67a1589d..fd70ad78 100644 --- a/packages/stem_builder/test/stem_registry_builder_test.dart +++ b/packages/stem_builder/test/stem_registry_builder_test.dart @@ -300,7 +300,7 @@ class DailyBillingWorkflow { 'helloFlow =', ), contains( - 'static final WorkflowRef<({String tenant}), Object?> ' + 'static final WorkflowRef ' 'dailyBilling =', ), ]), @@ -412,8 +412,8 @@ Future sendEmail(EmailRequest request) async => request.email; outputs: { 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( allOf([ - contains( - 'static final TaskDefinition<({EmailRequest request}), String> emailSend =', + contains( + 'static final TaskDefinition emailSend =', ), isNot(contains('enqueueEmailSend(')), isNot(contains('enqueueAndWaitEmailSend(')), @@ -447,8 +447,8 @@ class SignupWorkflow { outputs: { 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( allOf([ - contains( - 'static final WorkflowRef<({String email}), String> signupWorkflow =', + contains( + 'static final WorkflowRef signupWorkflow =', ), isNot(contains('startSignupWorkflow(')), isNot(contains('startAndWaitSignupWorkflow(')), @@ -484,8 +484,8 @@ class GreetingFlow { outputs: { 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( allOf([ - contains( - 'static final WorkflowRef<({String name}), String> greetingFlow =', + contains( + 'static final WorkflowRef greetingFlow =', ), isNot(contains('startGreetingFlow(')), isNot(contains('startAndWaitGreetingFlow(')), @@ -783,8 +783,8 @@ class SignupWorkflow { contains('_stemRequireArg(script.params, "email") as String'), contains('abstract final class StemWorkflowDefinitions'), contains( - 'signupWorkflow = WorkflowRef<({String email}), ' - 'Map>(', + 'static final WorkflowRef> ' + 'signupWorkflow =', ), isNot(contains('extraParams')), ]), @@ -1146,12 +1146,12 @@ Future dtoTask( contains('abstract final class StemPayloadCodecs'), contains('PayloadCodec emailRequest ='), contains( - 'WorkflowRef<({EmailRequest request}), EmailRequest> script =', + 'WorkflowRef script =', ), contains('encode: (value) => value.toJson(),'), contains('EmailRequest.fromJson('), contains( - 'StemPayloadCodecs.emailRequest.encode(params.request)', + 'StemPayloadCodecs.emailRequest.encode(params)', ), contains('StemPayloadCodecs.emailRequest.decode('), contains( From 8ba588ab26a622d600257458cd4375854dcd48a4 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 19:48:14 -0500 Subject: [PATCH 045/302] Make StemApp lazy-start and infer task queues --- .site/docs/core-concepts/stem-builder.md | 9 +-- .site/docs/getting-started/first-steps.md | 8 ++- packages/stem/CHANGELOG.md | 3 + packages/stem/README.md | 9 ++- .../docs_snippets/lib/first_steps.dart | 5 +- .../example/docs_snippets/lib/producer.dart | 5 +- .../docs_snippets/lib/quick_start.dart | 12 ++-- .../stem/example/docs_snippets/lib/tasks.dart | 5 +- packages/stem/lib/src/bootstrap/stem_app.dart | 54 ++++++++++++--- .../stem/test/bootstrap/stem_app_test.dart | 59 ++++++++++++++--- .../stem/test/bootstrap/stem_client_test.dart | 65 +++++++++++++++++-- packages/stem_builder/README.md | 8 ++- packages/stem_builder/example/bin/main.dart | 3 +- 13 files changed, 191 insertions(+), 54 deletions(-) diff --git a/.site/docs/core-concepts/stem-builder.md b/.site/docs/core-concepts/stem-builder.md index 1ff22f0c..66b15115 100644 --- a/.site/docs/core-concepts/stem-builder.md +++ b/.site/docs/core-concepts/stem-builder.md @@ -124,8 +124,9 @@ final taskApp = await StemApp.fromUrl( ); ``` -Plain `StemApp` bootstrap infers task queue subscriptions from the bundled -task handlers when `workerConfig.subscription` is omitted. +Plain `StemApp` bootstrap infers task queue subscriptions from the bundled or +explicitly supplied task handlers when `workerConfig.subscription` is omitted, +and it lazy-starts on the first enqueue or wait call. If you already centralize broker/backend wiring in a `StemClient`, prefer the shared-client path: @@ -141,8 +142,8 @@ final workflowApp = await client.createWorkflowApp(); ``` If you reuse an existing `StemApp`, its worker subscription remains your -responsibility. The module-based queue inference only applies when the -workflow app is creating the worker itself. +responsibility. Workflow-side queue inference only applies when the workflow +app is creating the worker itself. ## Parameter and Signature Rules diff --git a/.site/docs/getting-started/first-steps.md b/.site/docs/getting-started/first-steps.md index fbcfae25..8749265a 100644 --- a/.site/docs/getting-started/first-steps.md +++ b/.site/docs/getting-started/first-steps.md @@ -6,8 +6,8 @@ slug: /getting-started/first-steps --- This walkthrough stays in-memory so you can learn the pipeline without running -external services. It defines a task, starts a worker, enqueues a message, then -verifies the result inside a single Dart process. +external services. It defines a task, bootstraps `StemApp`, enqueues a +message, then verifies the result inside a single Dart process. ## 1. Define a task handler @@ -19,7 +19,9 @@ Create a task handler (StemApp will register it for you): ## 2. Bootstrap the in-memory runtime -Use `StemApp` to create the broker, backend, and worker in memory: +Use `StemApp` to create the broker, backend, and worker in memory. The worker +lazy-starts on the first enqueue or wait call, so the common path does not need +an explicit `await app.start()`: ```dart file=/../packages/stem/example/docs_snippets/lib/first_steps.dart#first-steps-bootstrap diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 48a5eb26..186dd5f4 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Made `StemApp` lazy-start its managed worker on first enqueue/wait calls so + in-memory and module-backed task apps no longer need an explicit `start()` + in the common case. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/README.md b/packages/stem/README.md index aaa3e44b..84cb0aff 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -656,8 +656,11 @@ final taskApp = await StemApp.fromUrl( ); ``` -When you bootstrap a plain `StemApp` with `module: stemModule`, the worker -infers task queue subscriptions from the bundled task handlers. Set +`StemApp` lazy-starts its managed worker on the first enqueue or wait call, so +you only need `await taskApp.start()` when you want explicit lifecycle control. + +When you bootstrap a plain `StemApp`, the worker infers task queue +subscriptions from the bundled or explicitly supplied task handlers. Set `workerConfig.subscription` explicitly only when you need broader routing. If your service already owns a `StemApp`, reuse it: @@ -673,7 +676,7 @@ final workflowApp = await client.createWorkflowApp(); ``` If you reuse an existing `StemApp`, its worker subscription stays authoritative. -The module-based queue inference only applies when `StemWorkflowApp` is also +Workflow-side queue inference only applies when `StemWorkflowApp` is also creating the worker. #### Mixing workflows and normal tasks diff --git a/packages/stem/example/docs_snippets/lib/first_steps.dart b/packages/stem/example/docs_snippets/lib/first_steps.dart index 80ecc6e8..fc19c2ca 100644 --- a/packages/stem/example/docs_snippets/lib/first_steps.dart +++ b/packages/stem/example/docs_snippets/lib/first_steps.dart @@ -31,11 +31,10 @@ Future runInMemoryDemo() async { consumerName: 'first-steps-worker', ), ); - await app.start(); // #endregion first-steps-bootstrap // #region first-steps-enqueue - final taskId = await app.stem.enqueue( + final taskId = await app.enqueue( 'email.send', args: {'to': 'hello@example.com'}, ); @@ -43,7 +42,7 @@ Future runInMemoryDemo() async { // #endregion first-steps-enqueue // #region first-steps-results - final result = await app.stem.waitForTask(taskId); + final result = await app.waitForTask(taskId); print('Task state: ${result?.status.state} value=${result?.value}'); // #endregion first-steps-results diff --git a/packages/stem/example/docs_snippets/lib/producer.dart b/packages/stem/example/docs_snippets/lib/producer.dart index a07a380b..95450e5c 100644 --- a/packages/stem/example/docs_snippets/lib/producer.dart +++ b/packages/stem/example/docs_snippets/lib/producer.dart @@ -129,15 +129,14 @@ class GenerateReportTask extends TaskHandler { Future enqueueTyped() async { final app = await StemApp.inMemory(tasks: [GenerateReportTask()]); - await app.start(); final taskId = await GenerateReportTask.definition.enqueue( - app.stem, + app, const ReportPayload(reportId: 'monthly-2025-10'), options: const TaskOptions(priority: 5), headers: const {'x-requested-by': 'analytics'}, ); - final result = await app.stem.waitForTask(taskId); + final result = await app.waitForTask(taskId); print(result?.value); await app.close(); } diff --git a/packages/stem/example/docs_snippets/lib/quick_start.dart b/packages/stem/example/docs_snippets/lib/quick_start.dart index 8253ed70..cde6595a 100644 --- a/packages/stem/example/docs_snippets/lib/quick_start.dart +++ b/packages/stem/example/docs_snippets/lib/quick_start.dart @@ -64,24 +64,22 @@ Future main() async { concurrency: 4, ), ); - - unawaited(app.start()); - - final stem = app.stem; // #endregion quickstart-bootstrap // #region quickstart-enqueue - final resizeId = await stem.enqueue( + final resizeId = await app.enqueue( 'media.resize', args: {'file': 'report.png'}, ); - final emailId = await stem.enqueue( + final emailId = await app.enqueue( 'billing.email-receipt', args: {'to': 'alice@example.com'}, options: const TaskOptions(priority: 10), - notBefore: DateTime.now().add(const Duration(seconds: 5)), meta: {'orderId': 4242}, + enqueueOptions: const TaskEnqueueOptions( + countdown: Duration(seconds: 5), + ), ); print('Enqueued tasks: resize=$resizeId email=$emailId'); diff --git a/packages/stem/example/docs_snippets/lib/tasks.dart b/packages/stem/example/docs_snippets/lib/tasks.dart index 71131d1a..3f2aadfd 100644 --- a/packages/stem/example/docs_snippets/lib/tasks.dart +++ b/packages/stem/example/docs_snippets/lib/tasks.dart @@ -244,13 +244,12 @@ class MyOtherEncoder extends TaskPayloadEncoder { Future main() async { final app = await StemApp.inMemory(tasks: [EmailTask()]); - await app.start(); - final taskId = await app.stem.enqueue( + final taskId = await app.enqueue( 'email.send', args: {'to': 'demo@example.com'}, ); - final result = await app.stem.waitForTask( + final result = await app.waitForTask( taskId, timeout: const Duration(seconds: 5), ); diff --git a/packages/stem/lib/src/bootstrap/stem_app.dart b/packages/stem/lib/src/bootstrap/stem_app.dart index 218b795b..bf044427 100644 --- a/packages/stem/lib/src/bootstrap/stem_app.dart +++ b/packages/stem/lib/src/bootstrap/stem_app.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:stem/src/backend/encoding_result_backend.dart'; import 'package:stem/src/bootstrap/factories.dart'; import 'package:stem/src/bootstrap/stem_client.dart'; @@ -58,6 +60,9 @@ class StemApp implements StemTaskApp { final List Function()> _disposers; bool _started = false; + Future? _startFuture; + + Future _ensureStarted() => _started ? Future.value() : start(); /// Registers an additional task handler with the underlying registry. void register(TaskHandler handler) => registry.register(handler); @@ -70,7 +75,8 @@ class StemApp implements StemTaskApp { TaskOptions options = const TaskOptions(), Map meta = const {}, TaskEnqueueOptions? enqueueOptions, - }) { + }) async { + await _ensureStarted(); return stem.enqueue( name, args: args, @@ -85,7 +91,8 @@ class StemApp implements StemTaskApp { Future enqueueCall( TaskCall call, { TaskEnqueueOptions? enqueueOptions, - }) { + }) async { + await _ensureStarted(); return stem.enqueueCall(call, enqueueOptions: enqueueOptions); } @@ -94,7 +101,8 @@ class StemApp implements StemTaskApp { String taskId, { Duration? timeout, TResult Function(Object? payload)? decode, - }) { + }) async { + await _ensureStarted(); return stem.waitForTask(taskId, timeout: timeout, decode: decode); } @@ -106,7 +114,8 @@ class StemApp implements StemTaskApp { String taskId, TaskDefinition definition, { Duration? timeout, - }) { + }) async { + await _ensureStarted(); return stem.waitForTaskDefinition(taskId, definition, timeout: timeout); } @@ -123,8 +132,24 @@ class StemApp implements StemTaskApp { /// Starts the managed worker if it is not already running. Future start() async { if (_started) return; - _started = true; - await worker.start(); + final existing = _startFuture; + if (existing != null) { + await existing; + return; + } + + final completer = Completer(); + _startFuture = completer.future; + try { + await worker.start(); + _started = true; + completer.complete(); + } catch (error, stackTrace) { + _startFuture = null; + _started = false; + completer.completeError(error, stackTrace); + rethrow; + } } /// Shuts down the worker and disposes any managed resources. @@ -133,6 +158,7 @@ class StemApp implements StemTaskApp { await disposer(); } _started = false; + _startFuture = null; } /// Alias for [shutdown]. @@ -204,7 +230,13 @@ class StemApp implements StemTaskApp { module?.inferTaskWorkerSubscription( defaultQueue: workerConfig.queue, additionalTasks: tasks, - ); + ) ?? + (() { + final tempModule = StemModule(tasks: tasks); + return tempModule.inferTaskWorkerSubscription( + defaultQueue: workerConfig.queue, + ); + })(); final worker = Worker( broker: brokerInstance, @@ -410,7 +442,13 @@ class StemApp implements StemTaskApp { module?.inferTaskWorkerSubscription( defaultQueue: workerConfig.queue, additionalTasks: tasks, - ); + ) ?? + (() { + final tempModule = StemModule(tasks: tasks); + return tempModule.inferTaskWorkerSubscription( + defaultQueue: workerConfig.queue, + ); + })(); final worker = Worker( broker: client.broker, diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index 80bf0389..4a326400 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -14,9 +14,7 @@ void main() { final app = await StemApp.inMemory(tasks: [handler]); try { - await app.start(); - - final taskId = await app.stem.enqueue('test.echo'); + final taskId = await app.enqueue('test.echo'); final completed = await app.backend .watch(taskId) .firstWhere((status) => status.state == TaskState.succeeded) @@ -27,6 +25,26 @@ void main() { } }); + test('inMemory lazy-starts on first enqueue', () async { + final handler = FunctionTaskHandler( + name: 'test.lazy-start', + entrypoint: (context, args) async => 'started', + runInIsolate: false, + ); + + final app = await StemApp.inMemory(tasks: [handler]); + try { + final taskId = await app.enqueue('test.lazy-start'); + final completed = await app.waitForTask( + taskId, + timeout: const Duration(seconds: 2), + ); + expect(completed?.value, 'started'); + } finally { + await app.shutdown(); + } + }); + test( 'inMemory registers module tasks and infers queued subscriptions', () async { @@ -44,13 +62,11 @@ void main() { expect(app.registry.resolve('test.module.queue'), same(handler)); expect(app.worker.subscription.queues, ['priority']); - await app.start(); - - final taskId = await app.stem.enqueue( + final taskId = await app.enqueue( 'test.module.queue', enqueueOptions: const TaskEnqueueOptions(queue: 'priority'), ); - final completed = await app.stem.waitForTask( + final completed = await app.waitForTask( taskId, timeout: const Duration(seconds: 2), ); @@ -61,6 +77,32 @@ void main() { }, ); + test('inMemory infers queued subscriptions from explicit tasks', () async { + final handler = FunctionTaskHandler( + name: 'test.explicit.queue', + options: const TaskOptions(queue: 'priority'), + entrypoint: (context, args) async => 'explicit-ok', + runInIsolate: false, + ); + + final app = await StemApp.inMemory(tasks: [handler]); + try { + expect(app.worker.subscription.queues, ['priority']); + + final taskId = await app.enqueue( + 'test.explicit.queue', + enqueueOptions: const TaskEnqueueOptions(queue: 'priority'), + ); + final completed = await app.waitForTask( + taskId, + timeout: const Duration(seconds: 2), + ); + expect(completed?.value, 'explicit-ok'); + } finally { + await app.shutdown(); + } + }); + test('inMemory applies worker config overrides', () async { final handler = FunctionTaskHandler( name: 'test.worker-config', @@ -176,8 +218,7 @@ void main() { tasks: [handler], ); try { - await app.start(); - final taskId = await app.stem.enqueue('test.from-url'); + final taskId = await app.enqueue('test.from-url'); final completed = await app.backend .watch(taskId) .firstWhere((status) => status.state == TaskState.succeeded) diff --git a/packages/stem/test/bootstrap/stem_client_test.dart b/packages/stem/test/bootstrap/stem_client_test.dart index 1de9b2ab..8898676f 100644 --- a/packages/stem/test/bootstrap/stem_client_test.dart +++ b/packages/stem/test/bootstrap/stem_client_test.dart @@ -74,7 +74,6 @@ void main() { ); final app = await client.createApp(); - await app.start(); expect( app.registry.resolve('client.default-module.app-task'), @@ -82,11 +81,11 @@ void main() { ); expect(app.worker.subscription.queues, ['priority']); - final taskId = await app.stem.enqueue( + final taskId = await app.enqueue( 'client.default-module.app-task', enqueueOptions: const TaskEnqueueOptions(queue: 'priority'), ); - final result = await app.stem.waitForTask( + final result = await app.waitForTask( taskId, timeout: const Duration(seconds: 2), ); @@ -98,6 +97,61 @@ void main() { }, ); + test('StemClient createApp lazy-starts on first enqueue', () async { + final client = await StemClient.inMemory( + tasks: [ + FunctionTaskHandler( + name: 'client.lazy-start', + entrypoint: (context, args) async => 'task-ok', + runInIsolate: false, + ), + ], + ); + + final app = await client.createApp(); + + final taskId = await app.enqueue('client.lazy-start'); + final result = await app.waitForTask( + taskId, + timeout: const Duration(seconds: 2), + ); + + expect(result?.value, 'task-ok'); + + await app.close(); + await client.close(); + }); + + test('StemClient createApp infers queues from explicit tasks', () async { + final client = await StemClient.inMemory(); + final app = await client.createApp( + tasks: [ + FunctionTaskHandler( + name: 'client.explicit.queue', + options: const TaskOptions(queue: 'priority'), + entrypoint: (context, args) async => 'task-ok', + runInIsolate: false, + ), + ], + ); + + expect(app.worker.subscription.queues, ['priority']); + + final taskId = await app.enqueue( + 'client.explicit.queue', + enqueueOptions: const TaskEnqueueOptions(queue: 'priority'), + ); + final result = await app.waitForTask( + taskId, + timeout: const Duration(seconds: 2), + ); + + expect(result?.value, 'task-ok'); + + await app.close(); + await client.close(); + }); + test( 'StemClient createApp registers module tasks and infers queues', () async { @@ -112,16 +166,15 @@ void main() { final app = await client.createApp( module: StemModule(tasks: [moduleTask]), ); - await app.start(); expect(app.registry.resolve('client.module.app-task'), same(moduleTask)); expect(app.worker.subscription.queues, ['priority']); - final taskId = await app.stem.enqueue( + final taskId = await app.enqueue( 'client.module.app-task', enqueueOptions: const TaskEnqueueOptions(queue: 'priority'), ); - final result = await app.stem.waitForTask( + final result = await app.waitForTask( taskId, timeout: const Duration(seconds: 2), ); diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index 445c8ac2..0e49e11c 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -233,7 +233,9 @@ final taskApp = await StemApp.fromUrl( ``` Plain `StemApp` bootstrap also infers task queue subscriptions from the -bundled task handlers when `workerConfig.subscription` is omitted. +bundled or explicitly supplied task handlers when +`workerConfig.subscription` is omitted, and it lazy-starts on the first +enqueue or wait call. If you already centralize wiring in a `StemClient`, prefer the shared-client path: @@ -249,8 +251,8 @@ final workflowApp = await client.createWorkflowApp(); ``` If you reuse an existing `StemApp`, its worker subscription stays in charge. -The module-based queue inference only applies when `StemWorkflowApp` is -creating the worker for you. +Workflow-side queue inference only applies when `StemWorkflowApp` is creating +the worker for you. The generated workflow refs work on `WorkflowRuntime` too: diff --git a/packages/stem_builder/example/bin/main.dart b/packages/stem_builder/example/bin/main.dart index da4403b8..a7bff21c 100644 --- a/packages/stem_builder/example/bin/main.dart +++ b/packages/stem_builder/example/bin/main.dart @@ -43,10 +43,9 @@ Future main() async { final taskApp = await StemApp.inMemory(module: stemModule); try { - await taskApp.start(); final taskResult = await StemTaskDefinitions.builderExamplePing .enqueueAndWait( - taskApp.stem, + taskApp, timeout: const Duration(seconds: 2), ); print('\nNo-arg task result: ${taskResult?.value}'); From 31ccda7f35792aaae6e3978bf4e409d4bbc2c154 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 19:52:24 -0500 Subject: [PATCH 046/302] Lazy-start StemApp canvas dispatch --- .site/docs/core-concepts/canvas.md | 8 ++- packages/stem/CHANGELOG.md | 6 +- packages/stem/README.md | 5 +- .../docs_snippets/lib/canvas_batch.dart | 1 - .../docs_snippets/lib/canvas_chain.dart | 1 - .../docs_snippets/lib/canvas_chord.dart | 1 - .../docs_snippets/lib/canvas_group.dart | 10 ++- packages/stem/lib/src/bootstrap/stem_app.dart | 63 ++++++++++++++++++- .../stem/test/bootstrap/stem_app_test.dart | 23 +++++++ 9 files changed, 100 insertions(+), 18 deletions(-) diff --git a/.site/docs/core-concepts/canvas.md b/.site/docs/core-concepts/canvas.md index e4114661..416e31a0 100644 --- a/.site/docs/core-concepts/canvas.md +++ b/.site/docs/core-concepts/canvas.md @@ -9,7 +9,9 @@ This guide walks through Stem's task composition primitives—chains, groups, an chords—using in-memory brokers and backends. Each snippet references a runnable file under `packages/stem/example/docs_snippets/` so you can experiment locally with `dart run`. If you bootstrap with `StemApp`, use `app.canvas` to reuse the -same broker, backend, task handlers, and encoder registry. +same broker, backend, task handlers, and encoder registry. `StemApp` lazy-starts +its managed worker for canvas dispatch too, so the common path does not need an +explicit `await app.start()`. ## Chains @@ -89,8 +91,8 @@ dart run lib/canvas_group.dart dart run lib/canvas_chord.dart ``` -Each script bootstraps a `StemApp` in-memory runtime, starts a worker, and then -uses `app.canvas` for composition. +Each script bootstraps a `StemApp` in-memory runtime and then uses `app.canvas` +for composition. ## Best practices diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 186dd5f4..2760a2f4 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,9 +2,9 @@ ## 0.1.1 -- Made `StemApp` lazy-start its managed worker on first enqueue/wait calls so - in-memory and module-backed task apps no longer need an explicit `start()` - in the common case. +- Made `StemApp` lazy-start its managed worker on first enqueue/wait and + `app.canvas` dispatch calls so in-memory and module-backed task apps no + longer need an explicit `start()` in the common case. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/README.md b/packages/stem/README.md index 84cb0aff..c4375655 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -656,8 +656,9 @@ final taskApp = await StemApp.fromUrl( ); ``` -`StemApp` lazy-starts its managed worker on the first enqueue or wait call, so -you only need `await taskApp.start()` when you want explicit lifecycle control. +`StemApp` lazy-starts its managed worker on the first enqueue, wait, or +`app.canvas` dispatch call, so you only need `await taskApp.start()` when you +want explicit lifecycle control. When you bootstrap a plain `StemApp`, the worker infers task queue subscriptions from the bundled or explicitly supplied task handlers. Set diff --git a/packages/stem/example/docs_snippets/lib/canvas_batch.dart b/packages/stem/example/docs_snippets/lib/canvas_batch.dart index 3374c6b8..698996b0 100644 --- a/packages/stem/example/docs_snippets/lib/canvas_batch.dart +++ b/packages/stem/example/docs_snippets/lib/canvas_batch.dart @@ -21,7 +21,6 @@ Future main() async { prefetchMultiplier: 1, ), ); - await app.start(); final submission = await app.canvas.submitBatch([ task('batch.double', args: {'value': 1}), diff --git a/packages/stem/example/docs_snippets/lib/canvas_chain.dart b/packages/stem/example/docs_snippets/lib/canvas_chain.dart index 4aa353f8..2bebd54a 100644 --- a/packages/stem/example/docs_snippets/lib/canvas_chain.dart +++ b/packages/stem/example/docs_snippets/lib/canvas_chain.dart @@ -36,7 +36,6 @@ Future main() async { prefetchMultiplier: 1, ), ); - await app.start(); final canvas = app.canvas; final chainResult = await canvas.chain([ diff --git a/packages/stem/example/docs_snippets/lib/canvas_chord.dart b/packages/stem/example/docs_snippets/lib/canvas_chord.dart index eb19d664..ed701572 100644 --- a/packages/stem/example/docs_snippets/lib/canvas_chord.dart +++ b/packages/stem/example/docs_snippets/lib/canvas_chord.dart @@ -36,7 +36,6 @@ Future main() async { prefetchMultiplier: 1, ), ); - await app.start(); final canvas = app.canvas; final chordResult = await canvas.chord( diff --git a/packages/stem/example/docs_snippets/lib/canvas_group.dart b/packages/stem/example/docs_snippets/lib/canvas_group.dart index 464d0977..69ed7fbf 100644 --- a/packages/stem/example/docs_snippets/lib/canvas_group.dart +++ b/packages/stem/example/docs_snippets/lib/canvas_group.dart @@ -24,22 +24,20 @@ Future main() async { prefetchMultiplier: 1, ), ); - await app.start(); final canvas = app.canvas; - const groupHandle = 'squares-demo'; - await canvas.group([ + final dispatch = await canvas.group([ task('square', args: {'value': 2}), task('square', args: {'value': 3}), task('square', args: {'value': 4}), - ], groupId: groupHandle); + ]); await _waitFor(() async { - final status = await app.backend.getGroup(groupHandle); + final status = await app.backend.getGroup(dispatch.groupId); return status?.results.length == 3; }); - final groupStatus = await app.backend.getGroup(groupHandle); + final groupStatus = await app.backend.getGroup(dispatch.groupId); final values = groupStatus?.results.values.map((s) => s.payload).toList(); print('Group results: $values'); diff --git a/packages/stem/lib/src/bootstrap/stem_app.dart b/packages/stem/lib/src/bootstrap/stem_app.dart index bf044427..d5b03afd 100644 --- a/packages/stem/lib/src/bootstrap/stem_app.dart +++ b/packages/stem/lib/src/bootstrap/stem_app.dart @@ -31,11 +31,12 @@ class StemApp implements StemTaskApp { required this.worker, required List Function()> disposers, }) : _disposers = disposers { - canvas = Canvas( + canvas = _ManagedCanvas( broker: broker, backend: backend, registry: registry, encoderRegistry: stem.payloadEncoders, + onBeforeDispatch: _ensureStarted, ); } @@ -492,3 +493,63 @@ class StemApp implements StemTaskApp { ); } } + +class _ManagedCanvas extends Canvas { + _ManagedCanvas({ + required super.broker, + required super.backend, + required super.registry, + required super.encoderRegistry, + required Future Function() onBeforeDispatch, + }) : _onBeforeDispatch = onBeforeDispatch; + + final Future Function() _onBeforeDispatch; + + @override + Future send(TaskSignature signature) async { + await _onBeforeDispatch(); + return super.send(signature); + } + + @override + Future> group( + List> signatures, { + String? groupId, + }) async { + await _onBeforeDispatch(); + return super.group(signatures, groupId: groupId); + } + + @override + Future submitBatch( + List> signatures, { + String? batchId, + Duration? ttl, + }) async { + await _onBeforeDispatch(); + return super.submitBatch(signatures, batchId: batchId, ttl: ttl); + } + + @override + Future> chain( + List> signatures, { + void Function(int index, TaskStatus status, T? value)? onStepCompleted, + }) async { + await _onBeforeDispatch(); + return super.chain(signatures, onStepCompleted: onStepCompleted); + } + + @override + Future> chord({ + required List> body, + required TaskSignature callback, + Duration pollInterval = const Duration(milliseconds: 100), + }) async { + await _onBeforeDispatch(); + return super.chord( + body: body, + callback: callback, + pollInterval: pollInterval, + ); + } +} diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index 4a326400..96af1259 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -103,6 +103,29 @@ void main() { } }); + test('inMemory lazy-starts for canvas dispatch', () async { + final handler = FunctionTaskHandler( + name: 'test.canvas.double', + entrypoint: (context, args) async { + final value = args['value'] as int? ?? 0; + return value * 2; + }, + runInIsolate: false, + ); + + final app = await StemApp.inMemory(tasks: [handler]); + try { + final result = await app.canvas.chain([ + task('test.canvas.double', args: {'value': 21}), + ]); + + expect(result.isCompleted, isTrue); + expect(result.value, 42); + } finally { + await app.shutdown(); + } + }); + test('inMemory applies worker config overrides', () async { final handler = FunctionTaskHandler( name: 'test.worker-config', From ee430cfe25f78ca6c7bde7f77b537ee1c401c5d9 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 19:54:48 -0500 Subject: [PATCH 047/302] Infer task queues in StemClient workers --- packages/stem/CHANGELOG.md | 3 + packages/stem/README.md | 3 + .../stem/lib/src/bootstrap/stem_client.dart | 18 ++++- .../stem/test/bootstrap/stem_client_test.dart | 66 +++++++++++++++++++ 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 2760a2f4..3cb5abd1 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -5,6 +5,9 @@ - Made `StemApp` lazy-start its managed worker on first enqueue/wait and `app.canvas` dispatch calls so in-memory and module-backed task apps no longer need an explicit `start()` in the common case. +- Made `StemClient.createWorker(...)` infer queue subscriptions from bundled + or explicitly supplied task handlers when `workerConfig.subscription` is + omitted. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/README.md b/packages/stem/README.md index c4375655..2137ca94 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -72,6 +72,9 @@ Future main() async { } ``` +`StemClient.createWorker(...)` infers queue subscriptions from the bundled or +explicitly supplied task handlers when `workerConfig.subscription` is omitted. + For persistent adapters, keep `StemClient` as the entrypoint and resolve broker/backend wiring from a URL: diff --git a/packages/stem/lib/src/bootstrap/stem_client.dart b/packages/stem/lib/src/bootstrap/stem_client.dart index 530e0fa9..2fe30b9e 100644 --- a/packages/stem/lib/src/bootstrap/stem_client.dart +++ b/packages/stem/lib/src/bootstrap/stem_client.dart @@ -225,7 +225,21 @@ abstract class StemClient implements TaskResultCaller { Iterable> tasks = const [], }) async { final config = workerConfig ?? defaultWorkerConfig; - tasks.forEach(taskRegistry.register); + final bundledTasks = module?.tasks ?? const >[]; + final allTasks = [...bundledTasks, ...tasks]; + registerModuleTaskHandlers(taskRegistry, allTasks); + final inferredSubscription = + config.subscription ?? + module?.inferTaskWorkerSubscription( + defaultQueue: config.queue, + additionalTasks: tasks, + ) ?? + (() { + final tempModule = StemModule(tasks: tasks); + return tempModule.inferTaskWorkerSubscription( + defaultQueue: config.queue, + ); + })(); return Worker( broker: broker, registry: taskRegistry, @@ -238,7 +252,7 @@ abstract class StemClient implements TaskResultCaller { config.uniqueTaskCoordinator ?? uniqueTaskCoordinator, retryStrategy: config.retryStrategy ?? retryStrategy, queue: config.queue, - subscription: config.subscription, + subscription: inferredSubscription, consumerName: config.consumerName, concurrency: config.concurrency, prefetchMultiplier: config.prefetchMultiplier, diff --git a/packages/stem/test/bootstrap/stem_client_test.dart b/packages/stem/test/bootstrap/stem_client_test.dart index 8898676f..ae2a4cfa 100644 --- a/packages/stem/test/bootstrap/stem_client_test.dart +++ b/packages/stem/test/bootstrap/stem_client_test.dart @@ -362,4 +362,70 @@ void main() { await client.close(); } }); + + test('StemClient createWorker infers queues from explicit tasks', () async { + final client = await StemClient.inMemory(); + final worker = await client.createWorker( + tasks: [ + FunctionTaskHandler( + name: 'client.worker.explicit.queue', + options: const TaskOptions(queue: 'priority'), + entrypoint: (context, args) async => 'task-ok', + runInIsolate: false, + ), + ], + ); + + expect(worker.subscription.queues, ['priority']); + + await worker.start(); + try { + final taskId = await client.enqueue( + 'client.worker.explicit.queue', + enqueueOptions: const TaskEnqueueOptions(queue: 'priority'), + ); + final result = await client.waitForTask( + taskId, + timeout: const Duration(seconds: 2), + ); + expect(result?.value, 'task-ok'); + } finally { + await worker.shutdown(); + await client.close(); + } + }); + + test('StemClient createWorker infers queues from default module', () async { + final client = await StemClient.inMemory( + module: StemModule( + tasks: [ + FunctionTaskHandler( + name: 'client.worker.default-module.queue', + options: const TaskOptions(queue: 'priority'), + entrypoint: (context, args) async => 'task-ok', + runInIsolate: false, + ), + ], + ), + ); + final worker = await client.createWorker(); + + expect(worker.subscription.queues, ['priority']); + + await worker.start(); + try { + final taskId = await client.enqueue( + 'client.worker.default-module.queue', + enqueueOptions: const TaskEnqueueOptions(queue: 'priority'), + ); + final result = await client.waitForTask( + taskId, + timeout: const Duration(seconds: 2), + ); + expect(result?.value, 'task-ok'); + } finally { + await worker.shutdown(); + await client.close(); + } + }); } From c1c49e681424e2e47625ac669bb8d007d50eaece Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:04:14 -0500 Subject: [PATCH 048/302] Respect handler defaults in raw task enqueue --- packages/stem/CHANGELOG.md | 3 + packages/stem/lib/src/core/stem.dart | 66 +++++++++-- .../stem/test/unit/core/stem_core_test.dart | 109 +++++++++++++++++- 3 files changed, 164 insertions(+), 14 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 3cb5abd1..2e7b98b3 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -8,6 +8,9 @@ - Made `StemClient.createWorker(...)` infer queue subscriptions from bundled or explicitly supplied task handlers when `workerConfig.subscription` is omitted. +- Made raw `Stem.enqueue('task.name')` inherit handler-declared publish defaults + like queue routing, priority, visibility timeout, and retry policy when the + producer does not override them explicitly. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/lib/src/core/stem.dart b/packages/stem/lib/src/core/stem.dart index c9c05a98..259d520c 100644 --- a/packages/stem/lib/src/core/stem.dart +++ b/packages/stem/lib/src/core/stem.dart @@ -243,13 +243,17 @@ class Stem implements TaskResultCaller { required TaskPayloadEncoder argsEncoder, required TaskPayloadEncoder resultEncoder, }) async { + final effectiveOptions = _resolveEffectiveTaskOptions( + options, + fallbackOptions, + ); final tracer = StemTracer.instance; - final queueOverride = enqueueOptions?.queue ?? options.queue; + final queueOverride = enqueueOptions?.queue ?? effectiveOptions.queue; final decision = routing.resolve( RouteRequest(task: name, headers: headers, queue: queueOverride), ); final targetName = decision.targetName; - final basePriority = enqueueOptions?.priority ?? options.priority; + final basePriority = enqueueOptions?.priority ?? effectiveOptions.priority; final resolvedPriority = decision.effectivePriority(basePriority); final scopeMeta = TaskEnqueueScope.currentMeta(); final mergedMeta = scopeMeta == null @@ -265,9 +269,9 @@ class Stem implements TaskResultCaller { if (!enrichedMeta.containsKey('stem.task')) { enrichedMeta['stem.task'] = name; } - if (options.retryPolicy != null && + if (effectiveOptions.retryPolicy != null && !enrichedMeta.containsKey('stem.retryPolicy')) { - enrichedMeta['stem.retryPolicy'] = options.retryPolicy!.toJson(); + enrichedMeta['stem.retryPolicy'] = effectiveOptions.retryPolicy!.toJson(); } final scheduledAt = _resolveNotBefore( @@ -275,8 +279,7 @@ class Stem implements TaskResultCaller { enqueueOptions, ); final maxRetries = _resolveMaxRetries( - options, - fallbackOptions, + effectiveOptions, enqueueOptions, ); final taskId = enqueueOptions?.taskId ?? generateEnvelopeId(); @@ -336,11 +339,11 @@ class Stem implements TaskResultCaller { notBefore: scheduledAt, priority: resolvedPriority, maxRetries: maxRetries, - visibilityTimeout: options.visibilityTimeout, + visibilityTimeout: effectiveOptions.visibilityTimeout, meta: encodedMeta, ); - if (options.unique) { + if (effectiveOptions.unique) { final coordinator = uniqueTaskCoordinator; if (coordinator == null) { throw StateError( @@ -350,7 +353,7 @@ class Stem implements TaskResultCaller { } final claim = await coordinator.acquire( envelope: envelope, - options: options, + options: effectiveOptions, ); if (!claim.isAcquired) { final existingId = claim.existingTaskId; @@ -448,6 +451,48 @@ class Stem implements TaskResultCaller { ); } + TaskOptions _resolveEffectiveTaskOptions( + TaskOptions options, + TaskOptions fallbackOptions, + ) { + const defaults = TaskOptions(); + return TaskOptions( + queue: options.queue != defaults.queue + ? options.queue + : fallbackOptions.queue, + maxRetries: options.maxRetries != defaults.maxRetries + ? options.maxRetries + : fallbackOptions.maxRetries, + softTimeLimit: options.softTimeLimit ?? fallbackOptions.softTimeLimit, + hardTimeLimit: options.hardTimeLimit ?? fallbackOptions.hardTimeLimit, + rateLimit: options.rateLimit ?? fallbackOptions.rateLimit, + groupRateLimit: options.groupRateLimit ?? fallbackOptions.groupRateLimit, + groupRateKey: options.groupRateKey ?? fallbackOptions.groupRateKey, + groupRateKeyHeader: + options.groupRateKeyHeader != defaults.groupRateKeyHeader + ? options.groupRateKeyHeader + : fallbackOptions.groupRateKeyHeader, + groupRateLimiterFailureMode: + options.groupRateLimiterFailureMode != + defaults.groupRateLimiterFailureMode + ? options.groupRateLimiterFailureMode + : fallbackOptions.groupRateLimiterFailureMode, + unique: options.unique != defaults.unique + ? options.unique + : fallbackOptions.unique, + uniqueFor: options.uniqueFor ?? fallbackOptions.uniqueFor, + priority: options.priority != defaults.priority + ? options.priority + : fallbackOptions.priority, + acksLate: options.acksLate != defaults.acksLate + ? options.acksLate + : fallbackOptions.acksLate, + visibilityTimeout: + options.visibilityTimeout ?? fallbackOptions.visibilityTimeout, + retryPolicy: options.retryPolicy ?? fallbackOptions.retryPolicy, + ); + } + /// Waits for [taskId] to reach a terminal state and returns a typed view of /// the final [TaskStatus]. Requires [backend] to be configured; otherwise a /// [StateError] is thrown. @@ -591,7 +636,6 @@ class Stem implements TaskResultCaller { /// handler defaults. int _resolveMaxRetries( TaskOptions options, - TaskOptions handlerOptions, TaskEnqueueOptions? enqueueOptions, ) { final policyMax = enqueueOptions?.retryPolicy?.maxRetries; @@ -605,7 +649,7 @@ class Stem implements TaskResultCaller { if (options.maxRetries != 0) { return options.maxRetries; } - return handlerOptions.maxRetries; + return 0; } /// Maps enqueue-only settings into envelope metadata. diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index ef2bfb47..a0899cbd 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -63,7 +63,7 @@ void main() { final stem = Stem( broker: broker, backend: backend, - tasks: [_StubTaskHandler()], + tasks: [const _StubTaskHandler()], ); final id = await stem.enqueue( @@ -208,6 +208,100 @@ void main() { }, ); + test('uses handler default queue when raw enqueue omits options', () async { + final broker = _RecordingBroker(); + final stem = Stem( + broker: broker, + tasks: [ + const _StubTaskHandler( + options: TaskOptions(queue: 'emails'), + ), + ], + ); + + await stem.enqueue('sample.task', args: {'value': 'ok'}); + + expect(broker.published.single.envelope.queue, 'emails'); + }); + + test( + 'uses handler publish defaults for priority visibility and retry policy', + () async { + final broker = _RecordingBroker(); + final backend = _RecordingBackend(); + final stem = Stem( + broker: broker, + backend: backend, + tasks: [ + const _StubTaskHandler( + options: TaskOptions( + queue: 'emails', + priority: 7, + visibilityTimeout: Duration(seconds: 45), + retryPolicy: TaskRetryPolicy(maxRetries: 9), + ), + ), + ], + ); + + await stem.enqueue('sample.task', args: {'value': 'ok'}); + + expect(broker.published.single.envelope.queue, 'emails'); + expect(broker.published.single.envelope.priority, 7); + expect( + broker.published.single.envelope.visibilityTimeout, + const Duration(seconds: 45), + ); + expect(broker.published.single.envelope.maxRetries, 9); + expect( + backend.records.single.meta['stem.retryPolicy'], + containsPair('maxRetries', 9), + ); + }, + ); + + test('explicit task options override handler defaults', () async { + final broker = _RecordingBroker(); + final stem = Stem( + broker: broker, + tasks: [ + const _StubTaskHandler( + options: TaskOptions(queue: 'emails', priority: 7), + ), + ], + ); + + await stem.enqueue( + 'sample.task', + args: {'value': 'ok'}, + options: const TaskOptions(queue: 'custom', priority: 3), + ); + + expect(broker.published.single.envelope.queue, 'custom'); + expect(broker.published.single.envelope.priority, 3); + }); + + test('enqueue options override handler routing defaults', () async { + final broker = _RecordingBroker(); + final stem = Stem( + broker: broker, + tasks: [ + const _StubTaskHandler( + options: TaskOptions(queue: 'emails', priority: 7), + ), + ], + ); + + await stem.enqueue( + 'sample.task', + args: {'value': 'ok'}, + enqueueOptions: const TaskEnqueueOptions(queue: 'audit', priority: 5), + ); + + expect(broker.published.single.envelope.queue, 'audit'); + expect(broker.published.single.envelope.priority, 5); + }); + test( 'no-arg task definitions can attach codec-backed result metadata', () async { @@ -530,14 +624,23 @@ _CodecTaskArgs _decodeCodecTaskArgs(Object? payload) { } class _StubTaskHandler implements TaskHandler { + const _StubTaskHandler({ + TaskOptions options = const TaskOptions(), + TaskMetadata metadata = const TaskMetadata(), + }) : _taskOptions = options, + _taskMetadata = metadata; + + final TaskOptions _taskOptions; + final TaskMetadata _taskMetadata; + @override String get name => 'sample.task'; @override - TaskOptions get options => const TaskOptions(); + TaskOptions get options => _taskOptions; @override - TaskMetadata get metadata => const TaskMetadata(); + TaskMetadata get metadata => _taskMetadata; @override TaskEntrypoint? get isolateEntrypoint => null; From 0e1c6c8bee34d4bea4fe21d5bdc2851e5d0adf45 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:05:43 -0500 Subject: [PATCH 049/302] Simplify lazy-start example bootstrap --- packages/stem/CHANGELOG.md | 3 +++ packages/stem/example/annotated_workflows/bin/main.dart | 1 - packages/stem/example/docs_snippets/lib/producer.dart | 3 ++- .../example/docs_snippets/lib/quick_start_failure.dart | 5 ++--- .../stem/example/docs_snippets/lib/rate_limiting.dart | 8 ++------ .../stem/example/docs_snippets/lib/retry_backoff.dart | 5 ++--- packages/stem/example/docs_snippets/lib/signals.dart | 3 +-- .../stem/example/docs_snippets/lib/troubleshooting.dart | 3 +-- packages/stem/example/docs_snippets/lib/workflows.dart | 2 -- 9 files changed, 13 insertions(+), 20 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 2e7b98b3..549b063d 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -11,6 +11,9 @@ - Made raw `Stem.enqueue('task.name')` inherit handler-declared publish defaults like queue routing, priority, visibility timeout, and retry policy when the producer does not override them explicitly. +- Updated the public snippets and annotated workflow example to use the + high-level app surfaces directly, dropping unnecessary `start()` calls and + `app.stem` hops in the common in-memory and workflow happy paths. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/annotated_workflows/bin/main.dart b/packages/stem/example/annotated_workflows/bin/main.dart index 45a8b6ed..37440496 100644 --- a/packages/stem/example/annotated_workflows/bin/main.dart +++ b/packages/stem/example/annotated_workflows/bin/main.dart @@ -6,7 +6,6 @@ import 'package:stem_annotated_workflows/definitions.dart'; Future main() async { final client = await StemClient.inMemory(); final app = await client.createWorkflowApp(module: stemModule); - await app.start(); final flowRunId = await StemWorkflowDefinitions.flow.startWith(app); final flowResult = await StemWorkflowDefinitions.flow.waitFor( diff --git a/packages/stem/example/docs_snippets/lib/producer.dart b/packages/stem/example/docs_snippets/lib/producer.dart index 95450e5c..ea2b4034 100644 --- a/packages/stem/example/docs_snippets/lib/producer.dart +++ b/packages/stem/example/docs_snippets/lib/producer.dart @@ -21,10 +21,11 @@ Future enqueueInMemory() async { ], ); - final taskId = await app.stem.enqueue( + final taskId = await app.enqueue( 'hello.print', args: {'name': 'Stem'}, ); + await app.waitForTask(taskId); print('Enqueued $taskId'); await app.close(); diff --git a/packages/stem/example/docs_snippets/lib/quick_start_failure.dart b/packages/stem/example/docs_snippets/lib/quick_start_failure.dart index e6d51906..2c8f2746 100644 --- a/packages/stem/example/docs_snippets/lib/quick_start_failure.dart +++ b/packages/stem/example/docs_snippets/lib/quick_start_failure.dart @@ -28,13 +28,12 @@ class EmailReceiptTask extends TaskHandler { Future main() async { final app = await StemApp.inMemory(tasks: [EmailReceiptTask()]); - await app.start(); - final taskId = await app.stem.enqueue( + final taskId = await app.enqueue( 'billing.email-receipt', args: {'to': 'demo@example.com'}, ); - final result = await app.stem.waitForTask( + final result = await app.waitForTask( taskId, timeout: const Duration(seconds: 5), ); diff --git a/packages/stem/example/docs_snippets/lib/rate_limiting.dart b/packages/stem/example/docs_snippets/lib/rate_limiting.dart index 701ec7c3..8d602c13 100644 --- a/packages/stem/example/docs_snippets/lib/rate_limiting.dart +++ b/packages/stem/example/docs_snippets/lib/rate_limiting.dart @@ -92,7 +92,7 @@ class GroupRateLimitedTask extends TaskHandler { // #endregion rate-limit-group-task-options // #region rate-limit-producer -Future enqueueRateLimited(Stem stem) async { +Future enqueueRateLimited(TaskEnqueuer stem) async { return stem.enqueue( 'demo.rateLimited', args: {'actor': 'acme'}, @@ -116,12 +116,8 @@ Future main() async { ); // #endregion rate-limit-demo-registry - // #region rate-limit-demo-worker-start - await app.start(); - // #endregion rate-limit-demo-worker-start - // #region rate-limit-demo-stem - final stem = app.stem; + final stem = app; // #endregion rate-limit-demo-stem // #region rate-limit-demo-enqueue await enqueueRateLimited(stem); diff --git a/packages/stem/example/docs_snippets/lib/retry_backoff.dart b/packages/stem/example/docs_snippets/lib/retry_backoff.dart index 6b2ae3c6..55628342 100644 --- a/packages/stem/example/docs_snippets/lib/retry_backoff.dart +++ b/packages/stem/example/docs_snippets/lib/retry_backoff.dart @@ -64,10 +64,9 @@ Future main() async { tasks: [FlakyTask()], workerConfig: workerConfig, ); - await app.start(); - final taskId = await app.stem.enqueue('demo.flaky'); - await app.stem.waitForTask(taskId, timeout: const Duration(seconds: 5)); + final taskId = await app.enqueue('demo.flaky'); + await app.waitForTask(taskId, timeout: const Duration(seconds: 5)); await app.close(); } diff --git a/packages/stem/example/docs_snippets/lib/signals.dart b/packages/stem/example/docs_snippets/lib/signals.dart index 97f7667f..69c0ff23 100644 --- a/packages/stem/example/docs_snippets/lib/signals.dart +++ b/packages/stem/example/docs_snippets/lib/signals.dart @@ -154,8 +154,7 @@ Future main() async { ), ); - unawaited(app.start()); - await app.stem.enqueue('signals.demo', args: const {}); + await app.enqueue('signals.demo', args: const {}); await Future.delayed(const Duration(milliseconds: 200)); await app.close(); diff --git a/packages/stem/example/docs_snippets/lib/troubleshooting.dart b/packages/stem/example/docs_snippets/lib/troubleshooting.dart index 01ac55c7..ec5be201 100644 --- a/packages/stem/example/docs_snippets/lib/troubleshooting.dart +++ b/packages/stem/example/docs_snippets/lib/troubleshooting.dart @@ -32,11 +32,10 @@ Future runTroubleshootingDemo() async { concurrency: 1, ), ); - unawaited(app.start()); // #endregion troubleshooting-bootstrap // #region troubleshooting-enqueue - final taskId = await app.stem.enqueue( + final taskId = await app.enqueue( 'debug.echo', args: {'message': 'troubleshooting'}, ); diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index 9d47a922..bec973da 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -55,7 +55,6 @@ Future bootstrapWorkflowRuntime() async { Future bootstrapWorkflowClient() async { final client = await StemClient.fromUrl('memory://'); final app = await client.createWorkflowApp(module: stemModule); - await app.start(); await app.close(); await client.close(); } @@ -273,7 +272,6 @@ Future main() async { final demoFlowRef = demoFlow.ref0(); final app = await StemWorkflowApp.inMemory(flows: [demoFlow]); - await app.start(); final runId = await demoFlowRef.startWith(app); final result = await demoFlowRef.waitFor( From de63ff96ebf0c752a88c501f6ec2e9d1b797d054 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:07:18 -0500 Subject: [PATCH 050/302] Use client-owned workflow bundles in examples --- packages/stem/CHANGELOG.md | 3 +++ packages/stem/example/annotated_workflows/bin/main.dart | 4 ++-- packages/stem/example/docs_snippets/lib/workflows.dart | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 549b063d..68b1a48f 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -14,6 +14,9 @@ - Updated the public snippets and annotated workflow example to use the high-level app surfaces directly, dropping unnecessary `start()` calls and `app.stem` hops in the common in-memory and workflow happy paths. +- Updated the client-backed workflow examples to attach `stemModule` at + `StemClient` creation time and then call `createWorkflowApp()` without + repeating the bundle. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/annotated_workflows/bin/main.dart b/packages/stem/example/annotated_workflows/bin/main.dart index 37440496..15b86fed 100644 --- a/packages/stem/example/annotated_workflows/bin/main.dart +++ b/packages/stem/example/annotated_workflows/bin/main.dart @@ -4,8 +4,8 @@ import 'package:stem/stem.dart'; import 'package:stem_annotated_workflows/definitions.dart'; Future main() async { - final client = await StemClient.inMemory(); - final app = await client.createWorkflowApp(module: stemModule); + final client = await StemClient.inMemory(module: stemModule); + final app = await client.createWorkflowApp(); final flowRunId = await StemWorkflowDefinitions.flow.startWith(app); final flowResult = await StemWorkflowDefinitions.flow.waitFor( diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index bec973da..ca894091 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -53,8 +53,8 @@ Future bootstrapWorkflowRuntime() async { // #region workflows-client Future bootstrapWorkflowClient() async { - final client = await StemClient.fromUrl('memory://'); - final app = await client.createWorkflowApp(module: stemModule); + final client = await StemClient.fromUrl('memory://', module: stemModule); + final app = await client.createWorkflowApp(); await app.close(); await client.close(); } From a4abf5735eea78c75b3deb22d28fa95ef6fbd4fa Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:08:20 -0500 Subject: [PATCH 051/302] Simplify stack autowire example bootstrap --- packages/stem/CHANGELOG.md | 3 +++ packages/stem/example/stack_autowire.dart | 5 ++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 68b1a48f..98163d7e 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -17,6 +17,9 @@ - Updated the client-backed workflow examples to attach `stemModule` at `StemClient` creation time and then call `createWorkflowApp()` without repeating the bundle. +- Updated the `stack_autowire` example to use `StemApp` lazy-start and the + high-level `app.enqueue(...)` / `app.waitForTask(...)` surface instead of + manually starting the task app and dropping to `app.stem`. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/stack_autowire.dart b/packages/stem/example/stack_autowire.dart index f1a3a4e2..cf8d3dda 100644 --- a/packages/stem/example/stack_autowire.dart +++ b/packages/stem/example/stack_autowire.dart @@ -61,12 +61,11 @@ Future main() async { ); try { - await app.start(); await workflowApp.start(); await beat.start(); - await app.stem.enqueue('demo.ping'); - await Future.delayed(const Duration(seconds: 1)); + final taskId = await app.enqueue('demo.ping'); + await app.waitForTask(taskId, timeout: const Duration(seconds: 1)); } finally { await beat.stop(); await workflowApp.shutdown(); From 123a90070bb0c8e9c6ab5bf5f181c70f3474b51e Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:10:03 -0500 Subject: [PATCH 052/302] Simplify uniqueness producer example --- packages/stem/CHANGELOG.md | 3 ++ .../example/docs_snippets/lib/uniqueness.dart | 44 ++++++++----------- 2 files changed, 22 insertions(+), 25 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 98163d7e..a6c773ac 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -20,6 +20,9 @@ - Updated the `stack_autowire` example to use `StemApp` lazy-start and the high-level `app.enqueue(...)` / `app.waitForTask(...)` surface instead of manually starting the task app and dropping to `app.stem`. +- Updated the uniqueness snippet to show raw `app.enqueue(...)` inheriting + handler-declared queue and uniqueness defaults, while still demonstrating + explicit uniqueness-key overrides without manual worker startup. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/docs_snippets/lib/uniqueness.dart b/packages/stem/example/docs_snippets/lib/uniqueness.dart index a0a18bec..36473579 100644 --- a/packages/stem/example/docs_snippets/lib/uniqueness.dart +++ b/packages/stem/example/docs_snippets/lib/uniqueness.dart @@ -59,39 +59,29 @@ Future buildRedisCoordinator() async { // #endregion uniqueness-coordinator-redis // #region uniqueness-enqueue -Future enqueueDigest(Stem stem) async { - final firstId = await stem.enqueue( +Future enqueueDigest(TaskEnqueuer enqueuer) async { + final firstId = await enqueuer.enqueue( 'email.sendDigest', args: const {'userId': 42}, - options: const TaskOptions( - queue: 'email', - unique: true, - uniqueFor: Duration(minutes: 15), - ), ); - final secondId = await stem.enqueue( + final secondId = await enqueuer.enqueue( 'email.sendDigest', args: const {'userId': 42}, - options: const TaskOptions( - queue: 'email', - unique: true, - uniqueFor: Duration(minutes: 15), - ), ); print('first enqueue id: $firstId'); print('second enqueue id: $secondId (dup is re-used)'); + return firstId; } // #endregion uniqueness-enqueue // #region uniqueness-override-key -Future enqueueWithOverride(Stem stem) async { - await stem.enqueue( - 'orders.sync', - args: const {'id': 42}, - options: const TaskOptions(unique: true, uniqueFor: Duration(minutes: 10)), - meta: const {UniqueTaskMetadata.override: 'order-42'}, +Future enqueueWithOverride(TaskEnqueuer enqueuer) async { + return enqueuer.enqueue( + 'email.sendDigest', + args: const {'userId': 42}, + meta: const {UniqueTaskMetadata.override: 'digest-override-42'}, ); } // #endregion uniqueness-override-key @@ -110,12 +100,16 @@ Future main() async { ); // #endregion uniqueness-stem-worker - unawaited(app.start()); - - await enqueueDigest(app.stem); - await enqueueWithOverride(app.stem); - - await Future.delayed(const Duration(milliseconds: 500)); + final digestTaskId = await enqueueDigest(app); + await app.waitForTask( + digestTaskId, + timeout: const Duration(seconds: 1), + ); + final overrideTaskId = await enqueueWithOverride(app); + await app.waitForTask( + overrideTaskId, + timeout: const Duration(seconds: 1), + ); await app.close(); } From 4fbf5a89adc2e3a14797223a5b52fbbefb7022ab Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:12:34 -0500 Subject: [PATCH 053/302] Simplify task usage patterns example --- packages/stem/CHANGELOG.md | 3 +++ .../stem/example/task_usage_patterns.dart | 26 ++++++------------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index a6c773ac..b23041cd 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -23,6 +23,9 @@ - Updated the uniqueness snippet to show raw `app.enqueue(...)` inheriting handler-declared queue and uniqueness defaults, while still demonstrating explicit uniqueness-key overrides without manual worker startup. +- Simplified the `task_usage_patterns` example to use `StemApp.inMemory(...)` + instead of manually wiring `Broker`, `Worker`, and `Stem` just to demonstrate + typed task-definition enqueue and wait helpers. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/task_usage_patterns.dart b/packages/stem/example/task_usage_patterns.dart index 982f9259..3c8fe12a 100644 --- a/packages/stem/example/task_usage_patterns.dart +++ b/packages/stem/example/task_usage_patterns.dart @@ -85,26 +85,19 @@ Future main() async { ), ]; - final broker = InMemoryBroker(); - final backend = InMemoryResultBackend(); - final worker = Worker( - broker: broker, - backend: backend, + final app = await StemApp.inMemory( tasks: tasks, - consumerName: 'example-worker', + workerConfig: const StemWorkerConfig(consumerName: 'example-worker'), ); - final stem = Stem(broker: broker, backend: backend, tasks: tasks); - unawaited(worker.start()); - - await stem.enqueue('tasks.parent', args: const {}); - await stem.enqueue('tasks.invocation_parent', args: const {}); + await app.enqueue('tasks.parent', args: const {}); + await app.enqueue('tasks.invocation_parent', args: const {}); final directTaskId = await childDefinition.enqueue( - stem, + app, const ChildArgs('direct-call'), ); final directResult = await childDefinition.waitFor( - stem, + app, directTaskId, timeout: const Duration(seconds: 1), ); @@ -113,7 +106,7 @@ Future main() async { print('[direct] result=${directResult?.value}'); final inlineResult = await childDefinition.enqueueAndWait( - stem, + app, const ChildArgs('inline-wait'), timeout: const Duration(seconds: 1), ); @@ -121,8 +114,5 @@ Future main() async { // ignore: avoid_print print('[inline] result=${inlineResult?.value}'); - await Future.delayed(const Duration(seconds: 1)); - await worker.shutdown(); - await backend.close(); - await broker.close(); + await app.close(); } From 57b7e9af292d481229c4df1af8f1bbcef34853f0 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:13:46 -0500 Subject: [PATCH 054/302] Simplify getting started example bootstrap --- packages/stem/CHANGELOG.md | 3 +++ packages/stem/example/stem_example.dart | 31 +++++++++++++------------ 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index b23041cd..1818c8aa 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -26,6 +26,9 @@ - Simplified the `task_usage_patterns` example to use `StemApp.inMemory(...)` instead of manually wiring `Broker`, `Worker`, and `Stem` just to demonstrate typed task-definition enqueue and wait helpers. +- Simplified `example/stem_example.dart` and the getting-started docs that + embed it to use `StemApp.fromUrl(...)` plus typed wait helpers instead of + manually wiring broker/backend/worker instances. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/stem_example.dart b/packages/stem/example/stem_example.dart index d5c0aaf2..d3d80e93 100644 --- a/packages/stem/example/stem_example.dart +++ b/packages/stem/example/stem_example.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'package:stem/stem.dart'; import 'package:stem_redis/stem_redis.dart'; @@ -47,27 +46,29 @@ class HelloArgs { Future main() async { // #region getting-started-runtime-setup - final broker = await RedisStreamsBroker.connect('redis://localhost:6379'); - final backend = await RedisResultBackend.connect('redis://localhost:6379/1'); - - final stem = Stem(broker: broker, backend: backend, tasks: [HelloTask()]); - final worker = Worker( - broker: broker, - backend: backend, + final app = await StemApp.fromUrl( + 'redis://localhost:6379', tasks: [HelloTask()], + adapters: const [StemRedisAdapter()], + overrides: const StemStoreOverrides(backend: 'redis://localhost:6379/1'), ); // #endregion getting-started-runtime-setup // #region getting-started-enqueue - unawaited(worker.start()); // Map-based enqueue for quick scripts or one-off calls. - await stem.enqueue('demo.hello', args: {'name': 'Stem'}); + final taskId = await app.enqueue('demo.hello', args: {'name': 'Stem'}); + await app.waitForTask(taskId, timeout: const Duration(seconds: 2)); // Typed helper with TaskDefinition for compile-time safety. - await HelloTask.definition(const HelloArgs(name: 'Stem')).enqueue(stem); - await Future.delayed(const Duration(seconds: 1)); - await worker.shutdown(); - await broker.close(); - await backend.close(); + final typedTaskId = await HelloTask.definition.enqueue( + app, + const HelloArgs(name: 'Stem'), + ); + await HelloTask.definition.waitFor( + app, + typedTaskId, + timeout: const Duration(seconds: 2), + ); + await app.close(); // #endregion getting-started-enqueue } From a4086f6a2f82f54c145f086f4813eb93f3bf5981 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:17:28 -0500 Subject: [PATCH 055/302] Use StemClient in Redis producer examples --- packages/stem/CHANGELOG.md | 3 +++ .../stem/example/retry_task/bin/producer.dart | 12 +++++++----- .../example/signals_demo/bin/producer.dart | 18 ++++++++---------- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 1818c8aa..379fad9e 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -29,6 +29,9 @@ - Simplified `example/stem_example.dart` and the getting-started docs that embed it to use `StemApp.fromUrl(...)` plus typed wait helpers instead of manually wiring broker/backend/worker instances. +- Simplified the Redis producer examples in `retry_task` and `signals_demo` to + use `StemClient.fromUrl(...)` instead of manually creating a broker and raw + `Stem` producer just to enqueue tasks. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/retry_task/bin/producer.dart b/packages/stem/example/retry_task/bin/producer.dart index f05e6549..09378fbb 100644 --- a/packages/stem/example/retry_task/bin/producer.dart +++ b/packages/stem/example/retry_task/bin/producer.dart @@ -9,12 +9,14 @@ Future main() async { final brokerUrl = Platform.environment['STEM_BROKER_URL'] ?? 'redis://redis:6379/0'; - final broker = await RedisStreamsBroker.connect(brokerUrl); - final tasks = buildTasks(); final subscriptions = attachLogging('producer'); - final stem = Stem(broker: broker, tasks: tasks); + final client = await StemClient.fromUrl( + brokerUrl, + adapters: const [StemRedisAdapter()], + tasks: buildTasks(), + ); - final taskId = await stem.enqueue( + final taskId = await client.enqueue( 'tasks.always_fail', options: const TaskOptions(maxRetries: 3, queue: 'retry-demo'), meta: const {'maxRetries': 3}, @@ -28,5 +30,5 @@ Future main() async { } await Future.delayed(const Duration(seconds: 1)); - await broker.close(); + await client.close(); } diff --git a/packages/stem/example/signals_demo/bin/producer.dart b/packages/stem/example/signals_demo/bin/producer.dart index abd7ce9a..3a4350a0 100644 --- a/packages/stem/example/signals_demo/bin/producer.dart +++ b/packages/stem/example/signals_demo/bin/producer.dart @@ -12,28 +12,26 @@ Future main() async { registerSignalLogging('producer'); - final broker = await RedisStreamsBroker.connect(brokerUrl); - final tasks = buildTasks(); - final stem = Stem( - broker: broker, - tasks: tasks, - backend: InMemoryResultBackend(), + final client = await StemClient.fromUrl( + brokerUrl, + adapters: const [StemRedisAdapter()], + tasks: buildTasks(), ); final timer = Timer.periodic(const Duration(seconds: 5), (_) async { - await stem.enqueue( + await client.enqueue( 'tasks.hello', args: {'name': 'from-producer'}, ); - await stem.enqueue('tasks.flaky'); - await stem.enqueue('tasks.always_fail'); + await client.enqueue('tasks.flaky'); + await client.enqueue('tasks.always_fail'); }); void scheduleShutdown(ProcessSignal signal) async { // ignore: avoid_print print('[signals][producer] received $signal, shutting down'); timer.cancel(); - await broker.close(); + await client.close(); exit(0); } From 8a5451635b65bd2e0c261c0c5c2c07fe6e426e06 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:18:46 -0500 Subject: [PATCH 056/302] Use StemClient in TLS producer examples --- packages/stem/CHANGELOG.md | 3 +++ .../autoscaling_demo/bin/producer.dart | 20 ++++++++++++------- .../ops_health_suite/bin/producer.dart | 20 ++++++++++++------- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 379fad9e..05f2edf7 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -32,6 +32,9 @@ - Simplified the Redis producer examples in `retry_task` and `signals_demo` to use `StemClient.fromUrl(...)` instead of manually creating a broker and raw `Stem` producer just to enqueue tasks. +- Simplified the TLS-aware `autoscaling_demo` and `ops_health_suite` producer + examples to use `StemClient.create(...)` with broker/backend factories + instead of hand-constructing `Stem` for publishing. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/autoscaling_demo/bin/producer.dart b/packages/stem/example/autoscaling_demo/bin/producer.dart index 567e729a..f9805772 100644 --- a/packages/stem/example/autoscaling_demo/bin/producer.dart +++ b/packages/stem/example/autoscaling_demo/bin/producer.dart @@ -5,10 +5,18 @@ import 'package:stem_autoscaling_demo/shared.dart'; Future main() async { final config = StemConfig.fromEnvironment(); - final broker = await connectBroker(config.brokerUrl, tls: config.tls); final backendUrl = config.resultBackendUrl ?? config.brokerUrl; - final backend = await connectBackend(backendUrl, tls: config.tls); - final tasks = buildTasks(); + final client = await StemClient.create( + broker: StemBrokerFactory( + create: () => connectBroker(config.brokerUrl, tls: config.tls), + dispose: (broker) => broker.close(), + ), + backend: StemBackendFactory( + create: () => connectBackend(backendUrl, tls: config.tls), + dispose: (backend) => backend.close(), + ), + tasks: buildTasks(), + ); final taskCount = _parseInt('TASKS', fallback: 48, min: 1); final burst = _parseInt('BURST', fallback: 12, min: 1); @@ -21,7 +29,6 @@ Future main() async { 'tasks=$taskCount burst=$burst pauseMs=$pauseMs durationMs=$durationMs', ); - final stem = Stem(broker: broker, tasks: tasks, backend: backend); const options = TaskOptions(queue: autoscaleQueue); if (initialDelayMs > 0) { @@ -30,7 +37,7 @@ Future main() async { for (var i = 0; i < taskCount; i += 1) { final label = 'job-${i + 1}'; - final id = await stem.enqueue( + final id = await client.enqueue( 'autoscale.work', options: options, args: {'label': label, 'durationMs': durationMs}, @@ -41,8 +48,7 @@ Future main() async { } } - await broker.close(); - await backend.close(); + await client.close(); } int _parseInt(String key, {required int fallback, int min = 0}) { diff --git a/packages/stem/example/ops_health_suite/bin/producer.dart b/packages/stem/example/ops_health_suite/bin/producer.dart index 8de24d63..40a03420 100644 --- a/packages/stem/example/ops_health_suite/bin/producer.dart +++ b/packages/stem/example/ops_health_suite/bin/producer.dart @@ -5,10 +5,18 @@ import 'package:stem_ops_health_suite/shared.dart'; Future main() async { final config = StemConfig.fromEnvironment(); - final broker = await connectBroker(config.brokerUrl, tls: config.tls); final backendUrl = config.resultBackendUrl ?? config.brokerUrl; - final backend = await connectBackend(backendUrl, tls: config.tls); - final tasks = buildTasks(); + final client = await StemClient.create( + broker: StemBrokerFactory( + create: () => connectBroker(config.brokerUrl, tls: config.tls), + dispose: (broker) => broker.close(), + ), + backend: StemBackendFactory( + create: () => connectBackend(backendUrl, tls: config.tls), + dispose: (backend) => backend.close(), + ), + tasks: buildTasks(), + ); final taskCount = _parseInt('TASKS', fallback: 6, min: 1); final delayMs = _parseInt('DELAY_MS', fallback: 400, min: 0); @@ -17,12 +25,11 @@ Future main() async { '[producer] broker=${config.brokerUrl} backend=$backendUrl tasks=$taskCount', ); - final stem = Stem(broker: broker, tasks: tasks, backend: backend); const options = TaskOptions(queue: opsQueue); for (var i = 0; i < taskCount; i += 1) { final label = 'health-${i + 1}'; - final id = await stem.enqueue( + final id = await client.enqueue( 'ops.ping', options: options, args: {'label': label, 'delayMs': delayMs}, @@ -30,8 +37,7 @@ Future main() async { stdout.writeln('[producer] enqueued $label id=$id'); } - await broker.close(); - await backend.close(); + await client.close(); } int _parseInt(String key, {required int fallback, int min = 0}) { From 00786c28893d625b1ef882494333710173fc01e7 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:19:47 -0500 Subject: [PATCH 057/302] Use StemClient in producer lab examples --- packages/stem/CHANGELOG.md | 3 +++ .../progress_heartbeat/bin/producer.dart | 23 +++++++++-------- .../worker_control_lab/bin/producer.dart | 25 ++++++++++--------- 3 files changed, 28 insertions(+), 23 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 05f2edf7..1cf0895e 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -35,6 +35,9 @@ - Simplified the TLS-aware `autoscaling_demo` and `ops_health_suite` producer examples to use `StemClient.create(...)` with broker/backend factories instead of hand-constructing `Stem` for publishing. +- Simplified the `worker_control_lab` and `progress_heartbeat` producer + examples to use `StemClient.create(...)` with their existing connection + helpers instead of manually wiring `Stem`. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/progress_heartbeat/bin/producer.dart b/packages/stem/example/progress_heartbeat/bin/producer.dart index 7a235f09..1a1d5c15 100644 --- a/packages/stem/example/progress_heartbeat/bin/producer.dart +++ b/packages/stem/example/progress_heartbeat/bin/producer.dart @@ -17,19 +17,21 @@ Future main() async { '[producer] broker=$brokerUrl backend=$backendUrl tasks=$taskCount', ); - final broker = await connectBroker(brokerUrl); - final backend = await connectBackend(backendUrl); - final tasks = buildTasks(); - - final stem = Stem( - broker: broker, - tasks: tasks, - backend: backend, + final client = await StemClient.create( + broker: StemBrokerFactory( + create: () => connectBroker(brokerUrl), + dispose: (broker) => broker.close(), + ), + backend: StemBackendFactory( + create: () => connectBackend(backendUrl), + dispose: (backend) => backend.close(), + ), + tasks: buildTasks(), ); const taskOptions = TaskOptions(queue: progressQueue); for (var i = 0; i < taskCount; i += 1) { - final id = await stem.enqueue( + final id = await client.enqueue( 'progress.demo', options: taskOptions, args: {'steps': steps, 'delayMs': delayMs}, @@ -37,6 +39,5 @@ Future main() async { stdout.writeln('[producer] enqueued progress.demo id=$id'); } - await broker.close(); - await backend.close(); + await client.close(); } diff --git a/packages/stem/example/worker_control_lab/bin/producer.dart b/packages/stem/example/worker_control_lab/bin/producer.dart index 2f64a78c..af95de49 100644 --- a/packages/stem/example/worker_control_lab/bin/producer.dart +++ b/packages/stem/example/worker_control_lab/bin/producer.dart @@ -19,14 +19,16 @@ Future main() async { '[producer] broker=$brokerUrl backend=$backendUrl long=$longCount quick=$quickCount', ); - final broker = await connectBroker(brokerUrl); - final backend = await connectBackend(backendUrl); - final tasks = buildTasks(); - - final stem = Stem( - broker: broker, - tasks: tasks, - backend: backend, + final client = await StemClient.create( + broker: StemBrokerFactory( + create: () => connectBroker(brokerUrl), + dispose: (broker) => broker.close(), + ), + backend: StemBackendFactory( + create: () => connectBackend(backendUrl), + dispose: (backend) => backend.close(), + ), + tasks: buildTasks(), ); final ids = []; @@ -34,7 +36,7 @@ Future main() async { for (var i = 0; i < longCount; i += 1) { final label = 'long-${i + 1}'; - final id = await stem.enqueue( + final id = await client.enqueue( 'control.long', options: taskOptions, args: {'label': label, 'steps': steps}, @@ -45,7 +47,7 @@ Future main() async { for (var i = 0; i < quickCount; i += 1) { final label = 'quick-${i + 1}'; - final id = await stem.enqueue( + final id = await client.enqueue( 'control.quick', options: taskOptions, args: {'label': label}, @@ -60,6 +62,5 @@ Future main() async { stdout.writeln('[producer] wrote ${ids.length} task ids to ${file.path}'); } - await broker.close(); - await backend.close(); + await client.close(); } From afb9e8a00de986a3f8bc215e596e971616e9e2da Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:30:03 -0500 Subject: [PATCH 058/302] Add delayed enqueue support to task enqueuers --- packages/stem/CHANGELOG.md | 3 +++ packages/stem/lib/src/bootstrap/stem_app.dart | 2 ++ .../stem/lib/src/bootstrap/stem_client.dart | 2 ++ .../stem/lib/src/bootstrap/workflow_app.dart | 2 ++ packages/stem/lib/src/core/contracts.dart | 3 +++ .../stem/lib/src/core/task_invocation.dart | 8 +++++++ packages/stem/lib/src/worker/worker.dart | 2 ++ .../lib/src/workflow/core/flow_context.dart | 2 ++ .../core/workflow_script_context.dart | 1 + .../workflow/runtime/workflow_runtime.dart | 4 ++++ .../unit/core/task_context_enqueue_test.dart | 2 ++ .../unit/core/task_enqueue_builder_test.dart | 1 + .../test/unit/core/task_invocation_test.dart | 22 +++++++++++++++++++ .../test/unit/workflow/flow_context_test.dart | 2 ++ .../unit/workflow/workflow_resume_test.dart | 4 ++++ 15 files changed, 60 insertions(+) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 1cf0895e..5c4bb541 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -38,6 +38,9 @@ - Simplified the `worker_control_lab` and `progress_heartbeat` producer examples to use `StemClient.create(...)` with their existing connection helpers instead of manually wiring `Stem`. +- Added `notBefore` support to the shared `TaskEnqueuer` surface so + `StemClient`, `StemApp`, workflow contexts, and task contexts can publish + delayed tasks without dropping down to raw `Stem`. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/lib/src/bootstrap/stem_app.dart b/packages/stem/lib/src/bootstrap/stem_app.dart index d5b03afd..781c336f 100644 --- a/packages/stem/lib/src/bootstrap/stem_app.dart +++ b/packages/stem/lib/src/bootstrap/stem_app.dart @@ -74,6 +74,7 @@ class StemApp implements StemTaskApp { Map args = const {}, Map headers = const {}, TaskOptions options = const TaskOptions(), + DateTime? notBefore, Map meta = const {}, TaskEnqueueOptions? enqueueOptions, }) async { @@ -83,6 +84,7 @@ class StemApp implements StemTaskApp { args: args, headers: headers, options: options, + notBefore: notBefore, meta: meta, enqueueOptions: enqueueOptions, ); diff --git a/packages/stem/lib/src/bootstrap/stem_client.dart b/packages/stem/lib/src/bootstrap/stem_client.dart index 2fe30b9e..91fec50d 100644 --- a/packages/stem/lib/src/bootstrap/stem_client.dart +++ b/packages/stem/lib/src/bootstrap/stem_client.dart @@ -154,6 +154,7 @@ abstract class StemClient implements TaskResultCaller { Map args = const {}, Map headers = const {}, TaskOptions options = const TaskOptions(), + DateTime? notBefore, Map meta = const {}, TaskEnqueueOptions? enqueueOptions, }) { @@ -162,6 +163,7 @@ abstract class StemClient implements TaskResultCaller { args: args, headers: headers, options: options, + notBefore: notBefore, meta: meta, enqueueOptions: enqueueOptions, ); diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index c253c3f4..3922be0c 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -82,6 +82,7 @@ class StemWorkflowApp implements WorkflowCaller, WorkflowEventEmitter, StemTaskA Map args = const {}, Map headers = const {}, TaskOptions options = const TaskOptions(), + DateTime? notBefore, Map meta = const {}, TaskEnqueueOptions? enqueueOptions, }) { @@ -90,6 +91,7 @@ class StemWorkflowApp implements WorkflowCaller, WorkflowEventEmitter, StemTaskA args: args, headers: headers, options: options, + notBefore: notBefore, meta: meta, enqueueOptions: enqueueOptions, ); diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index c874d2b7..97d9cd10 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -1645,6 +1645,7 @@ abstract class TaskEnqueuer { Map args, Map headers, TaskOptions options, + DateTime? notBefore, Map meta, TaskEnqueueOptions? enqueueOptions, }); @@ -1735,6 +1736,7 @@ class TaskContext implements TaskEnqueuer { Map headers = const {}, Map meta = const {}, TaskOptions options = const TaskOptions(), + DateTime? notBefore, TaskEnqueueOptions? enqueueOptions, }) async { final delegate = enqueuer; @@ -1762,6 +1764,7 @@ class TaskContext implements TaskEnqueuer { args: args, headers: mergedHeaders, options: options, + notBefore: notBefore, meta: mergedMeta, enqueueOptions: enqueueOptions, ); diff --git a/packages/stem/lib/src/core/task_invocation.dart b/packages/stem/lib/src/core/task_invocation.dart index e3f09695..be92e09c 100644 --- a/packages/stem/lib/src/core/task_invocation.dart +++ b/packages/stem/lib/src/core/task_invocation.dart @@ -97,6 +97,7 @@ class TaskEnqueueRequest { required this.args, required this.headers, required this.options, + this.notBefore, required this.meta, this.enqueueOptions, }); @@ -116,6 +117,9 @@ class TaskEnqueueRequest { /// Task metadata. final Map meta; + /// Optional delay before execution. + final DateTime? notBefore; + /// Enqueue options. final Map? enqueueOptions; } @@ -241,6 +245,7 @@ class TaskInvocationContext implements TaskEnqueuer { Map args = const {}, Map headers = const {}, TaskOptions options = const TaskOptions(), + DateTime? notBefore, Map meta = const {}, TaskEnqueueOptions? enqueueOptions, }) async { @@ -269,6 +274,7 @@ class TaskInvocationContext implements TaskEnqueuer { args: args, headers: mergedHeaders, options: options, + notBefore: notBefore, meta: mergedMeta, enqueueOptions: enqueueOptions, ); @@ -379,6 +385,7 @@ class _RemoteTaskEnqueuer implements TaskEnqueuer { Map args = const {}, Map headers = const {}, TaskOptions options = const TaskOptions(), + DateTime? notBefore, Map meta = const {}, TaskEnqueueOptions? enqueueOptions, }) async { @@ -391,6 +398,7 @@ class _RemoteTaskEnqueuer implements TaskEnqueuer { args: args, headers: headers, options: options.toJson(), + notBefore: notBefore, meta: meta, enqueueOptions: enqueueOptions?.toJson(), ), diff --git a/packages/stem/lib/src/worker/worker.dart b/packages/stem/lib/src/worker/worker.dart index 8e58795d..a7548f80 100644 --- a/packages/stem/lib/src/worker/worker.dart +++ b/packages/stem/lib/src/worker/worker.dart @@ -4493,6 +4493,7 @@ class Worker { signal.request.enqueueOptions!.cast(), ) : null; + final notBefore = signal.request.notBefore; final enqueuer = _enqueuer; if (enqueuer == null) { signal.replyPort.send( @@ -4505,6 +4506,7 @@ class Worker { args: signal.request.args, headers: signal.request.headers, options: options, + notBefore: notBefore, meta: signal.request.meta, enqueueOptions: enqueueOptions, ); diff --git a/packages/stem/lib/src/workflow/core/flow_context.dart b/packages/stem/lib/src/workflow/core/flow_context.dart index 9f8992d9..71aedc3e 100644 --- a/packages/stem/lib/src/workflow/core/flow_context.dart +++ b/packages/stem/lib/src/workflow/core/flow_context.dart @@ -165,6 +165,7 @@ class FlowContext implements TaskEnqueuer, WorkflowCaller { Map headers = const {}, Map meta = const {}, TaskOptions options = const TaskOptions(), + DateTime? notBefore, TaskEnqueueOptions? enqueueOptions, }) async { final delegate = enqueuer; @@ -177,6 +178,7 @@ class FlowContext implements TaskEnqueuer, WorkflowCaller { headers: headers, meta: meta, options: options, + notBefore: notBefore, enqueueOptions: enqueueOptions, ); } diff --git a/packages/stem/lib/src/workflow/core/workflow_script_context.dart b/packages/stem/lib/src/workflow/core/workflow_script_context.dart index 25c0a2b5..ccd1022a 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script_context.dart @@ -85,6 +85,7 @@ abstract class WorkflowScriptStepContext Map headers = const {}, Map meta = const {}, TaskOptions options = const TaskOptions(), + DateTime? notBefore, TaskEnqueueOptions? enqueueOptions, }); diff --git a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart index 0b935094..d53e09da 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart @@ -1861,6 +1861,7 @@ class _WorkflowScriptStepContextImpl implements WorkflowScriptStepContext { Map headers = const {}, Map meta = const {}, TaskOptions options = const TaskOptions(), + DateTime? notBefore, TaskEnqueueOptions? enqueueOptions, }) async { final delegate = enqueuer; @@ -1873,6 +1874,7 @@ class _WorkflowScriptStepContextImpl implements WorkflowScriptStepContext { headers: headers, meta: meta, options: options, + notBefore: notBefore, enqueueOptions: enqueueOptions, ); } @@ -1966,6 +1968,7 @@ class _WorkflowStepEnqueuer implements TaskEnqueuer { Map args = const {}, Map headers = const {}, TaskOptions options = const TaskOptions(), + DateTime? notBefore, Map meta = const {}, TaskEnqueueOptions? enqueueOptions, }) { @@ -1980,6 +1983,7 @@ class _WorkflowStepEnqueuer implements TaskEnqueuer { args: args, headers: headers, options: resolvedOptions, + notBefore: notBefore, meta: mergedMeta, enqueueOptions: enqueueOptions, ); diff --git a/packages/stem/test/unit/core/task_context_enqueue_test.dart b/packages/stem/test/unit/core/task_context_enqueue_test.dart index 1feb7eb5..c4fc16ff 100644 --- a/packages/stem/test/unit/core/task_context_enqueue_test.dart +++ b/packages/stem/test/unit/core/task_context_enqueue_test.dart @@ -247,6 +247,7 @@ class _RecordingEnqueuer implements TaskEnqueuer { Map args = const {}, Map headers = const {}, TaskOptions options = const TaskOptions(), + DateTime? notBefore, Map meta = const {}, TaskEnqueueOptions? enqueueOptions, }) async { @@ -273,6 +274,7 @@ class _RecordingEnqueuer implements TaskEnqueuer { args: call.encodeArgs(), headers: call.headers, options: call.resolveOptions(), + notBefore: call.notBefore, meta: call.meta, enqueueOptions: enqueueOptions, ); diff --git a/packages/stem/test/unit/core/task_enqueue_builder_test.dart b/packages/stem/test/unit/core/task_enqueue_builder_test.dart index e774493d..93f5e591 100644 --- a/packages/stem/test/unit/core/task_enqueue_builder_test.dart +++ b/packages/stem/test/unit/core/task_enqueue_builder_test.dart @@ -128,6 +128,7 @@ class _RecordingTaskEnqueuer implements TaskEnqueuer { Map args = const {}, Map headers = const {}, TaskOptions options = const TaskOptions(), + DateTime? notBefore, Map meta = const {}, TaskEnqueueOptions? enqueueOptions, }) { diff --git a/packages/stem/test/unit/core/task_invocation_test.dart b/packages/stem/test/unit/core/task_invocation_test.dart index 16ab5652..d30bb77e 100644 --- a/packages/stem/test/unit/core/task_invocation_test.dart +++ b/packages/stem/test/unit/core/task_invocation_test.dart @@ -12,6 +12,7 @@ class _CapturingEnqueuer implements TaskEnqueuer { Map? lastMeta; TaskEnqueueOptions? lastOptions; TaskCall? lastCall; + DateTime? lastNotBefore; @override Future enqueue( @@ -19,12 +20,14 @@ class _CapturingEnqueuer implements TaskEnqueuer { Map args = const {}, Map headers = const {}, TaskOptions options = const TaskOptions(), + DateTime? notBefore, Map meta = const {}, TaskEnqueueOptions? enqueueOptions, }) async { lastHeaders = headers; lastMeta = meta; lastOptions = enqueueOptions; + lastNotBefore = notBefore; return _taskId; } @@ -67,6 +70,25 @@ void main() { expect(enqueuer.lastMeta, containsPair('stem.parentAttempt', 2)); }); + test('TaskInvocationContext.local forwards notBefore', () async { + final enqueuer = _CapturingEnqueuer('task-1'); + final context = TaskInvocationContext.local( + id: 'root-task', + headers: const {}, + meta: const {}, + attempt: 0, + heartbeat: () {}, + extendLease: (_) async {}, + progress: (_, {Map? data}) async {}, + enqueuer: enqueuer, + ); + final scheduledAt = DateTime.now().add(const Duration(minutes: 5)); + + await context.enqueue('child', notBefore: scheduledAt); + + expect(enqueuer.lastNotBefore, scheduledAt); + }); + test('TaskInvocationContext.local throws when enqueuer missing', () async { final context = TaskInvocationContext.local( id: 'no-enqueuer', diff --git a/packages/stem/test/unit/workflow/flow_context_test.dart b/packages/stem/test/unit/workflow/flow_context_test.dart index 715ce3da..e18bf36b 100644 --- a/packages/stem/test/unit/workflow/flow_context_test.dart +++ b/packages/stem/test/unit/workflow/flow_context_test.dart @@ -173,6 +173,7 @@ class _RecordingEnqueuer implements TaskEnqueuer { Map headers = const {}, Map meta = const {}, TaskOptions options = const TaskOptions(), + DateTime? notBefore, TaskEnqueueOptions? enqueueOptions, }) async { lastName = name; @@ -192,6 +193,7 @@ class _RecordingEnqueuer implements TaskEnqueuer { headers: call.headers, meta: call.meta, options: call.resolveOptions(), + notBefore: call.notBefore, enqueueOptions: enqueueOptions ?? call.enqueueOptions, ); } diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart index 9c5b5a42..76a48ed7 100644 --- a/packages/stem/test/unit/workflow/workflow_resume_test.dart +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -349,6 +349,7 @@ class _FakeWorkflowScriptStepContext implements WorkflowScriptStepContext { Map headers = const {}, Map meta = const {}, TaskOptions options = const TaskOptions(), + DateTime? notBefore, TaskEnqueueOptions? enqueueOptions, }) async { final delegate = _enqueuer; @@ -361,6 +362,7 @@ class _FakeWorkflowScriptStepContext implements WorkflowScriptStepContext { headers: headers, meta: meta, options: options, + notBefore: notBefore, enqueueOptions: enqueueOptions, ); } @@ -448,6 +450,7 @@ class _RecordingTaskEnqueuer implements TaskEnqueuer { Map headers = const {}, Map meta = const {}, TaskOptions options = const TaskOptions(), + DateTime? notBefore, TaskEnqueueOptions? enqueueOptions, }) async { lastName = name; @@ -467,6 +470,7 @@ class _RecordingTaskEnqueuer implements TaskEnqueuer { headers: call.headers, meta: call.meta, options: call.resolveOptions(), + notBefore: call.notBefore, enqueueOptions: enqueueOptions ?? call.enqueueOptions, ); } From bcfe63220779db38c9f09f9e85ad35415e70f0d4 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:30:25 -0500 Subject: [PATCH 059/302] Simplify producer documentation snippets --- packages/stem/CHANGELOG.md | 4 + .../example/docs_snippets/lib/producer.dart | 36 +++--- .../lib/workers_programmatic.dart | 112 ++++++++---------- 3 files changed, 71 insertions(+), 81 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 5c4bb541..e7790091 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -41,6 +41,10 @@ - Added `notBefore` support to the shared `TaskEnqueuer` surface so `StemClient`, `StemApp`, workflow contexts, and task contexts can publish delayed tasks without dropping down to raw `Stem`. +- Updated the producer and programmatic-worker documentation snippets to use + `StemApp`/`StemClient` for their producer examples, keeping low-level worker + examples intact while reducing manual broker/backend wiring in the happy + path. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/docs_snippets/lib/producer.dart b/packages/stem/example/docs_snippets/lib/producer.dart index ea2b4034..147f5838 100644 --- a/packages/stem/example/docs_snippets/lib/producer.dart +++ b/packages/stem/example/docs_snippets/lib/producer.dart @@ -37,8 +37,6 @@ Future enqueueWithRedis() async { final brokerUrl = Platform.environment['STEM_BROKER_URL'] ?? 'redis://localhost:6379'; - final broker = await RedisStreamsBroker.connect(brokerUrl); - final backend = await RedisResultBackend.connect('$brokerUrl/1'); final tasks = [ FunctionTaskHandler( name: 'reports.generate', @@ -50,31 +48,26 @@ Future enqueueWithRedis() async { ), ]; - final stem = Stem( - broker: broker, - backend: backend, + final client = await StemClient.fromUrl( + brokerUrl, + adapters: const [StemRedisAdapter()], + overrides: StemStoreOverrides(backend: '$brokerUrl/1'), tasks: tasks, ); - await stem.enqueue( + await client.enqueue( 'reports.generate', args: {'reportId': 'monthly-2025-10'}, options: const TaskOptions(queue: 'reports', maxRetries: 3), meta: {'requestedBy': 'finance'}, ); - await backend.close(); - await broker.close(); + await client.close(); } // #endregion producer-redis // #region producer-signed Future enqueueWithSigning() async { final config = StemConfig.fromEnvironment(); - final broker = await RedisStreamsBroker.connect( - config.brokerUrl, - tls: config.tls, - ); - final backend = InMemoryResultBackend(); final tasks = [ FunctionTaskHandler( name: 'billing.charge', @@ -85,20 +78,25 @@ Future enqueueWithSigning() async { }, ), ]; - final stem = Stem( - broker: broker, - backend: backend, + final client = await StemClient.create( + broker: StemBrokerFactory( + create: () => RedisStreamsBroker.connect( + config.brokerUrl, + tls: config.tls, + ), + dispose: (broker) => broker.close(), + ), + backend: StemBackendFactory.inMemory(), tasks: tasks, signer: PayloadSigner.maybe(config.signing), ); - await stem.enqueue( + await client.enqueue( 'billing.charge', args: {'customerId': 'cust_123', 'amount': 4200}, notBefore: DateTime.now().add(const Duration(minutes: 5)), ); - await backend.close(); - await broker.close(); + await client.close(); } // #endregion producer-signed diff --git a/packages/stem/example/docs_snippets/lib/workers_programmatic.dart b/packages/stem/example/docs_snippets/lib/workers_programmatic.dart index d47dc4d8..ae34d687 100644 --- a/packages/stem/example/docs_snippets/lib/workers_programmatic.dart +++ b/packages/stem/example/docs_snippets/lib/workers_programmatic.dart @@ -9,33 +9,27 @@ import 'package:stem_redis/stem_redis.dart'; // #region workers-producer-minimal Future minimalProducer() async { - final tasks = [ - FunctionTaskHandler( - name: 'email.send', - entrypoint: (context, args) async { - final to = args['to'] as String? ?? 'friend'; - print('Queued email to $to'); - return null; - }, - ), - ]; - - final broker = InMemoryBroker(); - final backend = InMemoryResultBackend(); - final stem = Stem( - broker: broker, - backend: backend, - tasks: tasks, + final app = await StemApp.inMemory( + tasks: [ + FunctionTaskHandler( + name: 'email.send', + entrypoint: (context, args) async { + final to = args['to'] as String? ?? 'friend'; + print('Queued email to $to'); + return null; + }, + ), + ], ); - final taskId = await stem.enqueue( + final taskId = await app.enqueue( 'email.send', args: {'to': 'hello@example.com', 'subject': 'Welcome'}, ); print('Enqueued $taskId'); - await backend.close(); - await broker.close(); + await app.waitForTask(taskId); + await app.close(); } // #endregion workers-producer-minimal @@ -43,32 +37,28 @@ Future minimalProducer() async { Future redisProducer() async { final brokerUrl = Platform.environment['STEM_BROKER_URL'] ?? 'redis://localhost:6379'; - final broker = await RedisStreamsBroker.connect(brokerUrl); - final backend = await RedisResultBackend.connect('$brokerUrl/1'); - final tasks = [ - FunctionTaskHandler( - name: 'report.generate', - entrypoint: (context, args) async { - final id = args['reportId'] as String? ?? 'unknown'; - print('Queued report $id'); - return null; - }, - ), - ]; - - final stem = Stem( - broker: broker, - backend: backend, - tasks: tasks, + final client = await StemClient.fromUrl( + brokerUrl, + adapters: const [StemRedisAdapter()], + overrides: StemStoreOverrides(backend: '$brokerUrl/1'), + tasks: [ + FunctionTaskHandler( + name: 'report.generate', + entrypoint: (context, args) async { + final id = args['reportId'] as String? ?? 'unknown'; + print('Queued report $id'); + return null; + }, + ), + ], ); - await stem.enqueue( + await client.enqueue( 'report.generate', args: {'reportId': 'monthly-2025-10'}, options: const TaskOptions(queue: 'reports'), ); - await backend.close(); - await broker.close(); + await client.close(); } // #endregion workers-producer-redis @@ -76,35 +66,33 @@ Future redisProducer() async { Future signedProducer() async { final config = StemConfig.fromEnvironment(); final signer = PayloadSigner.maybe(config.signing); - final tasks = [ - FunctionTaskHandler( - name: 'billing.charge', - entrypoint: (context, args) async { - final customerId = args['customerId'] as String? ?? 'unknown'; - print('Queued charge for $customerId'); - return null; - }, + final client = await StemClient.create( + broker: StemBrokerFactory( + create: () => RedisStreamsBroker.connect( + config.brokerUrl, + tls: config.tls, + ), + dispose: (broker) => broker.close(), ), - ]; - - final broker = await RedisStreamsBroker.connect( - config.brokerUrl, - tls: config.tls, - ); - final backend = InMemoryResultBackend(); - final stem = Stem( - broker: broker, - backend: backend, - tasks: tasks, + backend: StemBackendFactory.inMemory(), + tasks: [ + FunctionTaskHandler( + name: 'billing.charge', + entrypoint: (context, args) async { + final customerId = args['customerId'] as String? ?? 'unknown'; + print('Queued charge for $customerId'); + return null; + }, + ), + ], signer: signer, ); - await stem.enqueue( + await client.enqueue( 'billing.charge', args: {'customerId': 'cust_123', 'amount': 4200}, ); - await backend.close(); - await broker.close(); + await client.close(); } // #endregion workers-producer-signed From 91b6a6f395a39d9e4a0f52425d61a6e4c6fe0876 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:32:26 -0500 Subject: [PATCH 060/302] Use StemClient in enqueuer examples --- packages/stem/CHANGELOG.md | 4 +++ .../example/postgres_tls/bin/enqueue.dart | 31 ++++++++--------- .../postgres_worker/enqueuer/bin/enqueue.dart | 33 ++++++++++--------- .../enqueuer/bin/enqueue.dart | 31 ++++++++--------- 4 files changed, 53 insertions(+), 46 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index e7790091..a56eeae5 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -45,6 +45,10 @@ `StemApp`/`StemClient` for their producer examples, keeping low-level worker examples intact while reducing manual broker/backend wiring in the happy path. +- Simplified the Postgres-backed enqueuer examples (`postgres_worker`, + `redis_postgres_worker`, and `postgres_tls`) to use `StemClient.create(...)` + with explicit broker/backend factories instead of hand-constructing `Stem` + for publishing. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/postgres_tls/bin/enqueue.dart b/packages/stem/example/postgres_tls/bin/enqueue.dart index ac4284f1..7065877c 100644 --- a/packages/stem/example/postgres_tls/bin/enqueue.dart +++ b/packages/stem/example/postgres_tls/bin/enqueue.dart @@ -8,11 +8,6 @@ import 'package:stem_redis/stem_redis.dart'; Future main(List args) async { final config = StemConfig.fromEnvironment(); - final broker = await RedisStreamsBroker.connect( - config.brokerUrl, - tls: config.tls, - ); - final backendUrl = config.resultBackendUrl; if (backendUrl == null) { throw StateError( @@ -20,10 +15,6 @@ Future main(List args) async { ); } - final backend = await PostgresResultBackend.connect( - connectionString: backendUrl, - ); - final tasks = >[ FunctionTaskHandler( name: 'reports.generate', @@ -32,16 +23,27 @@ Future main(List args) async { ), ]; - final stem = Stem( - broker: broker, + final client = await StemClient.create( + broker: StemBrokerFactory( + create: () => RedisStreamsBroker.connect( + config.brokerUrl, + tls: config.tls, + ), + dispose: (broker) => broker.close(), + ), + backend: StemBackendFactory( + create: () => PostgresResultBackend.connect( + connectionString: backendUrl, + ), + dispose: (backend) => backend.close(), + ), tasks: tasks, - backend: backend, signer: PayloadSigner.maybe(config.signing), ); final regions = ['emea', 'amer', 'apac']; for (final region in regions) { - final id = await stem.enqueue( + final id = await client.enqueue( 'reports.generate', args: {'region': region}, options: TaskOptions(queue: config.defaultQueue), @@ -49,8 +51,7 @@ Future main(List args) async { stdout.writeln('Enqueued TLS demo task $id for $region'); } - await broker.close(); - await backend.close(); + await client.close(); } FutureOr _noop( diff --git a/packages/stem/example/postgres_worker/enqueuer/bin/enqueue.dart b/packages/stem/example/postgres_worker/enqueuer/bin/enqueue.dart index a15e9169..e503407f 100644 --- a/packages/stem/example/postgres_worker/enqueuer/bin/enqueue.dart +++ b/packages/stem/example/postgres_worker/enqueuer/bin/enqueue.dart @@ -7,12 +7,6 @@ import 'package:stem_postgres/stem_postgres.dart'; Future main(List args) async { final config = StemConfig.fromEnvironment(); - final broker = await PostgresBroker.connect( - config.brokerUrl, - applicationName: 'stem-postgres-enqueuer', - tls: config.tls, - ); - final backendUrl = config.resultBackendUrl; if (backendUrl == null) { throw StateError( @@ -20,10 +14,6 @@ Future main(List args) async { ); } - final backend = await PostgresResultBackend.connect( - connectionString: backendUrl, - ); - final tasks = >[ FunctionTaskHandler( name: 'report.generate', @@ -32,16 +22,28 @@ Future main(List args) async { ), ]; - final stem = Stem( - broker: broker, + final client = await StemClient.create( + broker: StemBrokerFactory( + create: () => PostgresBroker.connect( + config.brokerUrl, + applicationName: 'stem-postgres-enqueuer', + tls: config.tls, + ), + dispose: (broker) => broker.close(), + ), + backend: StemBackendFactory( + create: () => PostgresResultBackend.connect( + connectionString: backendUrl, + ), + dispose: (backend) => backend.close(), + ), tasks: tasks, - backend: backend, signer: PayloadSigner.maybe(config.signing), ); final regions = ['us-east', 'eu-west', 'ap-south']; for (final region in regions) { - final taskId = await stem.enqueue( + final taskId = await client.enqueue( 'report.generate', args: {'region': region}, options: TaskOptions(queue: config.defaultQueue), @@ -49,8 +51,7 @@ Future main(List args) async { stdout.writeln('Enqueued report job $taskId for $region'); } - await broker.close(); - await backend.close(); + await client.close(); } FutureOr _noopEntrypoint( diff --git a/packages/stem/example/redis_postgres_worker/enqueuer/bin/enqueue.dart b/packages/stem/example/redis_postgres_worker/enqueuer/bin/enqueue.dart index ef8ea10c..60db0451 100644 --- a/packages/stem/example/redis_postgres_worker/enqueuer/bin/enqueue.dart +++ b/packages/stem/example/redis_postgres_worker/enqueuer/bin/enqueue.dart @@ -8,11 +8,6 @@ import 'package:stem_redis/stem_redis.dart'; Future main(List args) async { final config = StemConfig.fromEnvironment(); - final broker = await RedisStreamsBroker.connect( - config.brokerUrl, - tls: config.tls, - ); - final backendUrl = config.resultBackendUrl; if (backendUrl == null) { throw StateError( @@ -20,10 +15,6 @@ Future main(List args) async { ); } - final backend = await PostgresResultBackend.connect( - connectionString: backendUrl, - ); - final tasks = >[ FunctionTaskHandler( name: 'hybrid.process', @@ -32,16 +23,27 @@ Future main(List args) async { ), ]; - final stem = Stem( - broker: broker, + final client = await StemClient.create( + broker: StemBrokerFactory( + create: () => RedisStreamsBroker.connect( + config.brokerUrl, + tls: config.tls, + ), + dispose: (broker) => broker.close(), + ), + backend: StemBackendFactory( + create: () => PostgresResultBackend.connect( + connectionString: backendUrl, + ), + dispose: (backend) => backend.close(), + ), tasks: tasks, - backend: backend, signer: PayloadSigner.maybe(config.signing), ); final items = ['alpha', 'beta', 'gamma']; for (final item in items) { - final taskId = await stem.enqueue( + final taskId = await client.enqueue( 'hybrid.process', args: {'item': item}, options: TaskOptions(queue: config.defaultQueue), @@ -49,8 +51,7 @@ Future main(List args) async { stdout.writeln('Enqueued hybrid job $taskId for $item'); } - await broker.close(); - await backend.close(); + await client.close(); } FutureOr _noop( From ca0f92d5ef4b4ec9feaac7465e90b00abbaa10b1 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:33:46 -0500 Subject: [PATCH 061/302] Use StemClient in publisher services --- packages/stem/CHANGELOG.md | 3 ++ .../example/email_service/bin/enqueuer.dart | 38 ++++++++++--------- .../enqueuer/bin/enqueue.dart | 27 ++++++------- .../signing_key_rotation/bin/producer.dart | 19 ++++++---- 4 files changed, 48 insertions(+), 39 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index a56eeae5..d4370410 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -49,6 +49,9 @@ `redis_postgres_worker`, and `postgres_tls`) to use `StemClient.create(...)` with explicit broker/backend factories instead of hand-constructing `Stem` for publishing. +- Simplified the `encrypted_payload`, `email_service`, and + `signing_key_rotation` producer/enqueuer examples to use client-backed + publishing instead of manually wiring `Stem` just to enqueue tasks. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/email_service/bin/enqueuer.dart b/packages/stem/example/email_service/bin/enqueuer.dart index a6169920..4ebd4a21 100644 --- a/packages/stem/example/email_service/bin/enqueuer.dart +++ b/packages/stem/example/email_service/bin/enqueuer.dart @@ -10,19 +10,10 @@ import 'package:stem_redis/stem_redis.dart'; Future main(List args) async { final config = StemConfig.fromEnvironment(); - final broker = await RedisStreamsBroker.connect( - config.brokerUrl, - tls: config.tls, - ); - final backend = config.resultBackendUrl != null - ? await RedisResultBackend.connect( - config.resultBackendUrl!, - tls: config.tls, - ) - : null; + final backendUrl = config.resultBackendUrl; final signer = PayloadSigner.maybe(config.signing); - if (backend == null) { + if (backendUrl == null) { stderr.writeln( 'STEM_RESULT_BACKEND_URL must be provided for the email service.', ); @@ -34,13 +25,25 @@ Future main(List args) async { name: 'email.send', entrypoint: _placeholderEntrypoint, options: const TaskOptions(queue: 'emails', maxRetries: 3), - ), + ), ]; - final stem = Stem( - broker: broker, + final client = await StemClient.create( + broker: StemBrokerFactory( + create: () => RedisStreamsBroker.connect( + config.brokerUrl, + tls: config.tls, + ), + dispose: (broker) => broker.close(), + ), + backend: StemBackendFactory( + create: () => RedisResultBackend.connect( + backendUrl, + tls: config.tls, + ), + dispose: (backend) => backend.close(), + ), tasks: tasks, - backend: backend, signer: signer, ); @@ -62,7 +65,7 @@ Future main(List args) async { }), ); } - final taskId = await stem.enqueue( + final taskId = await client.enqueue( 'email.send', args: {'to': to, 'subject': subject, 'body': emailBody}, options: const TaskOptions(queue: 'emails'), @@ -85,8 +88,7 @@ Future main(List args) async { Future shutdown(ProcessSignal signal) async { stdout.writeln('Shutting down email enqueue service ($signal)...'); await server.close(force: true); - await broker.close(); - await backend.close(); + await client.close(); exit(0); } diff --git a/packages/stem/example/encrypted_payload/enqueuer/bin/enqueue.dart b/packages/stem/example/encrypted_payload/enqueuer/bin/enqueue.dart index 62238454..d55ddfb3 100644 --- a/packages/stem/example/encrypted_payload/enqueuer/bin/enqueue.dart +++ b/packages/stem/example/encrypted_payload/enqueuer/bin/enqueue.dart @@ -10,19 +10,12 @@ Future main(List args) async { final config = StemConfig.fromEnvironment(); final secretKey = SecretKey(base64Decode(_requireEnv('PAYLOAD_SECRET'))); final cipher = AesGcm.with256bits(); - - final broker = await RedisStreamsBroker.connect( - config.brokerUrl, - tls: config.tls, - ); final backendUrl = config.resultBackendUrl; if (backendUrl == null) { throw StateError( 'STEM_RESULT_BACKEND_URL must be set for the encrypted example.', ); } - final backend = await RedisResultBackend.connect(backendUrl, tls: config.tls); - final tasks = >[ FunctionTaskHandler( name: 'secure.report', @@ -31,10 +24,19 @@ Future main(List args) async { ), ]; - final stem = Stem( - broker: broker, + final client = await StemClient.create( + broker: StemBrokerFactory( + create: () => RedisStreamsBroker.connect( + config.brokerUrl, + tls: config.tls, + ), + dispose: (broker) => broker.close(), + ), + backend: StemBackendFactory( + create: () => RedisResultBackend.connect(backendUrl, tls: config.tls), + dispose: (backend) => backend.close(), + ), tasks: tasks, - backend: backend, signer: PayloadSigner.maybe(config.signing), ); @@ -57,7 +59,7 @@ Future main(List args) async { 'mac': base64Encode(box.mac.bytes), }; - final taskId = await stem.enqueue( + final taskId = await client.enqueue( 'secure.report', args: payload, options: TaskOptions(queue: config.defaultQueue), @@ -65,8 +67,7 @@ Future main(List args) async { stdout.writeln('Enqueued secure task $taskId for ${job['customerId']}'); } - await broker.close(); - await backend.close(); + await client.close(); } FutureOr _noopEntrypoint( diff --git a/packages/stem/example/signing_key_rotation/bin/producer.dart b/packages/stem/example/signing_key_rotation/bin/producer.dart index 4f95af13..f2116a87 100644 --- a/packages/stem/example/signing_key_rotation/bin/producer.dart +++ b/packages/stem/example/signing_key_rotation/bin/producer.dart @@ -7,19 +7,23 @@ Future main() async { // #region signing-rotation-producer-config final config = StemConfig.fromEnvironment(); // #endregion signing-rotation-producer-config - final broker = await connectBroker(config.brokerUrl, tls: config.tls); final backendUrl = config.resultBackendUrl ?? config.brokerUrl; - final backend = await connectBackend(backendUrl, tls: config.tls); // #region signing-rotation-producer-signer final signer = PayloadSigner.maybe(config.signing); // #endregion signing-rotation-producer-signer final tasks = buildTasks(); // #region signing-rotation-producer-stem - final stem = Stem( - broker: broker, + final client = await StemClient.create( + broker: StemBrokerFactory( + create: () => connectBroker(config.brokerUrl, tls: config.tls), + dispose: (broker) => broker.close(), + ), + backend: StemBackendFactory( + create: () => connectBackend(backendUrl, tls: config.tls), + dispose: (backend) => backend.close(), + ), tasks: tasks, - backend: backend, signer: signer, ); // #endregion signing-rotation-producer-stem @@ -40,7 +44,7 @@ Future main() async { const options = TaskOptions(queue: rotationQueue); for (var i = 0; i < taskCount; i += 1) { final label = 'rotation-${i + 1}'; - final id = await stem.enqueue( + final id = await client.enqueue( 'rotation.demo', options: options, args: {'label': label, 'key': keyId}, @@ -49,8 +53,7 @@ Future main() async { } // #endregion signing-rotation-producer-enqueue - await broker.close(); - await backend.close(); + await client.close(); } int _parseInt(String key, {required int fallback, int min = 0}) { From 5bf56536cb77ba9b9100fc416167fa245dc4779a Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:37:13 -0500 Subject: [PATCH 062/302] Use StemClient in task context enqueue example --- packages/stem/CHANGELOG.md | 3 +++ .../example/task_context_mixed/bin/enqueue.dart | 13 ++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index d4370410..842b41e9 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -52,6 +52,9 @@ - Simplified the `encrypted_payload`, `email_service`, and `signing_key_rotation` producer/enqueuer examples to use client-backed publishing instead of manually wiring `Stem` just to enqueue tasks. +- Simplified the `task_context_mixed` enqueue example to use + `StemClient.create(...)` with its existing SQLite broker helper instead of + manually creating a raw `Stem` producer for task submission. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/task_context_mixed/bin/enqueue.dart b/packages/stem/example/task_context_mixed/bin/enqueue.dart index db625577..4becff59 100644 --- a/packages/stem/example/task_context_mixed/bin/enqueue.dart +++ b/packages/stem/example/task_context_mixed/bin/enqueue.dart @@ -4,16 +4,19 @@ import 'package:stem/stem.dart'; import 'package:stem_task_context_mixed_example/shared.dart'; Future main(List args) async { - final broker = await connectBroker(); final tasks = buildTasks(); - final stem = Stem(broker: broker, tasks: tasks); + final client = await StemClient.create( + broker: StemBrokerFactory(create: connectBroker), + backend: StemBackendFactory.inMemory(), + tasks: tasks, + ); final forceFail = args.contains('--fail'); final overwrite = args.contains('--overwrite'); final runId = DateTime.now().millisecondsSinceEpoch.toString(); final taskId = overwrite ? 'task-context-mixed' : null; - final firstId = await stem.enqueue( + final firstId = await client.enqueue( 'demo.inline_parent', args: {'runId': runId, 'forceFail': forceFail}, enqueueOptions: TaskEnqueueOptions(taskId: taskId, queue: mixedQueue), @@ -23,7 +26,7 @@ Future main(List args) async { ); if (overwrite) { - final overwriteId = await stem.enqueue( + final overwriteId = await client.enqueue( 'demo.inline_parent', args: {'runId': '${runId}_overwrite', 'forceFail': forceFail}, enqueueOptions: TaskEnqueueOptions(taskId: taskId, queue: mixedQueue), @@ -31,5 +34,5 @@ Future main(List args) async { stdout.writeln('Overwrote task id=$overwriteId'); } - await broker.close(); + await client.close(); } From 860520adcc8e3bbcc3724f74e16236ba4e4ce5ab Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:38:07 -0500 Subject: [PATCH 063/302] Use StemClient in image API example --- packages/stem/CHANGELOG.md | 3 ++ .../stem/example/image_processor/bin/api.dart | 33 +++++++++---------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 842b41e9..17874f6d 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -55,6 +55,9 @@ - Simplified the `task_context_mixed` enqueue example to use `StemClient.create(...)` with its existing SQLite broker helper instead of manually creating a raw `Stem` producer for task submission. +- Simplified the `image_processor` HTTP API example to use + `StemClient.create(...)` with Redis broker/result backend factories instead + of manually constructing a raw `Stem` publisher service. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/image_processor/bin/api.dart b/packages/stem/example/image_processor/bin/api.dart index c60b8df5..1d0e5096 100644 --- a/packages/stem/example/image_processor/bin/api.dart +++ b/packages/stem/example/image_processor/bin/api.dart @@ -10,19 +10,10 @@ import 'package:stem_redis/stem_redis.dart'; Future main(List args) async { final config = StemConfig.fromEnvironment(); - final broker = await RedisStreamsBroker.connect( - config.brokerUrl, - tls: config.tls, - ); - final backend = config.resultBackendUrl != null - ? await RedisResultBackend.connect( - config.resultBackendUrl!, - tls: config.tls, - ) - : null; + final backendUrl = config.resultBackendUrl; final signer = PayloadSigner.maybe(config.signing); - if (backend == null) { + if (backendUrl == null) { stderr.writeln( 'STEM_RESULT_BACKEND_URL must be provided for the image service.', ); @@ -37,10 +28,19 @@ Future main(List args) async { ), ]; - final stem = Stem( - broker: broker, + final client = await StemClient.create( + broker: StemBrokerFactory( + create: () => RedisStreamsBroker.connect( + config.brokerUrl, + tls: config.tls, + ), + dispose: (broker) => broker.close(), + ), + backend: StemBackendFactory( + create: () => RedisResultBackend.connect(backendUrl, tls: config.tls), + dispose: (backend) => backend.close(), + ), tasks: tasks, - backend: backend, signer: signer, ); @@ -53,7 +53,7 @@ Future main(List args) async { body: jsonEncode({'error': 'Missing "imageUrl" field'}), ); } - final taskId = await stem.enqueue( + final taskId = await client.enqueue( 'image.generate_thumbnail', args: {'imageUrl': imageUrl}, options: const TaskOptions(queue: 'images'), @@ -76,8 +76,7 @@ Future main(List args) async { Future shutdown(ProcessSignal signal) async { stdout.writeln('Shutting down image service ($signal)...'); await server.close(force: true); - await broker.close(); - await backend.close(); + await client.close(); exit(0); } From 126f6efe37b8aff6ba64873124dabc3a4cc7c6df Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:39:12 -0500 Subject: [PATCH 064/302] Simplify typed task docs snippet --- packages/stem/CHANGELOG.md | 3 +++ packages/stem/example/docs_snippets/lib/tasks.dart | 14 ++++---------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 17874f6d..c11d0795 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -58,6 +58,9 @@ - Simplified the `image_processor` HTTP API example to use `StemClient.create(...)` with Redis broker/result backend factories instead of manually constructing a raw `Stem` publisher service. +- Simplified the typed task-definition docs snippet to use + `StemApp.inMemory(...)` and `definition.enqueueAndWait(...)` instead of + manually wiring in-memory broker/backend instances and raw result waits. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/docs_snippets/lib/tasks.dart b/packages/stem/example/docs_snippets/lib/tasks.dart index 3f2aadfd..d97f5940 100644 --- a/packages/stem/example/docs_snippets/lib/tasks.dart +++ b/packages/stem/example/docs_snippets/lib/tasks.dart @@ -94,24 +94,18 @@ class PublishInvoiceTask extends TaskHandler { } Future runTypedDefinitionExample() async { - final broker = InMemoryBroker(); - final backend = InMemoryResultBackend(); - final stem = Stem( - broker: broker, - backend: backend, + final app = await StemApp.inMemory( tasks: [PublishInvoiceTask()], ); - final taskId = await PublishInvoiceTask.definition.enqueue( - stem, + final result = await PublishInvoiceTask.definition.enqueueAndWait( + app, const InvoicePayload(invoiceId: 'inv_42'), ); - final result = await stem.waitForTask(taskId); if (result?.isSucceeded == true) { print('Invoice published'); } - await backend.close(); - await broker.close(); + await app.close(); } // #endregion tasks-typed-definition From 70730a6534d6ac71a183b3a45e15ce691c5a2955 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:40:19 -0500 Subject: [PATCH 065/302] Use StemClient in routing parity publisher --- packages/stem/CHANGELOG.md | 3 ++ .../example/routing_parity/bin/publisher.dart | 33 ++++++++++--------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index c11d0795..644b5dd8 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -61,6 +61,9 @@ - Simplified the typed task-definition docs snippet to use `StemApp.inMemory(...)` and `definition.enqueueAndWait(...)` instead of manually wiring in-memory broker/backend instances and raw result waits. +- Simplified the `routing_parity` publisher example to use + `StemClient.create(...)` with its explicit routing registry and Redis broker + factory instead of manually constructing a raw `Stem` publisher. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/routing_parity/bin/publisher.dart b/packages/stem/example/routing_parity/bin/publisher.dart index d41f78aa..0f870d70 100644 --- a/packages/stem/example/routing_parity/bin/publisher.dart +++ b/packages/stem/example/routing_parity/bin/publisher.dart @@ -10,14 +10,15 @@ Future main() async { final routing = buildRoutingRegistry(); final tasks = buildDemoTasks(); - - final broker = await RedisStreamsBroker.connect( - redisUrl, - namespace: 'stem-routing-demo', - ); - - final stem = Stem( - broker: broker, + final client = await StemClient.create( + broker: StemBrokerFactory( + create: () => RedisStreamsBroker.connect( + redisUrl, + namespace: 'stem-routing-demo', + ), + dispose: (broker) => broker.close(), + ), + backend: StemBackendFactory.inMemory(), tasks: tasks, routing: routing, ); @@ -25,27 +26,27 @@ Future main() async { stdout.writeln('Publishing demo tasks using routing parity features...'); await _enqueueWithRouting( - stem, + client, routing, 'billing.invoice', args: const {'invoiceId': 101}, ); await _enqueueWithRouting( - stem, + client, routing, 'billing.invoice', args: const {'invoiceId': 102}, ); await _enqueueWithRouting( - stem, + client, routing, 'reports.generate', args: const {'subject': 'Quarterly summary', 'priority': 'low'}, options: const TaskOptions(priority: 1), ); await _enqueueWithRouting( - stem, + client, routing, 'reports.generate', args: const {'subject': 'Incident post-mortem', 'priority': 'high'}, @@ -53,7 +54,7 @@ Future main() async { ); await _enqueueWithRouting( - stem, + client, routing, 'ops.status', args: const {'message': 'Maintenance window begins at 02:00 UTC.'}, @@ -61,11 +62,11 @@ Future main() async { stdout .writeln('All demo tasks enqueued. Watch the worker output for results.'); - await broker.close(); + await client.close(); } Future _enqueueWithRouting( - Stem stem, + TaskEnqueuer enqueuer, RoutingRegistry routing, String name, { Map args = const {}, @@ -88,7 +89,7 @@ Future _enqueueWithRouting( ...meta, }; - final id = await stem.enqueue( + final id = await enqueuer.enqueue( name, args: args, headers: headers, From 160e52ea43fc97bb0885327ad55a965e8c5124b9 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:41:36 -0500 Subject: [PATCH 066/302] Simplify best practices docs snippet --- packages/stem/CHANGELOG.md | 3 +++ .../docs_snippets/lib/best_practices.dart | 26 +++++-------------- 2 files changed, 9 insertions(+), 20 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 644b5dd8..070b5433 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -64,6 +64,9 @@ - Simplified the `routing_parity` publisher example to use `StemClient.create(...)` with its explicit routing registry and Redis broker factory instead of manually constructing a raw `Stem` publisher. +- Simplified the best-practices docs snippet to use `StemApp.inMemory(...)` + and a generic `TaskEnqueuer` helper instead of manually wiring `Stem` and + `Worker` just to enqueue a single in-memory task. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/docs_snippets/lib/best_practices.dart b/packages/stem/example/docs_snippets/lib/best_practices.dart index 84f16cc4..7f4e086b 100644 --- a/packages/stem/example/docs_snippets/lib/best_practices.dart +++ b/packages/stem/example/docs_snippets/lib/best_practices.dart @@ -1,8 +1,6 @@ // Best practices snippets for documentation. // ignore_for_file: unused_local_variable, unused_import, dead_code, avoid_print -import 'dart:async'; - import 'package:stem/stem.dart'; // #region best-practices-task @@ -25,8 +23,8 @@ class IdempotentTask extends TaskHandler { // #endregion best-practices-task // #region best-practices-enqueue -Future enqueueTyped(Stem stem) async { - await stem.enqueue( +Future enqueueTyped(TaskEnqueuer enqueuer) async { + await enqueuer.enqueue( 'orders.sync', args: {'orderId': 'order-42'}, meta: {'requestId': 'req-001'}, @@ -35,22 +33,10 @@ Future enqueueTyped(Stem stem) async { // #endregion best-practices-enqueue Future main() async { - final broker = InMemoryBroker(); - final backend = InMemoryResultBackend(); - final tasks = [IdempotentTask()]; - final stem = Stem(broker: broker, backend: backend, tasks: tasks); - - final worker = Worker( - broker: broker, - backend: backend, - tasks: tasks, - queue: 'default', + final app = await StemApp.inMemory( + tasks: [IdempotentTask()], ); - unawaited(worker.start()); - await enqueueTyped(stem); - await Future.delayed(const Duration(milliseconds: 200)); - await worker.shutdown(); - await broker.close(); - await backend.close(); + await enqueueTyped(app); + await app.close(); } From 935bd5bff7d4356482c22a60c473ebd110c4f603 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:43:59 -0500 Subject: [PATCH 067/302] Use StemClient in microservice enqueuer --- packages/stem/CHANGELOG.md | 3 ++ .../microservice/enqueuer/bin/main.dart | 39 ++++++++++--------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 070b5433..633f55b1 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -67,6 +67,9 @@ - Simplified the best-practices docs snippet to use `StemApp.inMemory(...)` and a generic `TaskEnqueuer` helper instead of manually wiring `Stem` and `Worker` just to enqueue a single in-memory task. +- Simplified the `microservice/enqueuer` HTTP service to use + `StemClient.create(...)` for publishing while keeping `Canvas` and the + autofill controller on the client-owned broker/backend instances. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/microservice/enqueuer/bin/main.dart b/packages/stem/example/microservice/enqueuer/bin/main.dart index fb868f6d..980f3706 100644 --- a/packages/stem/example/microservice/enqueuer/bin/main.dart +++ b/packages/stem/example/microservice/enqueuer/bin/main.dart @@ -88,20 +88,12 @@ Future main(List args) async { observability.applyMetricExporters(); observability.applySignalConfiguration(); - final broker = await RedisStreamsBroker.connect( - config.brokerUrl, - tls: config.tls, - ); final backendUrl = config.resultBackendUrl; if (backendUrl == null) { throw StateError( 'STEM_RESULT_BACKEND_URL must be configured for the microservice enqueuer.', ); } - final backend = await RedisResultBackend.connect( - backendUrl, - tls: config.tls, - ); // #region signing-producer-signer final signer = PayloadSigner.maybe(config.signing); // #endregion signing-producer-signer @@ -118,20 +110,32 @@ Future main(List args) async { .toList(growable: false); // #region signing-producer-stem - final stem = Stem( - broker: broker, + final client = await StemClient.create( + broker: StemBrokerFactory( + create: () => RedisStreamsBroker.connect( + config.brokerUrl, + tls: config.tls, + ), + dispose: (broker) => broker.close(), + ), + backend: StemBackendFactory( + create: () => RedisResultBackend.connect( + backendUrl, + tls: config.tls, + ), + dispose: (backend) => backend.close(), + ), tasks: tasks, - backend: backend, signer: signer, ); // #endregion signing-producer-stem final canvas = Canvas( - broker: broker, - backend: backend, + broker: client.broker, + backend: client.backend, tasks: tasks, ); final autoFill = _AutoFillController( - stem: stem, + stem: client.stem, enabled: _boolFromEnv( Platform.environment['ENQUEUER_AUTOFILL_ENABLED'], defaultValue: true, @@ -172,7 +176,7 @@ Future main(List args) async { } final name = (body['name'] as String?)?.trim(); final entity = (name == null || name.isEmpty) ? 'friend' : name; - final taskId = await stem.enqueue( + final taskId = await client.enqueue( taskSpec.name, args: { 'name': entity, @@ -221,7 +225,7 @@ Future main(List args) async { ); }) ..get('/group/', (Request request, String groupId) async { - final status = await backend.getGroup(groupId); + final status = await client.backend.getGroup(groupId); if (status == null) { return Response.notFound( jsonEncode({'error': 'Unknown group or expired results'}), @@ -266,8 +270,7 @@ Future main(List args) async { stdout.writeln('Shutting down enqueue service ($signal)...'); autoFill.stop(); await server.close(force: true); - await broker.close(); - await backend.close(); + await client.close(); exit(0); } From 5a08767746c8ee6cf5e9cf79d4f010ba5782e658 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:44:43 -0500 Subject: [PATCH 068/302] Generalize signing enqueue snippet --- packages/stem/CHANGELOG.md | 3 +++ packages/stem/example/docs_snippets/lib/signing.dart | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 633f55b1..c1b97a8f 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -70,6 +70,9 @@ - Simplified the `microservice/enqueuer` HTTP service to use `StemClient.create(...)` for publishing while keeping `Canvas` and the autofill controller on the client-owned broker/backend instances. +- Generalized the signing rotation docs snippet to target `TaskEnqueuer` + instead of a concrete `Stem` producer so the example teaches the shared + enqueue surface. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/docs_snippets/lib/signing.dart b/packages/stem/example/docs_snippets/lib/signing.dart index 8a808bb3..390dc6cb 100644 --- a/packages/stem/example/docs_snippets/lib/signing.dart +++ b/packages/stem/example/docs_snippets/lib/signing.dart @@ -98,8 +98,8 @@ void logActiveSigningKey() { // #endregion signing-rotation-producer-active-key // #region signing-rotation-producer-enqueue -Future enqueueDuringRotation(Stem stem) async { - await stem.enqueue( +Future enqueueDuringRotation(TaskEnqueuer enqueuer) async { + await enqueuer.enqueue( 'billing.charge', args: {'customerId': 'cust_123', 'amount': 4200}, ); From fcb9ab5ac1414b3232ef35bbdc9c468649f58037 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:45:49 -0500 Subject: [PATCH 069/302] Generalize microservice autofill enqueuer --- packages/stem/CHANGELOG.md | 3 +++ packages/stem/example/microservice/enqueuer/bin/main.dart | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index c1b97a8f..99c16f55 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -73,6 +73,9 @@ - Generalized the signing rotation docs snippet to target `TaskEnqueuer` instead of a concrete `Stem` producer so the example teaches the shared enqueue surface. +- Generalized the microservice enqueuer's autofill controller to target + `TaskEnqueuer` instead of a concrete `Stem`, keeping the enqueue loop on the + shared producer surface. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/microservice/enqueuer/bin/main.dart b/packages/stem/example/microservice/enqueuer/bin/main.dart index 980f3706..97945bf4 100644 --- a/packages/stem/example/microservice/enqueuer/bin/main.dart +++ b/packages/stem/example/microservice/enqueuer/bin/main.dart @@ -135,7 +135,7 @@ Future main(List args) async { tasks: tasks, ); final autoFill = _AutoFillController( - stem: client.stem, + enqueuer: client.stem, enabled: _boolFromEnv( Platform.environment['ENQUEUER_AUTOFILL_ENABLED'], defaultValue: true, @@ -303,14 +303,14 @@ SecurityContext? _buildHttpSecurityContext() { class _AutoFillController { _AutoFillController({ - required this.stem, + required this.enqueuer, required this.enabled, required this.interval, required this.batchSize, required this.failureEvery, }); - final Stem stem; + final TaskEnqueuer enqueuer; final bool enabled; final Duration interval; final int batchSize; @@ -371,7 +371,7 @@ class _AutoFillController { required bool shouldFail, Map extraMeta = const {}, }) { - return stem.enqueue( + return enqueuer.enqueue( spec.name, args: { 'name': label, From 0d73d985d4a7c66e556919dd821047f6a3ff6dd5 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:47:42 -0500 Subject: [PATCH 070/302] Use StemClient in encrypted container producer --- packages/stem/CHANGELOG.md | 3 ++ .../encrypted_payload/docker/main.dart | 28 +++++++++++++------ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 99c16f55..06c067c6 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -76,6 +76,9 @@ - Generalized the microservice enqueuer's autofill controller to target `TaskEnqueuer` instead of a concrete `Stem`, keeping the enqueue loop on the shared producer surface. +- Simplified the `encrypted_payload/docker` producer entrypoint to use + `StemClient.create(...)` instead of manually constructing a raw `Stem` + publisher inside the container example. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/encrypted_payload/docker/main.dart b/packages/stem/example/encrypted_payload/docker/main.dart index a933fc25..4e5906dc 100644 --- a/packages/stem/example/encrypted_payload/docker/main.dart +++ b/packages/stem/example/encrypted_payload/docker/main.dart @@ -10,10 +10,13 @@ Future main(List args) async { final config = StemConfig.fromEnvironment(); final secretKey = SecretKey(base64Decode(_requireEnv('PAYLOAD_SECRET'))); final cipher = AesGcm.with256bits(); - - // Build Stem client - final broker = await RedisStreamsBroker.connect(config.brokerUrl); - final backend = await RedisResultBackend.connect(config.resultBackendUrl!); + final backendUrl = config.resultBackendUrl; + if (backendUrl == null) { + throw StateError( + 'STEM_RESULT_BACKEND_URL must be set ' + 'for the encrypted container example.', + ); + } final tasks = >[ FunctionTaskHandler( @@ -23,7 +26,17 @@ Future main(List args) async { ), ]; - final stem = Stem(broker: broker, tasks: tasks, backend: backend); + final client = await StemClient.create( + broker: StemBrokerFactory( + create: () => RedisStreamsBroker.connect(config.brokerUrl), + dispose: (broker) => broker.close(), + ), + backend: StemBackendFactory( + create: () => RedisResultBackend.connect(backendUrl), + dispose: (backend) => backend.close(), + ), + tasks: tasks, + ); final jobs = [ {'customerId': 'cust-1001', 'amount': 1250.75}, @@ -43,7 +56,7 @@ Future main(List args) async { 'mac': base64Encode(box.mac.bytes), }; - final id = await stem.enqueue( + final id = await client.enqueue( 'secure.report', args: payload, options: TaskOptions(queue: config.defaultQueue), @@ -51,8 +64,7 @@ Future main(List args) async { stdout.writeln('Container task $id for ${job['customerId']}'); } - await broker.close(); - await backend.close(); + await client.close(); } FutureOr _noopEntrypoint( From 3e4cf2a6e3a2e3becaa27132813938506276645a Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:48:27 -0500 Subject: [PATCH 071/302] Use StemClient in rate limit producer --- packages/stem/CHANGELOG.md | 3 +++ .../rate_limit_delay/bin/producer.dart | 19 +++++++++++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 06c067c6..098a44b5 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -79,6 +79,9 @@ - Simplified the `encrypted_payload/docker` producer entrypoint to use `StemClient.create(...)` instead of manually constructing a raw `Stem` publisher inside the container example. +- Simplified the `rate_limit_delay` producer example to use + `StemClient.create(...)` with its existing broker/backend helpers instead of + building a raw `Stem` producer for the delayed enqueue loop. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/rate_limit_delay/bin/producer.dart b/packages/stem/example/rate_limit_delay/bin/producer.dart index 0262338f..0f4ff8b8 100644 --- a/packages/stem/example/rate_limit_delay/bin/producer.dart +++ b/packages/stem/example/rate_limit_delay/bin/producer.dart @@ -11,14 +11,18 @@ Future main() async { stdout.writeln('[producer] connecting broker=$brokerUrl backend=$backendUrl'); - final broker = await connectBroker(brokerUrl); - final backend = await connectBackend(backendUrl); final tasks = buildTasks(); final routing = buildRoutingRegistry(); - final stem = buildStem( - broker: broker, + final client = await StemClient.create( + broker: StemBrokerFactory( + create: () => connectBroker(brokerUrl), + dispose: (broker) => broker.close(), + ), + backend: StemBackendFactory( + create: () => connectBackend(backendUrl), + dispose: (backend) => backend.close(), + ), tasks: tasks, - backend: backend, routing: routing, ); @@ -45,7 +49,7 @@ Future main() async { ); final appliedPriority = route.effectivePriority(priority); - final id = await stem.enqueue( + final id = await client.enqueue( taskName(), args: { 'job': i + 1, @@ -75,7 +79,6 @@ Future main() async { stdout.writeln('[producer] all jobs queued. Waiting 5s before shutdown...'); await Future.delayed(const Duration(seconds: 5)); - await broker.close(); - await backend.close(); + await client.close(); stdout.writeln('[producer] done.'); } From bb21c3b11e2ca33c6b5e8c56c6cc456748fc10e7 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:49:11 -0500 Subject: [PATCH 072/302] Use StemClient in dlq producer --- packages/stem/CHANGELOG.md | 3 +++ .../example/dlq_sandbox/bin/producer.dart | 19 +++++++++++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 098a44b5..c7a31f9e 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -82,6 +82,9 @@ - Simplified the `rate_limit_delay` producer example to use `StemClient.create(...)` with its existing broker/backend helpers instead of building a raw `Stem` producer for the delayed enqueue loop. +- Simplified the `dlq_sandbox` producer example to use + `StemClient.create(...)` with its existing broker/backend helpers instead of + building a raw `Stem` producer for the dead-letter sandbox. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/dlq_sandbox/bin/producer.dart b/packages/stem/example/dlq_sandbox/bin/producer.dart index f900c843..ef7fb36d 100644 --- a/packages/stem/example/dlq_sandbox/bin/producer.dart +++ b/packages/stem/example/dlq_sandbox/bin/producer.dart @@ -11,13 +11,17 @@ Future main() async { stdout.writeln('[producer] connecting broker=$brokerUrl backend=$backendUrl'); - final broker = await connectBroker(brokerUrl); - final backend = await connectBackend(backendUrl); final tasks = buildTasks(); - final stem = buildStem( - broker: broker, + final client = await StemClient.create( + broker: StemBrokerFactory( + create: () => connectBroker(brokerUrl), + dispose: (broker) => broker.close(), + ), + backend: StemBackendFactory( + create: () => connectBackend(backendUrl), + dispose: (backend) => backend.close(), + ), tasks: tasks, - backend: backend, ); final invoices = List.generate( @@ -29,7 +33,7 @@ Future main() async { '[producer] enqueueing invoices $invoices (all expected to fail first)'); // #region dlq-producer-enqueue for (final invoice in invoices) { - final id = await stem.enqueue( + final id = await client.enqueue( taskName(), args: { 'invoiceId': invoice, @@ -49,7 +53,6 @@ Future main() async { stdout.writeln('[producer] jobs queued. Waiting 3s before exit...'); await Future.delayed(const Duration(seconds: 3)); - await broker.close(); - await backend.close(); + await client.close(); stdout.writeln('[producer] done.'); } From 9b9831ed9ffe558e9279190b86f16c3a9286e67d Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:50:24 -0500 Subject: [PATCH 073/302] Simplify routing bootstrap docs snippet --- packages/stem/CHANGELOG.md | 3 +++ .../example/docs_snippets/lib/routing.dart | 22 ++++++++++--------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index c7a31f9e..e7429371 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -85,6 +85,9 @@ - Simplified the `dlq_sandbox` producer example to use `StemClient.create(...)` with its existing broker/backend helpers instead of building a raw `Stem` producer for the dead-letter sandbox. +- Simplified the routing bootstrap docs snippet to use + `StemClient.create(...)` and `createWorker(...)` instead of manually opening + separate broker/backend pairs just to demonstrate routing subscription setup. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/docs_snippets/lib/routing.dart b/packages/stem/example/docs_snippets/lib/routing.dart index 4a0e8181..ff864448 100644 --- a/packages/stem/example/docs_snippets/lib/routing.dart +++ b/packages/stem/example/docs_snippets/lib/routing.dart @@ -41,7 +41,7 @@ final priorityRegistry = RoutingRegistry( // #endregion routing-priority-range // #region routing-bootstrap -Future<(Stem, Worker)> bootstrapStem() async { +Future<(StemClient, Worker)> bootstrapStem() async { final routing = await loadRouting(); final tasks = [EmailTask()]; final config = StemConfig.fromEnvironment(); @@ -52,21 +52,23 @@ Future<(Stem, Worker)> bootstrapStem() async { broadcastChannels: config.workerBroadcasts, ); - final stem = Stem( - broker: await RedisStreamsBroker.connect('redis://localhost:6379'), - backend: InMemoryResultBackend(), + final client = await StemClient.create( + broker: StemBrokerFactory( + create: () => RedisStreamsBroker.connect('redis://localhost:6379'), + dispose: (broker) => broker.close(), + ), + backend: StemBackendFactory.inMemory(), tasks: tasks, routing: routing, ); - final worker = Worker( - broker: await RedisStreamsBroker.connect('redis://localhost:6379'), - backend: InMemoryResultBackend(), - tasks: tasks, - subscription: subscription, + final worker = await client.createWorker( + workerConfig: StemWorkerConfig( + subscription: subscription, + ), ); - return (stem, worker); + return (client, worker); } class EmailTask extends TaskHandler { From 3670ed57ab9d191ada47eec45fe9b061a3e6c051 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:53:11 -0500 Subject: [PATCH 074/302] Simplify observability tracing snippet --- packages/stem/CHANGELOG.md | 3 +++ .../docs_snippets/lib/observability.dart | 18 +++++------------- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index e7429371..b2423d74 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -88,6 +88,9 @@ - Simplified the routing bootstrap docs snippet to use `StemClient.create(...)` and `createWorker(...)` instead of manually opening separate broker/backend pairs just to demonstrate routing subscription setup. +- Simplified the observability tracing docs snippet to use + `StemClient.inMemory(...)` instead of manually wiring in-memory broker and + backend instances for the traced producer path. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/docs_snippets/lib/observability.dart b/packages/stem/example/docs_snippets/lib/observability.dart index b1d88e0b..788b482a 100644 --- a/packages/stem/example/docs_snippets/lib/observability.dart +++ b/packages/stem/example/docs_snippets/lib/observability.dart @@ -1,7 +1,6 @@ // Observability snippets for documentation. // ignore_for_file: unused_local_variable, unused_import, dead_code, avoid_print -import 'package:contextual/contextual.dart'; import 'package:stem/stem.dart'; // #region observability-metrics @@ -11,16 +10,12 @@ void configureMetrics() { // #endregion observability-metrics // #region observability-tracing -Stem buildTracedStem( - Broker broker, - ResultBackend backend, +Future buildTracedStem( Iterable> tasks, ) { // Configure OpenTelemetry globally; StemTracer.instance reads from it. final _ = StemTracer.instance; - return Stem( - broker: broker, - backend: backend, + return StemClient.inMemory( tasks: tasks, ); } @@ -80,9 +75,7 @@ Future main() async { ), ]; - final broker = InMemoryBroker(); - final backend = InMemoryResultBackend(); - final stem = buildTracedStem(broker, backend, tasks); + final client = await buildTracedStem(tasks); logTaskStart( Envelope( @@ -90,7 +83,6 @@ Future main() async { args: const {}, ), ); - await stem.enqueue('demo.trace', args: const {}); - await backend.close(); - await broker.close(); + await client.enqueue('demo.trace', args: const {}); + await client.close(); } From 8b9558bfc2e890197684752cdc4bf13752160899 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:54:00 -0500 Subject: [PATCH 075/302] Simplify otel metrics example bootstrap --- packages/stem/CHANGELOG.md | 3 +++ .../stem/example/otel_metrics/bin/worker.dart | 22 +++++++++---------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index b2423d74..a1703ca8 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -91,6 +91,9 @@ - Simplified the observability tracing docs snippet to use `StemClient.inMemory(...)` instead of manually wiring in-memory broker and backend instances for the traced producer path. +- Simplified the `otel_metrics` worker example to use + `StemClient.inMemory(...)` and `createWorker(...)` so the producer-side ping + loop no longer needs a raw `Stem` instance. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/otel_metrics/bin/worker.dart b/packages/stem/example/otel_metrics/bin/worker.dart index 36974825..327b5d3c 100644 --- a/packages/stem/example/otel_metrics/bin/worker.dart +++ b/packages/stem/example/otel_metrics/bin/worker.dart @@ -16,9 +16,6 @@ Future main() async { ), ]; - final broker = InMemoryBroker(); - final backend = InMemoryResultBackend(); - final otlpEndpoint = Platform.environment['STEM_OTLP_ENDPOINT'] ?? 'http://localhost:4318/v1/metrics'; @@ -28,16 +25,17 @@ Future main() async { metricExporters: ['otlp:$otlpEndpoint'], ); - final worker = Worker( - broker: broker, + final client = await StemClient.inMemory( tasks: tasks, - backend: backend, - consumerName: 'otel-demo-worker', - observability: observability, - heartbeatTransport: const NoopHeartbeatTransport(), ); - - final stem = Stem(broker: broker, tasks: tasks, backend: backend); + final worker = await client.createWorker( + workerConfig: const StemWorkerConfig( + consumerName: 'otel-demo-worker', + heartbeatTransport: NoopHeartbeatTransport(), + ).copyWith( + observability: observability, + ), + ); await worker.start(); print( @@ -45,6 +43,6 @@ Future main() async { ); Timer.periodic(const Duration(seconds: 1), (_) async { - await stem.enqueue('metrics.ping'); + await client.enqueue('metrics.ping'); }); } From a5dac1127bf7fbdd95e55331833bd77ec365b240 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:56:28 -0500 Subject: [PATCH 076/302] Simplify production signing checklist snippet --- .../getting-started/production-checklist.md | 6 ++--- packages/stem/CHANGELOG.md | 4 ++++ .../lib/production_checklist.dart | 24 +++++++------------ 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/.site/docs/getting-started/production-checklist.md b/.site/docs/getting-started/production-checklist.md index 405e2d74..1709d891 100644 --- a/.site/docs/getting-started/production-checklist.md +++ b/.site/docs/getting-started/production-checklist.md @@ -39,16 +39,16 @@ In code, wire the signer into both producers and workers: ``` - + ```dart title="lib/production_checklist.dart" file=/../packages/stem/example/docs_snippets/lib/production_checklist.dart#production-signing-registry ``` - + -```dart title="lib/production_checklist.dart" file=/../packages/stem/example/docs_snippets/lib/production_checklist.dart#production-signing-stem +```dart title="lib/production_checklist.dart" file=/../packages/stem/example/docs_snippets/lib/production_checklist.dart#production-signing-client ``` diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index a1703ca8..6c7de889 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -94,6 +94,10 @@ - Simplified the `otel_metrics` worker example to use `StemClient.inMemory(...)` and `createWorker(...)` so the producer-side ping loop no longer needs a raw `Stem` instance. +- Simplified the production signing checklist snippet to use + `StemClient.create(...)` with in-memory factories and `createWorker(...)`, + and updated the docs tab copy to describe the shared client setup instead of + raw `Stem` wiring. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/docs_snippets/lib/production_checklist.dart b/packages/stem/example/docs_snippets/lib/production_checklist.dart index a78e310d..3ba785fd 100644 --- a/packages/stem/example/docs_snippets/lib/production_checklist.dart +++ b/packages/stem/example/docs_snippets/lib/production_checklist.dart @@ -15,8 +15,6 @@ Future configureSigning() async { // #endregion production-signing-signer // #region production-signing-registry - final broker = InMemoryBroker(); - final backend = InMemoryResultBackend(); final tasks = [ FunctionTaskHandler( name: 'audit.log', @@ -29,33 +27,27 @@ Future configureSigning() async { // #endregion production-signing-registry // #region production-signing-runtime - // #region production-signing-stem - final stem = Stem( - broker: broker, - backend: backend, + // #region production-signing-client + final client = await StemClient.create( + broker: StemBrokerFactory.inMemory(), + backend: StemBackendFactory.inMemory(), tasks: tasks, signer: signer, ); - // #endregion production-signing-stem + // #endregion production-signing-client // #region production-signing-worker - final worker = Worker( - broker: broker, - backend: backend, - tasks: tasks, - signer: signer, - ); + final worker = await client.createWorker(); // #endregion production-signing-worker // #endregion production-signing-runtime // #region production-signing-enqueue - await stem.enqueue('audit.log', args: {'message': 'hello'}); + await client.enqueue('audit.log', args: {'message': 'hello'}); // #endregion production-signing-enqueue // #region production-signing-shutdown await worker.shutdown(); - await backend.close(); - await broker.close(); + await client.close(); // #endregion production-signing-shutdown } From 0a71b1112bb80f0b000a446404bb5261a304461b Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 20:58:47 -0500 Subject: [PATCH 077/302] Simplify persistence backend docs snippets --- packages/stem/CHANGELOG.md | 3 + .../docs_snippets/lib/persistence.dart | 74 ++++++++++--------- 2 files changed, 43 insertions(+), 34 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 6c7de889..c02fe841 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -98,6 +98,9 @@ `StemClient.create(...)` with in-memory factories and `createWorker(...)`, and updated the docs tab copy to describe the shared client setup instead of raw `Stem` wiring. +- Simplified the persistence backend docs snippets to use + `StemClient.create(...)` while still showing the explicit in-memory, Redis, + Postgres, and SQLite backend choices. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/docs_snippets/lib/persistence.dart b/packages/stem/example/docs_snippets/lib/persistence.dart index 557c54ce..0eb660cd 100644 --- a/packages/stem/example/docs_snippets/lib/persistence.dart +++ b/packages/stem/example/docs_snippets/lib/persistence.dart @@ -21,63 +21,69 @@ final demoTasks = [ // #region persistence-backend-in-memory Future connectInMemoryBackend() async { - final broker = InMemoryBroker(); - final backend = InMemoryResultBackend(); - final stem = Stem( - broker: broker, - backend: backend, + final client = await StemClient.create( + broker: StemBrokerFactory.inMemory(), + backend: StemBackendFactory.inMemory(), tasks: demoTasks, ); - await stem.enqueue('demo', args: {}); - await backend.close(); - await broker.close(); + await client.enqueue('demo', args: {}); + await client.close(); } // #endregion persistence-backend-in-memory // #region persistence-backend-redis Future connectRedisBackend() async { - final backend = await RedisResultBackend.connect('redis://localhost:6379/1'); - final broker = await RedisStreamsBroker.connect('redis://localhost:6379'); - final stem = Stem( - broker: broker, - backend: backend, + final client = await StemClient.create( + broker: StemBrokerFactory( + create: () => RedisStreamsBroker.connect('redis://localhost:6379'), + dispose: (broker) => broker.close(), + ), + backend: StemBackendFactory( + create: () => RedisResultBackend.connect('redis://localhost:6379/1'), + dispose: (backend) => backend.close(), + ), tasks: demoTasks, ); - await stem.enqueue('demo', args: {}); - await backend.close(); - await broker.close(); + await client.enqueue('demo', args: {}); + await client.close(); } // #endregion persistence-backend-redis // #region persistence-backend-postgres Future connectPostgresBackend() async { - final backend = await PostgresResultBackend.connect( - connectionString: 'postgres://postgres:postgres@localhost:5432/stem', - ); - final broker = await RedisStreamsBroker.connect('redis://localhost:6379'); - final stem = Stem( - broker: broker, - backend: backend, + final client = await StemClient.create( + broker: StemBrokerFactory( + create: () => RedisStreamsBroker.connect('redis://localhost:6379'), + dispose: (broker) => broker.close(), + ), + backend: StemBackendFactory( + create: () => PostgresResultBackend.connect( + connectionString: 'postgres://postgres:postgres@localhost:5432/stem', + ), + dispose: (backend) => backend.close(), + ), tasks: demoTasks, ); - await stem.enqueue('demo', args: {}); - await backend.close(); - await broker.close(); + await client.enqueue('demo', args: {}); + await client.close(); } // #endregion persistence-backend-postgres // #region persistence-backend-sqlite Future connectSqliteBackend() async { - final broker = await SqliteBroker.open(File('stem_broker.sqlite')); - final backend = await SqliteResultBackend.open(File('stem_backend.sqlite')); - final stem = Stem( - broker: broker, - backend: backend, + final client = await StemClient.create( + broker: StemBrokerFactory( + create: () => SqliteBroker.open(File('stem_broker.sqlite')), + dispose: (broker) => broker.close(), + ), + backend: StemBackendFactory( + create: () => SqliteResultBackend.open(File('stem_backend.sqlite')), + dispose: (backend) => backend.close(), + ), tasks: demoTasks, ); - await stem.enqueue('demo', args: {}); - await backend.close(); - await broker.close(); + await client.enqueue('demo', args: {}); + await client.close(); } // #endregion persistence-backend-sqlite From e3e5f0b8b6c350b8b65b6562daec8bc28c5486fc Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 21:01:36 -0500 Subject: [PATCH 078/302] Simplify README task examples --- packages/stem/CHANGELOG.md | 3 +++ packages/stem/README.md | 38 ++++++++++++++++++-------------------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index c02fe841..e9572068 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -101,6 +101,9 @@ - Simplified the persistence backend docs snippets to use `StemClient.create(...)` while still showing the explicit in-memory, Redis, Postgres, and SQLite backend choices. +- Simplified the README Redis task examples to use `StemClient.fromUrl(...)` + and `createWorker(...)` instead of manually wiring raw `Stem` and `Worker` + instances in the happy path. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/README.md b/packages/stem/README.md index 2137ca94..c8a3fa24 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -132,22 +132,21 @@ class HelloTask implements TaskHandler { } Future main() async { - final broker = await RedisStreamsBroker.connect('redis://localhost:6379'); - final backend = await RedisResultBackend.connect('redis://localhost:6379/1'); - - final stem = Stem(broker: broker, backend: backend, tasks: [HelloTask()]); - final worker = Worker( - broker: broker, - backend: backend, + final client = await StemClient.fromUrl( + 'redis://localhost:6379', + adapters: const [StemRedisAdapter()], + overrides: const StemStoreOverrides( + backend: 'redis://localhost:6379/1', + ), tasks: [HelloTask()], ); + final worker = await client.createWorker(); unawaited(worker.start()); - await stem.enqueue('demo.hello', args: {'name': 'Stem'}); + await client.enqueue('demo.hello', args: {'name': 'Stem'}); await Future.delayed(const Duration(seconds: 1)); await worker.shutdown(); - await broker.close(); - await backend.close(); + await client.close(); } ``` @@ -198,25 +197,24 @@ const helloArgsCodec = PayloadCodec( ); Future main() async { - final broker = await RedisStreamsBroker.connect('redis://localhost:6379'); - final backend = await RedisResultBackend.connect('redis://localhost:6379/1'); - - final stem = Stem(broker: broker, backend: backend, tasks: [HelloTask()]); - final worker = Worker( - broker: broker, - backend: backend, + final client = await StemClient.fromUrl( + 'redis://localhost:6379', + adapters: const [StemRedisAdapter()], + overrides: const StemStoreOverrides( + backend: 'redis://localhost:6379/1', + ), tasks: [HelloTask()], ); + final worker = await client.createWorker(); unawaited(worker.start()); await HelloTask.definition.enqueue( - stem, + client, const HelloArgs(name: 'Stem'), ); await Future.delayed(const Duration(seconds: 1)); await worker.shutdown(); - await broker.close(); - await backend.close(); + await client.close(); } ``` From e93854d485769e270fcadd353c37a6b5f0d5daf7 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 21:02:48 -0500 Subject: [PATCH 079/302] Use StemClient in mixed-cluster enqueuer --- packages/stem/CHANGELOG.md | 3 + .../mixed_cluster/enqueuer/bin/enqueue.dart | 61 ++++++++++--------- 2 files changed, 34 insertions(+), 30 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index e9572068..02e16020 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -104,6 +104,9 @@ - Simplified the README Redis task examples to use `StemClient.fromUrl(...)` and `createWorker(...)` instead of manually wiring raw `Stem` and `Worker` instances in the happy path. +- Simplified the mixed-cluster enqueuer example to use `StemClient.create(...)` + and normal client shutdown instead of returning raw `Stem` producers with + manual adapter-specific close logic. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/mixed_cluster/enqueuer/bin/enqueue.dart b/packages/stem/example/mixed_cluster/enqueuer/bin/enqueue.dart index e5c53258..90b586e4 100644 --- a/packages/stem/example/mixed_cluster/enqueuer/bin/enqueue.dart +++ b/packages/stem/example/mixed_cluster/enqueuer/bin/enqueue.dart @@ -3,19 +3,18 @@ import 'dart:io'; import 'package:stem/stem.dart'; import 'package:stem_postgres/stem_postgres.dart'; -import 'package:stem_postgres/stem_postgres.dart'; import 'package:stem_redis/stem_redis.dart'; Future main(List args) async { final redisConfig = _configFromPrefix('REDIS_'); final postgresConfig = _configFromPrefix('POSTGRES_'); - final redisStem = await _buildRedisStem(redisConfig); - final postgresStem = await _buildPostgresStem(postgresConfig); + final redisClient = await _buildRedisClient(redisConfig); + final postgresClient = await _buildPostgresClient(postgresConfig); final redisItems = ['cache-warmup', 'metrics-snapshot']; for (final item in redisItems) { - final id = await redisStem.enqueue( + final id = await redisClient.enqueue( 'redis.only', args: {'task': item}, options: TaskOptions(queue: redisConfig.defaultQueue), @@ -25,7 +24,7 @@ Future main(List args) async { final postgresItems = ['billing-report', 'inventory-rollup']; for (final item in postgresItems) { - final id = await postgresStem.enqueue( + final id = await postgresClient.enqueue( 'postgres.only', args: {'task': item}, options: TaskOptions(queue: postgresConfig.defaultQueue), @@ -33,10 +32,8 @@ Future main(List args) async { stdout.writeln('Enqueued Postgres task $id for $item'); } - await (redisStem.broker as RedisStreamsBroker).close(); - await (redisStem.backend as RedisResultBackend).close(); - await (postgresStem.broker as PostgresBroker).close(); - await (postgresStem.backend as PostgresResultBackend).close(); + await redisClient.close(); + await postgresClient.close(); } StemConfig _configFromPrefix(String prefix) { @@ -52,16 +49,11 @@ StemConfig _configFromPrefix(String prefix) { return StemConfig.fromEnvironment(overrides); } -Future _buildRedisStem(StemConfig config) async { - final broker = await RedisStreamsBroker.connect( - config.brokerUrl, - tls: config.tls, - ); +Future _buildRedisClient(StemConfig config) async { final backendUrl = config.resultBackendUrl; if (backendUrl == null) { throw StateError('STEM_RESULT_BACKEND_URL must be set for Redis Stem'); } - final backend = await RedisResultBackend.connect(backendUrl, tls: config.tls); final tasks = >[ FunctionTaskHandler( @@ -71,27 +63,26 @@ Future _buildRedisStem(StemConfig config) async { ), ]; - return Stem( - broker: broker, + return StemClient.create( + broker: StemBrokerFactory( + create: () => + RedisStreamsBroker.connect(config.brokerUrl, tls: config.tls), + dispose: (broker) => broker.close(), + ), + backend: StemBackendFactory( + create: () => RedisResultBackend.connect(backendUrl, tls: config.tls), + dispose: (backend) => backend.close(), + ), tasks: tasks, - backend: backend, signer: PayloadSigner.maybe(config.signing), ); } -Future _buildPostgresStem(StemConfig config) async { - final broker = await PostgresBroker.connect( - config.brokerUrl, - applicationName: 'stem-mixed-enqueuer', - tls: config.tls, - ); +Future _buildPostgresClient(StemConfig config) async { final backendUrl = config.resultBackendUrl; if (backendUrl == null) { throw StateError('STEM_RESULT_BACKEND_URL must be set for Postgres Stem'); } - final backend = await PostgresResultBackend.connect( - connectionString: backendUrl, - ); final tasks = >[ FunctionTaskHandler( @@ -101,10 +92,20 @@ Future _buildPostgresStem(StemConfig config) async { ), ]; - return Stem( - broker: broker, + return StemClient.create( + broker: StemBrokerFactory( + create: () => PostgresBroker.connect( + config.brokerUrl, + applicationName: 'stem-mixed-enqueuer', + tls: config.tls, + ), + dispose: (broker) => broker.close(), + ), + backend: StemBackendFactory( + create: () => PostgresResultBackend.connect(connectionString: backendUrl), + dispose: (backend) => backend.close(), + ), tasks: tasks, - backend: backend, signer: PayloadSigner.maybe(config.signing), ); } From ed1c9179741b3ea54a385d6eee2de72b843c00b2 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 21:04:11 -0500 Subject: [PATCH 080/302] Simplify developer environment bootstrap --- .../getting-started/developer-environment.md | 2 +- packages/stem/CHANGELOG.md | 3 + .../lib/developer_environment.dart | 55 ++++++++++--------- 3 files changed, 33 insertions(+), 27 deletions(-) diff --git a/.site/docs/getting-started/developer-environment.md b/.site/docs/getting-started/developer-environment.md index 3e6a93d6..0c09d6fc 100644 --- a/.site/docs/getting-started/developer-environment.md +++ b/.site/docs/getting-started/developer-environment.md @@ -51,7 +51,7 @@ piece is easy to scan and reuse: ``` -### Create the Stem producer +### Create the shared client/producer ```dart title="lib/stem_bootstrap.dart" file=/../packages/stem/example/docs_snippets/lib/developer_environment.dart#dev-env-stem diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 02e16020..afce5810 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -107,6 +107,9 @@ - Simplified the mixed-cluster enqueuer example to use `StemClient.create(...)` and normal client shutdown instead of returning raw `Stem` producers with manual adapter-specific close logic. +- Simplified the developer-environment bootstrap snippet to use a shared + `StemClient` plus `createWorker(...)` while keeping the explicit Redis + adapter configuration visible. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/docs_snippets/lib/developer_environment.dart b/packages/stem/example/docs_snippets/lib/developer_environment.dart index df2b60b2..68b009f4 100644 --- a/packages/stem/example/docs_snippets/lib/developer_environment.dart +++ b/packages/stem/example/docs_snippets/lib/developer_environment.dart @@ -13,13 +13,16 @@ Future bootstrapStem(List> tasks) async { // #endregion dev-env-config // #region dev-env-adapters - final broker = await RedisStreamsBroker.connect( - config.brokerUrl, - tls: config.tls, + final broker = StemBrokerFactory( + create: () => RedisStreamsBroker.connect(config.brokerUrl, tls: config.tls), + dispose: (broker) => broker.close(), ); - final backend = await RedisResultBackend.connect( - _resolveRedisUrl(config.brokerUrl, config.resultBackendUrl, 1), - tls: config.tls, + final backend = StemBackendFactory( + create: () => RedisResultBackend.connect( + _resolveRedisUrl(config.brokerUrl, config.resultBackendUrl, 1), + tls: config.tls, + ), + dispose: (backend) => backend.close(), ); final revokeStore = await RedisRevokeStore.connect( _resolveRedisUrl(config.brokerUrl, config.revokeStoreUrl, 2), @@ -29,7 +32,7 @@ Future bootstrapStem(List> tasks) async { // #endregion dev-env-adapters // #region dev-env-stem - final stem = Stem( + final client = await StemClient.create( broker: broker, backend: backend, tasks: tasks, @@ -39,27 +42,26 @@ Future bootstrapStem(List> tasks) async { // #region dev-env-worker final subscription = _buildSubscription(config); - final worker = Worker( - broker: broker, - backend: backend, - tasks: tasks, - revokeStore: revokeStore, - rateLimiter: rateLimiter, - queue: config.defaultQueue, - subscription: subscription, - concurrency: 8, - autoscale: const WorkerAutoscaleConfig( - enabled: true, - minConcurrency: 2, - maxConcurrency: 16, - backlogPerIsolate: 2.0, - idlePeriod: Duration(seconds: 45), + final worker = await client.createWorker( + workerConfig: StemWorkerConfig( + revokeStore: revokeStore, + rateLimiter: rateLimiter, + queue: config.defaultQueue, + subscription: subscription, + concurrency: 8, + autoscale: const WorkerAutoscaleConfig( + enabled: true, + minConcurrency: 2, + maxConcurrency: 16, + backlogPerIsolate: 2.0, + idlePeriod: Duration(seconds: 45), + ), ), ); // #endregion dev-env-worker return Bootstrap( - stem: stem, + client: client, worker: worker, config: config, rateLimiter: rateLimiter, @@ -68,13 +70,13 @@ Future bootstrapStem(List> tasks) async { class Bootstrap { Bootstrap({ - required this.stem, + required this.client, required this.worker, required this.config, required this.rateLimiter, }); - final Stem stem; + final StemClient client; final Worker worker; final StemConfig config; final RateLimiter? rateLimiter; @@ -87,7 +89,7 @@ Future runCanvasFlows( List> tasks, ) async { final canvas = Canvas( - broker: bootstrap.stem.broker, + broker: bootstrap.client.broker, backend: await RedisResultBackend.connect( _resolveRedisUrl( bootstrap.config.brokerUrl, @@ -221,4 +223,5 @@ Future main() async { await runCanvasFlows(bootstrap, tasks); await Future.delayed(const Duration(seconds: 1)); await bootstrap.worker.shutdown(); + await bootstrap.client.close(); } From 13d2932ab0dcac4275406e8fde6ee26d000fd616 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 21:05:07 -0500 Subject: [PATCH 081/302] Remove unused raw Stem demo helpers --- packages/stem/CHANGELOG.md | 3 +++ packages/stem/example/dlq_sandbox/lib/shared.dart | 12 ------------ .../stem/example/rate_limit_delay/lib/shared.dart | 14 -------------- 3 files changed, 3 insertions(+), 26 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index afce5810..2cc2afdd 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -110,6 +110,9 @@ - Simplified the developer-environment bootstrap snippet to use a shared `StemClient` plus `createWorker(...)` while keeping the explicit Redis adapter configuration visible. +- Removed the now-unused raw `Stem` helper constructors from the DLQ sandbox + and rate-limit delay shared libraries after those demos moved to + `StemClient`-based producers. - Flattened single-argument generated workflow/task refs and helper calls so one-field annotated workflows/tasks now use direct values instead of synthetic named-record wrappers in generated APIs, examples, and docs. diff --git a/packages/stem/example/dlq_sandbox/lib/shared.dart b/packages/stem/example/dlq_sandbox/lib/shared.dart index 779787ff..e6deeb80 100644 --- a/packages/stem/example/dlq_sandbox/lib/shared.dart +++ b/packages/stem/example/dlq_sandbox/lib/shared.dart @@ -20,18 +20,6 @@ List> buildTasks() => [ ), ]; -Stem buildStem({ - required Broker broker, - required Iterable> tasks, - ResultBackend? backend, -}) { - return Stem( - broker: broker, - tasks: tasks, - backend: backend, - ); -} - Future connectBroker(String uri) => RedisStreamsBroker.connect(uri); diff --git a/packages/stem/example/rate_limit_delay/lib/shared.dart b/packages/stem/example/rate_limit_delay/lib/shared.dart index c7cfa765..849b838f 100644 --- a/packages/stem/example/rate_limit_delay/lib/shared.dart +++ b/packages/stem/example/rate_limit_delay/lib/shared.dart @@ -39,20 +39,6 @@ RoutingRegistry buildRoutingRegistry() { return RoutingRegistry(config); } -Stem buildStem({ - required Broker broker, - required Iterable> tasks, - ResultBackend? backend, - RoutingRegistry? routing, -}) { - return Stem( - broker: broker, - tasks: tasks, - backend: backend, - routing: routing, - ); -} - Future connectBroker(String uri) => RedisStreamsBroker.connect(uri); From d8a2a688dcdd40d12d87bd0b3a39baccce1f41d4 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 21:08:04 -0500 Subject: [PATCH 082/302] Use StemClient in unique-task example --- packages/stem/CHANGELOG.md | 3 ++ .../unique_tasks/unique_task_example.dart | 39 +++++++++---------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 2cc2afdd..2e26b16f 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -110,6 +110,9 @@ - Simplified the developer-environment bootstrap snippet to use a shared `StemClient` plus `createWorker(...)` while keeping the explicit Redis adapter configuration visible. +- Simplified the unique-task example to use `StemClient.create(...)` and + `createWorker(...)` so the example stays focused on deduplication semantics + instead of raw producer wiring. - Removed the now-unused raw `Stem` helper constructors from the DLQ sandbox and rate-limit delay shared libraries after those demos moved to `StemClient`-based producers. diff --git a/packages/stem/example/unique_tasks/unique_task_example.dart b/packages/stem/example/unique_tasks/unique_task_example.dart index d42012ac..d440bb12 100644 --- a/packages/stem/example/unique_tasks/unique_task_example.dart +++ b/packages/stem/example/unique_tasks/unique_task_example.dart @@ -43,12 +43,6 @@ Future main() async { dbFile.createSync(recursive: true); } - final broker = InMemoryBroker(); - final backend = await SqliteResultBackend.open( - dbFile, - defaultTtl: const Duration(hours: 1), - groupDefaultTtl: const Duration(hours: 1), - ); final lockStore = InMemoryLockStore(); final coordinator = UniqueTaskCoordinator( lockStore: lockStore, @@ -59,26 +53,32 @@ Future main() async { final tasks = [SendDigestTask()]; // #region unique-task-stem-worker - final stem = Stem( - broker: broker, - backend: backend, + final client = await StemClient.create( + broker: StemBrokerFactory.inMemory(), + backend: StemBackendFactory( + create: () => SqliteResultBackend.open( + dbFile, + defaultTtl: const Duration(hours: 1), + groupDefaultTtl: const Duration(hours: 1), + ), + dispose: (backend) => backend.close(), + ), tasks: tasks, uniqueTaskCoordinator: coordinator, ); - final worker = Worker( - broker: broker, - backend: backend, - tasks: tasks, - uniqueTaskCoordinator: coordinator, - queue: 'email', - consumerName: 'unique-worker', + final worker = await client.createWorker( + workerConfig: StemWorkerConfig( + uniqueTaskCoordinator: coordinator, + queue: 'email', + consumerName: 'unique-worker', + ), ); // #endregion unique-task-stem-worker unawaited(worker.start()); // #region unique-task-enqueue - final firstId = await stem.enqueue( + final firstId = await client.enqueue( 'email.sendDigest', args: const {'userId': 42}, options: const TaskOptions( @@ -88,7 +88,7 @@ Future main() async { ), ); // #endregion unique-task-enqueue - final secondId = await stem.enqueue( + final secondId = await client.enqueue( 'email.sendDigest', args: const {'userId': 42}, options: const TaskOptions( @@ -104,6 +104,5 @@ Future main() async { await Future.delayed(const Duration(seconds: 2)); await worker.shutdown(); - await broker.close(); - await backend.close(); + await client.close(); } From 11145b4ef496c4365dd2fcd50f0281c62917dbff Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 21:08:47 -0500 Subject: [PATCH 083/302] Simplify README unique task snippet --- packages/stem/CHANGELOG.md | 2 ++ packages/stem/README.md | 14 ++++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 2e26b16f..742587d5 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -113,6 +113,8 @@ - Simplified the unique-task example to use `StemClient.create(...)` and `createWorker(...)` so the example stays focused on deduplication semantics instead of raw producer wiring. +- Simplified the README unique-task deduplication snippet to use + `StemClient.create(...)` instead of a raw `Stem(...)` constructor example. - Removed the now-unused raw `Stem` helper constructors from the DLQ sandbox and rate-limit delay shared libraries after those demos moved to `StemClient`-based producers. diff --git a/packages/stem/README.md b/packages/stem/README.md index c8a3fa24..76766299 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -910,9 +910,15 @@ final unique = UniqueTaskCoordinator( defaultTtl: const Duration(minutes: 5), ); -final stem = Stem( - broker: broker, - backend: backend, +final client = await StemClient.create( + broker: StemBrokerFactory( + create: () => RedisStreamsBroker.connect('redis://localhost:6379'), + dispose: (broker) => broker.close(), + ), + backend: StemBackendFactory( + create: () => RedisResultBackend.connect('redis://localhost:6379/1'), + dispose: (backend) => backend.close(), + ), tasks: [OrdersSyncTask()], uniqueTaskCoordinator: unique, ); @@ -933,7 +939,7 @@ falls back to `visibilityTimeout` or its default TTL. Override the unique key when needed: ```dart -final id = await stem.enqueue( +final id = await client.enqueue( 'orders.sync', args: {'id': 42}, options: const TaskOptions(unique: true, uniqueFor: Duration(minutes: 10)), From 2b23229d194b312d985b01255c489c7158b406cd Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 21:11:49 -0500 Subject: [PATCH 084/302] Add StemClient canvas helper --- packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 8 +-- .../lib/developer_environment.dart | 12 +---- .../microservice/enqueuer/bin/main.dart | 6 +-- .../stem/lib/src/bootstrap/stem_client.dart | 23 ++++++-- .../stem/test/bootstrap/stem_client_test.dart | 53 +++++++++++++++++++ 6 files changed, 77 insertions(+), 28 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 742587d5..ef0c3f9c 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -115,6 +115,9 @@ instead of raw producer wiring. - Simplified the README unique-task deduplication snippet to use `StemClient.create(...)` instead of a raw `Stem(...)` constructor example. +- Added `StemClient.createCanvas()` so shared-client setups can reuse the + client-owned broker/backend/registry for canvas dispatch without manually + constructing `Canvas(...)`. - Removed the now-unused raw `Stem` helper constructors from the DLQ sandbox and rate-limit delay shared libraries after those demos moved to `StemClient`-based producers. diff --git a/packages/stem/README.md b/packages/stem/README.md index 76766299..886bbcd0 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -861,13 +861,7 @@ final client = await StemClient.inMemory( additionalEncoders: const [MyOtherEncoder()], ); -final canvas = Canvas( - broker: broker, - backend: backend, - tasks: [SecretTask()], - resultEncoder: const Base64ResultEncoder(), - argsEncoder: const Base64ResultEncoder(), -); +final canvas = client.createCanvas(); ``` Every envelope published by Stem carries the argument encoder id in headers/meta diff --git a/packages/stem/example/docs_snippets/lib/developer_environment.dart b/packages/stem/example/docs_snippets/lib/developer_environment.dart index 68b009f4..4b8d781f 100644 --- a/packages/stem/example/docs_snippets/lib/developer_environment.dart +++ b/packages/stem/example/docs_snippets/lib/developer_environment.dart @@ -88,17 +88,7 @@ Future runCanvasFlows( Bootstrap bootstrap, List> tasks, ) async { - final canvas = Canvas( - broker: bootstrap.client.broker, - backend: await RedisResultBackend.connect( - _resolveRedisUrl( - bootstrap.config.brokerUrl, - bootstrap.config.resultBackendUrl, - 1, - ), - ), - tasks: tasks, - ); + final canvas = bootstrap.client.createCanvas(tasks: tasks); final ids = await canvas.group([ task('media.resize', args: {'file': 'hero.png'}), diff --git a/packages/stem/example/microservice/enqueuer/bin/main.dart b/packages/stem/example/microservice/enqueuer/bin/main.dart index 97945bf4..772280e0 100644 --- a/packages/stem/example/microservice/enqueuer/bin/main.dart +++ b/packages/stem/example/microservice/enqueuer/bin/main.dart @@ -129,11 +129,7 @@ Future main(List args) async { signer: signer, ); // #endregion signing-producer-stem - final canvas = Canvas( - broker: client.broker, - backend: client.backend, - tasks: tasks, - ); + final canvas = client.createCanvas(tasks: tasks); final autoFill = _AutoFillController( enqueuer: client.stem, enabled: _boolFromEnv( diff --git a/packages/stem/lib/src/bootstrap/stem_client.dart b/packages/stem/lib/src/bootstrap/stem_client.dart index 91fec50d..ca2b01c2 100644 --- a/packages/stem/lib/src/bootstrap/stem_client.dart +++ b/packages/stem/lib/src/bootstrap/stem_client.dart @@ -3,6 +3,7 @@ import 'package:stem/src/bootstrap/stem_app.dart'; import 'package:stem/src/bootstrap/stem_module.dart'; import 'package:stem/src/bootstrap/stem_stack.dart'; import 'package:stem/src/bootstrap/workflow_app.dart'; +import 'package:stem/src/canvas/canvas.dart'; import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/core/stem.dart'; import 'package:stem/src/core/task_payload_encoder.dart'; @@ -189,10 +190,8 @@ abstract class StemClient implements TaskResultCaller { /// Waits for a task result using a typed [definition] for decoding. @override - Future?> waitForTaskDefinition< - TArgs, - TResult extends Object? - >( + Future?> + waitForTaskDefinition( String taskId, TaskDefinition definition, { Duration? timeout, @@ -271,6 +270,21 @@ abstract class StemClient implements TaskResultCaller { ); } + /// Creates a canvas using the shared broker/backend/registry. + Canvas createCanvas({ + Iterable> tasks = const [], + }) { + final bundledTasks = module?.tasks ?? const >[]; + final allTasks = [...bundledTasks, ...tasks]; + registerModuleTaskHandlers(taskRegistry, allTasks); + return Canvas( + broker: broker, + backend: backend, + registry: taskRegistry, + encoderRegistry: encoderRegistry, + ); + } + /// Creates a workflow app using the shared client configuration. Future createWorkflowApp({ StemModule? module, @@ -319,7 +333,6 @@ abstract class StemClient implements TaskResultCaller { Future close(); } - class _DefaultStemClient extends StemClient { _DefaultStemClient({ required this.broker, diff --git a/packages/stem/test/bootstrap/stem_client_test.dart b/packages/stem/test/bootstrap/stem_client_test.dart index ae2a4cfa..b294a0ac 100644 --- a/packages/stem/test/bootstrap/stem_client_test.dart +++ b/packages/stem/test/bootstrap/stem_client_test.dart @@ -122,6 +122,59 @@ void main() { await client.close(); }); + test('StemClient createCanvas reuses shared registry and backend', () async { + final client = await StemClient.inMemory( + tasks: [ + FunctionTaskHandler( + name: 'client.canvas.task', + entrypoint: (context, args) async => 'canvas-ok', + runInIsolate: false, + ), + ], + ); + final worker = await client.createWorker(); + await worker.start(); + + final canvas = client.createCanvas(); + final taskId = await canvas.send( + task('client.canvas.task', args: const {}), + ); + final result = await client.waitForTask( + taskId, + timeout: const Duration(seconds: 2), + ); + + expect(result?.value, 'canvas-ok'); + + await worker.shutdown(); + await client.close(); + }); + + test('StemClient createCanvas registers additional tasks', () async { + final extraTask = FunctionTaskHandler( + name: 'client.canvas.extra', + entrypoint: (context, args) async => 'extra-ok', + runInIsolate: false, + ); + final client = await StemClient.inMemory(); + final worker = await client.createWorker(tasks: [extraTask]); + await worker.start(); + + final canvas = client.createCanvas(tasks: [extraTask]); + final taskId = await canvas.send( + task('client.canvas.extra', args: const {}), + ); + final result = await client.waitForTask( + taskId, + timeout: const Duration(seconds: 2), + ); + + expect(result?.value, 'extra-ok'); + + await worker.shutdown(); + await client.close(); + }); + test('StemClient createApp infers queues from explicit tasks', () async { final client = await StemClient.inMemory(); final app = await client.createApp( From faf265770f2759c9f7f55c542ab3f6e25f1e893a Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 21:14:28 -0500 Subject: [PATCH 085/302] Remove client.stem example leak --- .site/docs/workflows/annotated-workflows.md | 8 ++++---- packages/stem/CHANGELOG.md | 3 +++ packages/stem/README.md | 8 +++----- packages/stem/example/microservice/enqueuer/bin/main.dart | 2 +- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index 3e56a546..51fed5c6 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -135,10 +135,10 @@ This keeps one authoring model: When a workflow needs to start another workflow, do it from a durable boundary: -- `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, value)` - inside flow steps -- `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, value)` - inside checkpoint methods +- `FlowContext` and `WorkflowScriptStepContext` both implement + `WorkflowCaller`, so use + `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, value)` + inside flow steps and checkpoint methods Avoid starting child workflows from the raw `WorkflowScriptContext` body. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index ef0c3f9c..3d5b4338 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -118,6 +118,9 @@ - Added `StemClient.createCanvas()` so shared-client setups can reuse the client-owned broker/backend/registry for canvas dispatch without manually constructing `Canvas(...)`. +- Removed the remaining `client.stem` leak from the microservice enqueuer + example and clarified in the README/docs that `FlowContext` and + `WorkflowScriptStepContext` share the same child-workflow helper surface. - Removed the now-unused raw `Stem` helper constructors from the DLQ sandbox and rate-limit delay shared libraries after those demos moved to `StemClient`-based producers. diff --git a/packages/stem/README.md b/packages/stem/README.md index 886bbcd0..d7254423 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -570,12 +570,10 @@ Durable workflow contexts enqueue tasks directly: Child workflows belong in durable execution boundaries: -- use +- `FlowContext` and `WorkflowScriptStepContext` both implement + `WorkflowCaller`, so use `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, value)` - inside flow steps -- use - `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, value)` - inside script checkpoints + inside flow steps and script checkpoints - do not start child workflows from the raw `WorkflowScriptContext` body unless you are deliberately managing replay/idempotency yourself diff --git a/packages/stem/example/microservice/enqueuer/bin/main.dart b/packages/stem/example/microservice/enqueuer/bin/main.dart index 772280e0..afce848f 100644 --- a/packages/stem/example/microservice/enqueuer/bin/main.dart +++ b/packages/stem/example/microservice/enqueuer/bin/main.dart @@ -131,7 +131,7 @@ Future main(List args) async { // #endregion signing-producer-stem final canvas = client.createCanvas(tasks: tasks); final autoFill = _AutoFillController( - enqueuer: client.stem, + enqueuer: client, enabled: _boolFromEnv( Platform.environment['ENQUEUER_AUTOFILL_ENABLED'], defaultValue: true, From 9ded0803b747a0ba3804bc670d1d51157386678f Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 21:15:44 -0500 Subject: [PATCH 086/302] Clarify low-level runtime examples --- .site/docs/core-concepts/signing.md | 9 ++++++--- .site/docs/workers/programmatic-integration.md | 7 ++++++- packages/stem/CHANGELOG.md | 3 +++ packages/stem/example/docs_snippets/lib/signing.dart | 1 + .../example/docs_snippets/lib/workers_programmatic.dart | 1 + 5 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.site/docs/core-concepts/signing.md b/.site/docs/core-concepts/signing.md index 219ce109..aaa22716 100644 --- a/.site/docs/core-concepts/signing.md +++ b/.site/docs/core-concepts/signing.md @@ -23,7 +23,8 @@ reason. ## How signing works in Stem - Producers create a `PayloadSigner` from environment-derived config and pass it - into `Stem` to sign new envelopes. + into the producer runtime (`StemClient` or raw `Stem`) to sign new + envelopes. - Workers create the same signer (or verification-only config) and pass it into `Worker` to verify each delivery. - Schedulers/Beat that enqueue tasks should also sign. @@ -46,8 +47,10 @@ export STEM_SIGNING_ACTIVE_KEY=v1 2) Wire the signer into producers, workers, and schedulers. -These snippets come from the `packages/stem/example/microservice` project so you can see the -full context. +These snippets come from the `packages/stem/example/microservice` project so you +can see the full context. They intentionally show the lower-level signing +plumbing; for the normal happy path, still prefer `StemClient`/`StemApp` +bootstrap. diff --git a/.site/docs/workers/programmatic-integration.md b/.site/docs/workers/programmatic-integration.md index c02036fd..c87116dd 100644 --- a/.site/docs/workers/programmatic-integration.md +++ b/.site/docs/workers/programmatic-integration.md @@ -9,6 +9,10 @@ Use Stem's Dart APIs to embed task production and processing inside your application services. This guide focuses on the two core roles: **producer** (enqueuer) and **worker**. +This page is intentionally about the lower-level embedding surface. If you want +the default happy path, prefer `StemClient`, `StemApp`, or `StemWorkflowApp` +and come here only when you need direct runtime composition. + import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; @@ -40,7 +44,8 @@ import TabItem from '@theme/TabItem'; ### Tips -- Always reuse a `Stem` instance rather than creating one per request. +- Always reuse the producer runtime (`StemClient`, `StemApp`, or raw `Stem`) + rather than constructing one per request. - Use `TaskOptions` to set queue, retries, timeouts, and isolation. - Add custom metadata via the `meta` argument for observability or downstream processing. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 3d5b4338..9276c6ad 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -121,6 +121,9 @@ - Removed the remaining `client.stem` leak from the microservice enqueuer example and clarified in the README/docs that `FlowContext` and `WorkflowScriptStepContext` share the same child-workflow helper surface. +- Clarified in the worker-programmatic and signing docs that the remaining raw + `Stem` examples are intentionally the lower-level embedding path, not the + default happy path. - Removed the now-unused raw `Stem` helper constructors from the DLQ sandbox and rate-limit delay shared libraries after those demos moved to `StemClient`-based producers. diff --git a/packages/stem/example/docs_snippets/lib/signing.dart b/packages/stem/example/docs_snippets/lib/signing.dart index 390dc6cb..d8b5218a 100644 --- a/packages/stem/example/docs_snippets/lib/signing.dart +++ b/packages/stem/example/docs_snippets/lib/signing.dart @@ -1,4 +1,5 @@ // Signing examples for documentation. +// These snippets intentionally show the lower-level producer/worker/Beat wiring. // ignore_for_file: unused_local_variable, unused_import, dead_code, avoid_print import 'dart:async'; diff --git a/packages/stem/example/docs_snippets/lib/workers_programmatic.dart b/packages/stem/example/docs_snippets/lib/workers_programmatic.dart index ae34d687..a33ac95e 100644 --- a/packages/stem/example/docs_snippets/lib/workers_programmatic.dart +++ b/packages/stem/example/docs_snippets/lib/workers_programmatic.dart @@ -1,4 +1,5 @@ // Programmatic worker and producer examples for documentation. +// These snippets intentionally cover the lower-level embedding surface. // ignore_for_file: unused_local_variable, unused_import, dead_code, avoid_print import 'dart:async'; From ef0f685582b97d320b8fdfab541cb34f008439ed Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 21:22:05 -0500 Subject: [PATCH 087/302] Add task and group status helpers --- .site/docs/core-concepts/canvas.md | 6 ++- packages/stem/CHANGELOG.md | 3 ++ .../docs_snippets/lib/canvas_group.dart | 4 +- .../docs_snippets/lib/quick_start.dart | 2 +- .../docs_snippets/lib/troubleshooting.dart | 2 +- .../microservice/enqueuer/bin/main.dart | 2 +- packages/stem/lib/src/bootstrap/stem_app.dart | 18 +++++-- .../stem/lib/src/bootstrap/stem_client.dart | 10 ++++ .../stem/lib/src/bootstrap/workflow_app.dart | 28 ++++++++--- packages/stem/lib/src/core/stem.dart | 26 ++++++++-- .../stem/test/bootstrap/stem_app_test.dart | 49 +++++++++++++++++++ .../stem/test/bootstrap/stem_client_test.dart | 49 +++++++++++++++++++ 12 files changed, 178 insertions(+), 21 deletions(-) diff --git a/.site/docs/core-concepts/canvas.md b/.site/docs/core-concepts/canvas.md index 416e31a0..f20b8f6e 100644 --- a/.site/docs/core-concepts/canvas.md +++ b/.site/docs/core-concepts/canvas.md @@ -56,7 +56,8 @@ runs with `context.meta['chordResults']` populated. ``` If any branch fails, the callback is skipped and the chord group is marked as -failed. Inspect `backend.getGroup(chordId)` to see which branch failed before +failed. Inspect the latest group status via `StemApp.getGroupStatus(...)`, +`StemClient.getGroupStatus(...)`, or `backend.getGroup(chordId)` before retrying. ## Dependency semantics @@ -73,7 +74,8 @@ retrying. - `Canvas.group` returns a `GroupDispatch` with a result stream for each child. - `Canvas.chord` preserves the original signature order when building `chordResults`, so you can map results back to inputs deterministically. -- `backend.getGroup(groupId)` returns the latest status for each child task. +- `StemApp.getGroupStatus(...)`, `StemClient.getGroupStatus(...)`, or + `backend.getGroup(groupId)` return the latest status for each child task. ## Removal semantics diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 9276c6ad..728d793c 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -118,6 +118,9 @@ - Added `StemClient.createCanvas()` so shared-client setups can reuse the client-owned broker/backend/registry for canvas dispatch without manually constructing `Canvas(...)`. +- Added `getTaskStatus(...)` / `getGroupStatus(...)` to the shared + `TaskResultCaller` surface so apps and clients can inspect task/group state + without reaching into the raw result backend. - Removed the remaining `client.stem` leak from the microservice enqueuer example and clarified in the README/docs that `FlowContext` and `WorkflowScriptStepContext` share the same child-workflow helper surface. diff --git a/packages/stem/example/docs_snippets/lib/canvas_group.dart b/packages/stem/example/docs_snippets/lib/canvas_group.dart index 69ed7fbf..8143f495 100644 --- a/packages/stem/example/docs_snippets/lib/canvas_group.dart +++ b/packages/stem/example/docs_snippets/lib/canvas_group.dart @@ -33,11 +33,11 @@ Future main() async { ]); await _waitFor(() async { - final status = await app.backend.getGroup(dispatch.groupId); + final status = await app.getGroupStatus(dispatch.groupId); return status?.results.length == 3; }); - final groupStatus = await app.backend.getGroup(dispatch.groupId); + final groupStatus = await app.getGroupStatus(dispatch.groupId); final values = groupStatus?.results.values.map((s) => s.payload).toList(); print('Group results: $values'); diff --git a/packages/stem/example/docs_snippets/lib/quick_start.dart b/packages/stem/example/docs_snippets/lib/quick_start.dart index cde6595a..23301649 100644 --- a/packages/stem/example/docs_snippets/lib/quick_start.dart +++ b/packages/stem/example/docs_snippets/lib/quick_start.dart @@ -92,7 +92,7 @@ Future main() async { // #region quickstart-inspect await Future.delayed(const Duration(seconds: 6)); - final resizeStatus = await app.backend.get(resizeId); + final resizeStatus = await app.getTaskStatus(resizeId); print('Resize status: ${resizeStatus?.state} (${resizeStatus?.attempt})'); await app.close(); diff --git a/packages/stem/example/docs_snippets/lib/troubleshooting.dart b/packages/stem/example/docs_snippets/lib/troubleshooting.dart index ec5be201..3e775ecc 100644 --- a/packages/stem/example/docs_snippets/lib/troubleshooting.dart +++ b/packages/stem/example/docs_snippets/lib/troubleshooting.dart @@ -43,7 +43,7 @@ Future runTroubleshootingDemo() async { // #region troubleshooting-results await Future.delayed(const Duration(milliseconds: 200)); - final result = await app.backend.get(taskId); + final result = await app.getTaskStatus(taskId); print('Result: ${result?.payload}'); // #endregion troubleshooting-results diff --git a/packages/stem/example/microservice/enqueuer/bin/main.dart b/packages/stem/example/microservice/enqueuer/bin/main.dart index afce848f..75787dd0 100644 --- a/packages/stem/example/microservice/enqueuer/bin/main.dart +++ b/packages/stem/example/microservice/enqueuer/bin/main.dart @@ -221,7 +221,7 @@ Future main(List args) async { ); }) ..get('/group/', (Request request, String groupId) async { - final status = await client.backend.getGroup(groupId); + final status = await client.getGroupStatus(groupId); if (status == null) { return Response.notFound( jsonEncode({'error': 'Unknown group or expired results'}), diff --git a/packages/stem/lib/src/bootstrap/stem_app.dart b/packages/stem/lib/src/bootstrap/stem_app.dart index 781c336f..ffaa2813 100644 --- a/packages/stem/lib/src/bootstrap/stem_app.dart +++ b/packages/stem/lib/src/bootstrap/stem_app.dart @@ -99,6 +99,18 @@ class StemApp implements StemTaskApp { return stem.enqueueCall(call, enqueueOptions: enqueueOptions); } + @override + Future getTaskStatus(String taskId) async { + await _ensureStarted(); + return stem.getTaskStatus(taskId); + } + + @override + Future getGroupStatus(String groupId) async { + await _ensureStarted(); + return stem.getGroupStatus(groupId); + } + @override Future?> waitForTask( String taskId, { @@ -110,10 +122,8 @@ class StemApp implements StemTaskApp { } @override - Future?> waitForTaskDefinition< - TArgs, - TResult extends Object? - >( + Future?> + waitForTaskDefinition( String taskId, TaskDefinition definition, { Duration? timeout, diff --git a/packages/stem/lib/src/bootstrap/stem_client.dart b/packages/stem/lib/src/bootstrap/stem_client.dart index ca2b01c2..1dd9083a 100644 --- a/packages/stem/lib/src/bootstrap/stem_client.dart +++ b/packages/stem/lib/src/bootstrap/stem_client.dart @@ -170,6 +170,16 @@ abstract class StemClient implements TaskResultCaller { ); } + @override + Future getTaskStatus(String taskId) { + return stem.getTaskStatus(taskId); + } + + @override + Future getGroupStatus(String groupId) { + return stem.getGroupStatus(groupId); + } + @override Future enqueueCall( TaskCall call, { diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index 3922be0c..c31d558a 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -6,7 +6,14 @@ import 'package:stem/src/bootstrap/stem_stack.dart'; import 'package:stem/src/control/revoke_store.dart'; import 'package:stem/src/core/clock.dart'; import 'package:stem/src/core/contracts.dart' - show TaskCall, TaskDefinition, TaskEnqueueOptions, TaskHandler, TaskOptions; + show + GroupStatus, + TaskCall, + TaskDefinition, + TaskEnqueueOptions, + TaskHandler, + TaskOptions, + TaskStatus; import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/core/task_payload_encoder.dart'; import 'package:stem/src/core/task_result.dart'; @@ -31,7 +38,8 @@ import 'package:stem/src/workflow/runtime/workflow_runtime.dart'; /// This wrapper wires together broker/backend infrastructure, registers flows, /// and exposes convenience helpers for scheduling and observing workflow runs /// without having to manage [WorkflowRuntime] directly. -class StemWorkflowApp implements WorkflowCaller, WorkflowEventEmitter, StemTaskApp { +class StemWorkflowApp + implements WorkflowCaller, WorkflowEventEmitter, StemTaskApp { StemWorkflowApp._({ required this.app, required this.runtime, @@ -105,6 +113,16 @@ class StemWorkflowApp implements WorkflowCaller, WorkflowEventEmitter, StemTaskA return app.enqueueCall(call, enqueueOptions: enqueueOptions); } + @override + Future getTaskStatus(String taskId) { + return app.getTaskStatus(taskId); + } + + @override + Future getGroupStatus(String groupId) { + return app.getGroupStatus(groupId); + } + @override Future?> waitForTask( String taskId, { @@ -115,10 +133,8 @@ class StemWorkflowApp implements WorkflowCaller, WorkflowEventEmitter, StemTaskA } @override - Future?> waitForTaskDefinition< - TArgs, - TResult extends Object? - >( + Future?> + waitForTaskDefinition( String taskId, TaskDefinition definition, { Duration? timeout, diff --git a/packages/stem/lib/src/core/stem.dart b/packages/stem/lib/src/core/stem.dart index 259d520c..4f45baef 100644 --- a/packages/stem/lib/src/core/stem.dart +++ b/packages/stem/lib/src/core/stem.dart @@ -76,6 +76,12 @@ import 'package:stem/src/signals/emitter.dart'; /// Shared typed task-dispatch surface used by producers, apps, and contexts. abstract interface class TaskResultCaller implements TaskEnqueuer { + /// Reads the latest task status by task id. + Future getTaskStatus(String taskId); + + /// Reads the latest group status by group id. + Future getGroupStatus(String groupId); + /// Waits for a task result by task id. Future?> waitForTask( String taskId, { @@ -84,10 +90,8 @@ abstract interface class TaskResultCaller implements TaskEnqueuer { }); /// Waits for [taskId] using a typed [definition] for result decoding. - Future?> waitForTaskDefinition< - TArgs, - TResult extends Object? - >( + Future?> + waitForTaskDefinition( String taskId, TaskDefinition definition, { Duration? timeout, @@ -175,6 +179,20 @@ class Stem implements TaskResultCaller { } } + @override + Future getTaskStatus(String taskId) async { + final resolved = backend; + if (resolved == null) return null; + return resolved.get(taskId); + } + + @override + Future getGroupStatus(String groupId) async { + final resolved = backend; + if (resolved == null) return null; + return resolved.getGroup(groupId); + } + /// Enqueue a typed task using a [TaskCall] wrapper produced by a /// [TaskDefinition]. @override diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index 96af1259..45dc0b40 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -45,6 +45,39 @@ void main() { } }); + test('inMemory exposes task and group status helpers', () async { + final taskHandler = FunctionTaskHandler( + name: 'test.status.task', + entrypoint: (context, args) async => 'status-ok', + runInIsolate: false, + ); + + final app = await StemApp.inMemory(tasks: [taskHandler]); + try { + final taskId = await app.enqueue('test.status.task'); + final taskStatus = await app.waitForTask( + taskId, + timeout: const Duration(seconds: 2), + ); + expect(taskStatus?.value, 'status-ok'); + expect((await app.getTaskStatus(taskId))?.state, TaskState.succeeded); + + final dispatch = await app.canvas.group([ + task('test.status.task', args: const {}), + ]); + try { + final groupStatus = await _waitForGroupStatus( + () => app.getGroupStatus(dispatch.groupId), + ); + expect(groupStatus?.completed, 1); + } finally { + await dispatch.dispose(); + } + } finally { + await app.shutdown(); + } + }); + test( 'inMemory registers module tasks and infers queued subscriptions', () async { @@ -821,6 +854,22 @@ void main() { }); } +Future _waitForGroupStatus( + Future Function() lookup, { + Duration timeout = const Duration(seconds: 2), + Duration pollInterval = const Duration(milliseconds: 25), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + final status = await lookup(); + if (status != null && status.completed == status.expected) { + return status; + } + await Future.delayed(pollInterval); + } + return lookup(); +} + class _DemoPayload { const _DemoPayload(this.foo); diff --git a/packages/stem/test/bootstrap/stem_client_test.dart b/packages/stem/test/bootstrap/stem_client_test.dart index b294a0ac..e699bda3 100644 --- a/packages/stem/test/bootstrap/stem_client_test.dart +++ b/packages/stem/test/bootstrap/stem_client_test.dart @@ -122,6 +122,39 @@ void main() { await client.close(); }); + test('StemClient exposes task and group status helpers', () async { + final taskHandler = FunctionTaskHandler( + name: 'client.status.task', + entrypoint: (context, args) async => 'status-ok', + runInIsolate: false, + ); + final client = await StemClient.inMemory(tasks: [taskHandler]); + final worker = await client.createWorker(); + await worker.start(); + + final taskId = await client.enqueue('client.status.task'); + final taskStatus = await client.waitForTask( + taskId, + timeout: const Duration(seconds: 2), + ); + expect(taskStatus?.value, 'status-ok'); + expect((await client.getTaskStatus(taskId))?.state, TaskState.succeeded); + + final dispatch = await client.createCanvas().group([ + task('client.status.task', args: const {}), + ]); + try { + final groupStatus = await _waitForClientGroupStatus( + () => client.getGroupStatus(dispatch.groupId), + ); + expect(groupStatus?.completed, 1); + } finally { + await dispatch.dispose(); + await worker.shutdown(); + await client.close(); + } + }); + test('StemClient createCanvas reuses shared registry and backend', () async { final client = await StemClient.inMemory( tasks: [ @@ -482,3 +515,19 @@ void main() { } }); } + +Future _waitForClientGroupStatus( + Future Function() lookup, { + Duration timeout = const Duration(seconds: 2), + Duration pollInterval = const Duration(milliseconds: 25), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + final status = await lookup(); + if (status != null && status.completed == status.expected) { + return status; + } + await Future.delayed(pollInterval); + } + return lookup(); +} From d3b81921b8f3397b326de5231d283cbeb9ef0c98 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 21:24:24 -0500 Subject: [PATCH 088/302] Use status helpers in developer docs --- .site/docs/core-concepts/canvas.md | 11 +++++---- .../getting-started/developer-environment.md | 2 +- packages/stem/CHANGELOG.md | 3 +++ .../lib/developer_environment.dart | 23 ++++++++++++++----- 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/.site/docs/core-concepts/canvas.md b/.site/docs/core-concepts/canvas.md index f20b8f6e..d8f01c1e 100644 --- a/.site/docs/core-concepts/canvas.md +++ b/.site/docs/core-concepts/canvas.md @@ -56,9 +56,9 @@ runs with `context.meta['chordResults']` populated. ``` If any branch fails, the callback is skipped and the chord group is marked as -failed. Inspect the latest group status via `StemApp.getGroupStatus(...)`, -`StemClient.getGroupStatus(...)`, or `backend.getGroup(chordId)` before -retrying. +failed. Inspect the latest group status via `StemApp.getGroupStatus(...)` or +`StemClient.getGroupStatus(...)` before retrying. If you are operating below +the runtime layer, read the raw backend directly. ## Dependency semantics @@ -74,8 +74,9 @@ retrying. - `Canvas.group` returns a `GroupDispatch` with a result stream for each child. - `Canvas.chord` preserves the original signature order when building `chordResults`, so you can map results back to inputs deterministically. -- `StemApp.getGroupStatus(...)`, `StemClient.getGroupStatus(...)`, or - `backend.getGroup(groupId)` return the latest status for each child task. +- `StemApp.getGroupStatus(...)` and `StemClient.getGroupStatus(...)` return the + latest status for each child task. Low-level integrations can still read the + raw backend directly. ## Removal semantics diff --git a/.site/docs/getting-started/developer-environment.md b/.site/docs/getting-started/developer-environment.md index 0c09d6fc..8765ad1f 100644 --- a/.site/docs/getting-started/developer-environment.md +++ b/.site/docs/getting-started/developer-environment.md @@ -115,7 +115,7 @@ pipelines and query progress from any process: ``` -Later, you can monitor status from any machine: +Later, you can monitor status from any machine with a lightweight client: ```dart file=/../packages/stem/example/docs_snippets/lib/developer_environment.dart#dev-env-status diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 728d793c..3edfafc3 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -121,6 +121,9 @@ - Added `getTaskStatus(...)` / `getGroupStatus(...)` to the shared `TaskResultCaller` surface so apps and clients can inspect task/group state without reaching into the raw result backend. +- Updated the developer environment and canvas docs to use the shared status + helpers as the default status-inspection path, keeping raw backend reads as + low-level guidance only. - Removed the remaining `client.stem` leak from the microservice enqueuer example and clarified in the README/docs that `FlowContext` and `WorkflowScriptStepContext` share the same child-workflow helper surface. diff --git a/packages/stem/example/docs_snippets/lib/developer_environment.dart b/packages/stem/example/docs_snippets/lib/developer_environment.dart index 4b8d781f..6b8027db 100644 --- a/packages/stem/example/docs_snippets/lib/developer_environment.dart +++ b/packages/stem/example/docs_snippets/lib/developer_environment.dart @@ -114,14 +114,25 @@ Future runCanvasFlows( // #region dev-env-status Future inspectChordStatus(String chordId) async { - final backend = await RedisResultBackend.connect( - _resolveRedisUrl( - Platform.environment['STEM_BROKER_URL']!, - Platform.environment['STEM_RESULT_BACKEND_URL'], - 1, + final config = StemConfig.fromEnvironment(Platform.environment); + final client = await StemClient.create( + broker: StemBrokerFactory( + create: () => RedisStreamsBroker.connect(config.brokerUrl, tls: config.tls), + dispose: (broker) => broker.close(), + ), + backend: StemBackendFactory( + create: () => RedisResultBackend.connect( + _resolveRedisUrl( + config.brokerUrl, + config.resultBackendUrl, + 1, + ), + tls: config.tls, + ), + dispose: (backend) => backend.close(), ), ); - final status = await backend.get(chordId); + final status = await client.getTaskStatus(chordId); print('Chord completion state: ${status?.state}'); } // #endregion dev-env-status From b7b5c37720c3dec8d7efa98a012fca8b09d393e3 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 21:26:18 -0500 Subject: [PATCH 089/302] Simplify canvas pattern examples --- packages/stem/CHANGELOG.md | 3 +++ .../canvas_patterns/chain_example.dart | 27 +++++++------------ .../canvas_patterns/chord_example.dart | 27 +++++++------------ .../canvas_patterns/group_example.dart | 27 +++++++------------ 4 files changed, 33 insertions(+), 51 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 3edfafc3..bddd3cf0 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -124,6 +124,9 @@ - Updated the developer environment and canvas docs to use the shared status helpers as the default status-inspection path, keeping raw backend reads as low-level guidance only. +- Simplified the `canvas_patterns` examples to use `StemApp.inMemory(...)`, + `app.canvas`, and the shared task/group status helpers instead of manual + broker/backend/worker wiring. - Removed the remaining `client.stem` leak from the microservice enqueuer example and clarified in the README/docs that `FlowContext` and `WorkflowScriptStepContext` share the same child-workflow helper surface. diff --git a/packages/stem/example/canvas_patterns/chain_example.dart b/packages/stem/example/canvas_patterns/chain_example.dart index 67e49084..20968897 100644 --- a/packages/stem/example/canvas_patterns/chain_example.dart +++ b/packages/stem/example/canvas_patterns/chain_example.dart @@ -3,8 +3,6 @@ import 'dart:async'; import 'package:stem/stem.dart'; Future main() async { - final broker = InMemoryBroker(); - final backend = InMemoryResultBackend(); final tasks = >[ FunctionTaskHandler( name: 'fetch.user', @@ -27,34 +25,29 @@ Future main() async { ), ]; - final worker = Worker( - broker: broker, - backend: backend, + final app = await StemApp.inMemory( tasks: tasks, - consumerName: 'chain-worker', - concurrency: 1, - prefetchMultiplier: 1, + workerConfig: const StemWorkerConfig( + consumerName: 'chain-worker', + concurrency: 1, + prefetchMultiplier: 1, + ), ); - await worker.start(); - - final canvas = Canvas(broker: broker, backend: backend, tasks: tasks); - final chainResult = await canvas.chain([ + final chainResult = await app.canvas.chain([ task('fetch.user'), task('enrich.user'), task('send.email'), ]); await _waitFor(() async { - final status = await backend.get(chainResult.finalTaskId); + final status = await app.getTaskStatus(chainResult.finalTaskId); return status?.state == TaskState.succeeded; }); - final status = await backend.get(chainResult.finalTaskId); + final status = await app.getTaskStatus(chainResult.finalTaskId); print('Chain completed with state: ${status?.state}'); - await worker.shutdown(); - await backend.close(); - await broker.close(); + await app.shutdown(); } Future _waitFor( diff --git a/packages/stem/example/canvas_patterns/chord_example.dart b/packages/stem/example/canvas_patterns/chord_example.dart index 4d9034a5..32f5e7e1 100644 --- a/packages/stem/example/canvas_patterns/chord_example.dart +++ b/packages/stem/example/canvas_patterns/chord_example.dart @@ -3,8 +3,6 @@ import 'dart:async'; import 'package:stem/stem.dart'; Future main() async { - final broker = InMemoryBroker(); - final backend = InMemoryResultBackend(); final tasks = >[ FunctionTaskHandler( name: 'fetch.metric', @@ -28,18 +26,15 @@ Future main() async { ), ]; - final worker = Worker( - broker: broker, - backend: backend, + final app = await StemApp.inMemory( tasks: tasks, - consumerName: 'chord-worker', - concurrency: 3, - prefetchMultiplier: 1, + workerConfig: const StemWorkerConfig( + consumerName: 'chord-worker', + concurrency: 3, + prefetchMultiplier: 1, + ), ); - await worker.start(); - - final canvas = Canvas(broker: broker, backend: backend, tasks: tasks); - final chordResult = await canvas.chord( + final chordResult = await app.canvas.chord( body: [ task('fetch.metric', args: {'value': 5}), task('fetch.metric', args: {'value': 7}), @@ -51,16 +46,14 @@ Future main() async { final callbackId = chordResult.callbackTaskId; await _waitFor(() async { - final status = await backend.get(callbackId); + final status = await app.getTaskStatus(callbackId); return status?.state == TaskState.succeeded; }); - final callbackStatus = await backend.get(callbackId); + final callbackStatus = await app.getTaskStatus(callbackId); print('Callback state: ${callbackStatus?.state}'); - await worker.shutdown(); - await backend.close(); - await broker.close(); + await app.shutdown(); } Future _waitFor( diff --git a/packages/stem/example/canvas_patterns/group_example.dart b/packages/stem/example/canvas_patterns/group_example.dart index 4ab069a8..031d60e2 100644 --- a/packages/stem/example/canvas_patterns/group_example.dart +++ b/packages/stem/example/canvas_patterns/group_example.dart @@ -1,8 +1,6 @@ import 'package:stem/stem.dart'; Future main() async { - final broker = InMemoryBroker(); - final backend = InMemoryResultBackend(); final tasks = >[ FunctionTaskHandler( name: 'square', @@ -14,20 +12,17 @@ Future main() async { ), ]; - final worker = Worker( - broker: broker, - backend: backend, + final app = await StemApp.inMemory( tasks: tasks, - consumerName: 'group-worker', - concurrency: 2, - prefetchMultiplier: 1, + workerConfig: const StemWorkerConfig( + consumerName: 'group-worker', + concurrency: 2, + prefetchMultiplier: 1, + ), ); - await worker.start(); - - final canvas = Canvas(broker: broker, backend: backend, tasks: tasks); const groupHandle = 'squares-demo'; - await backend.initGroup(GroupDescriptor(id: groupHandle, expected: 3)); - final dispatch = await canvas.group([ + await app.backend.initGroup(GroupDescriptor(id: groupHandle, expected: 3)); + final dispatch = await app.canvas.group([ task('square', args: {'value': 2}), task('square', args: {'value': 3}), task('square', args: {'value': 4}), @@ -38,11 +33,9 @@ Future main() async { .where((value) => value != null) .cast() .toList(); - final status = await backend.getGroup(groupHandle); + final status = await app.getGroupStatus(groupHandle); print('Group results: $squares (backend count: ${status?.results.length})'); await dispatch.dispose(); - await worker.shutdown(); - await backend.close(); - await broker.close(); + await app.shutdown(); } From c11dea552645ae2cc5d877c3c57838180c00a117 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 21:32:56 -0500 Subject: [PATCH 090/302] Add StemApp workflow bootstrap helper --- .site/docs/core-concepts/stem-builder.md | 15 ++- .site/docs/workflows/getting-started.md | 4 +- .site/docs/workflows/index.md | 7 +- packages/stem/CHANGELOG.md | 4 + packages/stem/lib/src/bootstrap/stem_app.dart | 12 +- .../stem/lib/src/bootstrap/workflow_app.dart | 106 ++++++++++++++++-- .../test/bootstrap/module_bootstrap_test.dart | 74 ++++++++++++ packages/stem_builder/README.md | 15 ++- packages/stem_builder/example/README.md | 3 +- 9 files changed, 216 insertions(+), 24 deletions(-) diff --git a/.site/docs/core-concepts/stem-builder.md b/.site/docs/core-concepts/stem-builder.md index 66b15115..134acde0 100644 --- a/.site/docs/core-concepts/stem-builder.md +++ b/.site/docs/core-concepts/stem-builder.md @@ -106,14 +106,21 @@ final stemApp = await StemApp.fromUrl( 'redis://localhost:6379', adapters: const [StemRedisAdapter()], module: stemModule, + workerConfig: StemWorkerConfig( + queue: 'workflow', + subscription: RoutingSubscription( + queues: ['workflow', 'default'], + ), + ), ); -final workflowApp = await StemWorkflowApp.create( - stemApp: stemApp, - module: stemModule, -); +final workflowApp = await stemApp.createWorkflowApp(); ``` +That shared-app path reuses the existing worker, so it only works when the +worker already covers the workflow queue plus the task queues your workflows +need. If you want automatic queue inference, prefer `StemClient`. + For task-only services, the same bundle works directly with `StemApp`: ```dart diff --git a/.site/docs/workflows/getting-started.md b/.site/docs/workflows/getting-started.md index b83628d3..cc66cb73 100644 --- a/.site/docs/workflows/getting-started.md +++ b/.site/docs/workflows/getting-started.md @@ -50,7 +50,9 @@ Use `StemClient` when one service wants to own broker, backend, and workflow setup in one place. The clean path there is `client.createWorkflowApp(...)`. If your service already owns a `StemApp`, layer workflows on top of it with -`StemWorkflowApp.create(stemApp: ..., flows: ..., scripts: ..., tasks: ...)`. +`stemApp.createWorkflowApp(...)`. That path reuses the current worker, so the +underlying app must already subscribe to the workflow queue plus the task +queues your workflows need. ## 5. Move to the right next page diff --git a/.site/docs/workflows/index.md b/.site/docs/workflows/index.md index ea4c9fc5..811554ac 100644 --- a/.site/docs/workflows/index.md +++ b/.site/docs/workflows/index.md @@ -75,6 +75,7 @@ final workflowApp = await client.createWorkflowApp( ); ``` -If your service already owns a `StemApp`, reuse it directly with -`StemWorkflowApp.create(stemApp: ..., flows: ..., scripts: ..., tasks: ...)` -rather than bootstrapping a second broker/backend/task boundary. +If your service already owns a `StemApp`, layer workflows on top of it with +`stemApp.createWorkflowApp(...)`. That path reuses the existing worker, so the +app must already subscribe to the workflow queue plus any task queues the +workflows need. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index bddd3cf0..20e40058 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -127,6 +127,10 @@ - Simplified the `canvas_patterns` examples to use `StemApp.inMemory(...)`, `app.canvas`, and the shared task/group status helpers instead of manual broker/backend/worker wiring. +- Added `StemApp.createWorkflowApp(...)` and made `StemWorkflowApp.create( + stemApp: ...)` reuse `stemApp.module` by default while failing fast when the + reused worker does not actually cover the workflow/task queues the runtime + needs. - Removed the remaining `client.stem` leak from the microservice enqueuer example and clarified in the README/docs that `FlowContext` and `WorkflowScriptStepContext` share the same child-workflow helper surface. diff --git a/packages/stem/lib/src/bootstrap/stem_app.dart b/packages/stem/lib/src/bootstrap/stem_app.dart index ffaa2813..1d8f8b05 100644 --- a/packages/stem/lib/src/bootstrap/stem_app.dart +++ b/packages/stem/lib/src/bootstrap/stem_app.dart @@ -24,6 +24,7 @@ abstract interface class StemTaskApp implements TaskResultCaller {} /// Convenience bootstrap for setting up a Stem runtime with sensible defaults. class StemApp implements StemTaskApp { StemApp._({ + required this.module, required this.registry, required this.broker, required this.backend, @@ -43,6 +44,9 @@ class StemApp implements StemTaskApp { /// Task registry containing all registered handlers. final TaskRegistry registry; + /// Optional default bundle registered into this app. + final StemModule? module; + /// Active broker instance used by the helper. final Broker broker; @@ -290,6 +294,7 @@ class StemApp implements StemTaskApp { ]; return StemApp._( + module: module, registry: taskRegistry, broker: brokerInstance, backend: encodedBackend, @@ -446,13 +451,15 @@ class StemApp implements StemTaskApp { Iterable> tasks = const [], StemWorkerConfig workerConfig = const StemWorkerConfig(), }) async { - final bundledTasks = module?.tasks ?? const >[]; + final effectiveModule = module ?? client.module; + final bundledTasks = + effectiveModule?.tasks ?? const >[]; final allTasks = [...bundledTasks, ...tasks]; final taskRegistry = client.taskRegistry; registerModuleTaskHandlers(taskRegistry, allTasks); final inferredSubscription = workerConfig.subscription ?? - module?.inferTaskWorkerSubscription( + effectiveModule?.inferTaskWorkerSubscription( defaultQueue: workerConfig.queue, additionalTasks: tasks, ) ?? @@ -492,6 +499,7 @@ class StemApp implements StemTaskApp { ); return StemApp._( + module: effectiveModule, registry: taskRegistry, broker: client.broker, backend: client.backend, diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index c31d558a..e354ce44 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -399,16 +399,16 @@ class StemWorkflowApp TaskPayloadEncoder argsEncoder = const JsonTaskPayloadEncoder(), Iterable additionalEncoders = const [], }) async { - final moduleTasks = module?.tasks ?? const >[]; + final effectiveModule = module ?? stemApp?.module; + final moduleTasks = + effectiveModule?.tasks ?? const >[]; final moduleWorkflowDefinitions = - module?.workflowDefinitions ?? const []; - final resolvedWorkerConfig = stemApp == null - ? _resolveWorkflowWorkerConfig( - workerConfig, - module: module, - tasks: tasks, - ) - : workerConfig; + effectiveModule?.workflowDefinitions ?? const []; + final resolvedWorkerConfig = _resolveWorkflowWorkerConfig( + workerConfig, + module: effectiveModule, + tasks: tasks, + ); final appInstance = stemApp ?? await StemApp.create( @@ -420,6 +420,12 @@ class StemWorkflowApp argsEncoder: argsEncoder, additionalEncoders: additionalEncoders, ); + if (stemApp != null) { + _validateReusableStemApp( + appInstance, + resolvedWorkerConfig, + ); + } final storeFactoryInstance = storeFactory ?? WorkflowStoreFactory.inMemory(); @@ -650,6 +656,88 @@ class StemWorkflowApp } } +/// Convenience helpers for layering workflows onto an existing [StemApp]. +extension StemAppWorkflowExtension on StemApp { + /// Creates a workflow app on top of this shared task app. + /// + /// This reuses the existing broker/backend/worker wiring, so the current + /// worker must already subscribe to the workflow queue and any task queues + /// required by the supplied module or tasks. + Future createWorkflowApp({ + StemModule? module, + Iterable workflows = const [], + Iterable flows = const [], + Iterable scripts = const [], + Iterable> tasks = const [], + WorkflowStoreFactory? storeFactory, + WorkflowEventBusFactory? eventBusFactory, + StemWorkerConfig workerConfig = const StemWorkerConfig(queue: 'workflow'), + Duration pollInterval = const Duration(milliseconds: 500), + Duration leaseExtension = const Duration(seconds: 30), + WorkflowRegistry? workflowRegistry, + WorkflowIntrospectionSink? introspectionSink, + }) { + return StemWorkflowApp.create( + module: module ?? this.module, + workflows: workflows, + flows: flows, + scripts: scripts, + tasks: tasks, + stemApp: this, + storeFactory: storeFactory, + eventBusFactory: eventBusFactory, + workerConfig: workerConfig, + pollInterval: pollInterval, + leaseExtension: leaseExtension, + workflowRegistry: workflowRegistry, + introspectionSink: introspectionSink, + ); + } +} + +void _validateReusableStemApp( + StemApp app, + StemWorkerConfig workerConfig, +) { + final requiredQueues = workerConfig.subscription?.resolveQueues( + workerConfig.queue, + ) ?? + [workerConfig.queue]; + final workerQueues = app.worker.subscriptionQueues.toSet(); + final missingQueues = requiredQueues + .map((queue) => queue.trim()) + .where((queue) => queue.isNotEmpty) + .where((queue) => !workerQueues.contains(queue)) + .toList(growable: false); + + final requiredBroadcasts = + workerConfig.subscription?.broadcastChannels ?? const []; + final workerBroadcasts = app.worker.subscriptionBroadcasts.toSet(); + final missingBroadcasts = requiredBroadcasts + .map((channel) => channel.trim()) + .where((channel) => channel.isNotEmpty) + .where((channel) => !workerBroadcasts.contains(channel)) + .toList(growable: false); + + if (missingQueues.isEmpty && missingBroadcasts.isEmpty) { + return; + } + + final details = [ + if (missingQueues.isNotEmpty) 'queues=${missingQueues.join(",")}', + if (missingBroadcasts.isNotEmpty) + 'broadcasts=${missingBroadcasts.join(",")}', + ].join(' '); + + throw StateError( + 'StemWorkflowApp.create(stemApp: ...) requires the reused StemApp worker ' + 'to already subscribe to the workflow/runtime queues it needs ($details). ' + 'Create the StemApp with a matching workerConfig.subscription, or use ' + 'StemClient.createWorkflowApp(...) / StemWorkflowApp.inMemory(...) so ' + 'subscriptions can be inferred automatically.', + ); +} + StemWorkerConfig _resolveWorkflowWorkerConfig( StemWorkerConfig workerConfig, { StemModule? module, diff --git a/packages/stem/test/bootstrap/module_bootstrap_test.dart b/packages/stem/test/bootstrap/module_bootstrap_test.dart index 42c5b125..6c14c9df 100644 --- a/packages/stem/test/bootstrap/module_bootstrap_test.dart +++ b/packages/stem/test/bootstrap/module_bootstrap_test.dart @@ -92,5 +92,79 @@ void main() { await client.close(); } }); + + test('StemApp.createWorkflowApp reuses its default module', () async { + final moduleTask = FunctionTaskHandler( + name: 'module.app.workflow-task', + options: const TaskOptions(queue: 'priority'), + entrypoint: (context, args) async => 'task-ok', + runInIsolate: false, + ); + final moduleFlow = Flow( + name: 'module.app.workflow', + build: (builder) { + builder.step('hello', (ctx) async => 'module-ok'); + }, + ); + final stemApp = await StemApp.inMemory( + module: StemModule(flows: [moduleFlow], tasks: [moduleTask]), + workerConfig: StemWorkerConfig( + queue: 'workflow', + subscription: RoutingSubscription( + queues: ['workflow', 'priority'], + ), + ), + ); + + final workflowApp = await stemApp.createWorkflowApp(); + await workflowApp.start(); + try { + expect( + workflowApp.app.registry.resolve('module.app.workflow-task'), + same(moduleTask), + ); + + final runId = await workflowApp.startWorkflow('module.app.workflow'); + final result = await workflowApp.waitForCompletion( + runId, + timeout: const Duration(seconds: 2), + ); + + expect(result?.value, 'module-ok'); + } finally { + await workflowApp.close(); + } + }); + + test( + 'StemWorkflowApp.create rejects reused StemApp without workflow queue ' + 'coverage', + () async { + final moduleFlow = Flow( + name: 'module.app.missing-workflow-queue', + build: (builder) { + builder.step('hello', (ctx) async => 'module-ok'); + }, + ); + final stemApp = await StemApp.inMemory( + module: StemModule(flows: [moduleFlow]), + ); + + try { + await expectLater( + stemApp.createWorkflowApp, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('reused StemApp worker'), + ), + ), + ); + } finally { + await stemApp.close(); + } + }, + ); }); } diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index 0e49e11c..b50fd7bf 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -214,14 +214,21 @@ final stemApp = await StemApp.fromUrl( 'redis://localhost:6379', adapters: const [StemRedisAdapter()], module: stemModule, + workerConfig: StemWorkerConfig( + queue: 'workflow', + subscription: RoutingSubscription( + queues: ['workflow', 'default'], + ), + ), ); -final workflowApp = await StemWorkflowApp.create( - stemApp: stemApp, - module: stemModule, -); +final workflowApp = await stemApp.createWorkflowApp(); ``` +That shared-app path only works when the existing `StemApp` worker already +subscribes to the workflow queue plus any task queues the workflows need. +If you want subscription inference, prefer `StemClient.createWorkflowApp()`. + For task-only services, use the same bundle directly with `StemApp`: ```dart diff --git a/packages/stem_builder/example/README.md b/packages/stem_builder/example/README.md index 294d7443..1f44af32 100644 --- a/packages/stem_builder/example/README.md +++ b/packages/stem_builder/example/README.md @@ -37,5 +37,6 @@ The generated bundle is the default integration surface: - `StemWorkflowApp.inMemory(module: stemModule)` - `StemWorkflowApp.fromUrl(..., module: stemModule)` -- `StemWorkflowApp.create(stemApp: ..., module: stemModule)` +- `stemApp.createWorkflowApp()` when the shared app already covers the workflow + queue - `StemClient.fromUrl(..., module: stemModule)` + `createWorkflowApp()` From 645350044129c68f87f9f1dfdabd9309eaa68bbf Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 21:35:29 -0500 Subject: [PATCH 091/302] Auto-initialize custom canvas group IDs --- packages/stem/CHANGELOG.md | 2 ++ .../canvas_patterns/group_example.dart | 1 - packages/stem/lib/src/canvas/canvas.dart | 2 +- .../stem/test/unit/canvas/canvas_test.dart | 21 +++++++++++++++++++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 20e40058..87db1c45 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -131,6 +131,8 @@ stemApp: ...)` reuse `stemApp.module` by default while failing fast when the reused worker does not actually cover the workflow/task queues the runtime needs. +- Made `Canvas.group(..., groupId: ...)` auto-initialize missing groups so + custom group ids no longer require a manual `backend.initGroup(...)` first. - Removed the remaining `client.stem` leak from the microservice enqueuer example and clarified in the README/docs that `FlowContext` and `WorkflowScriptStepContext` share the same child-workflow helper surface. diff --git a/packages/stem/example/canvas_patterns/group_example.dart b/packages/stem/example/canvas_patterns/group_example.dart index 031d60e2..37e95003 100644 --- a/packages/stem/example/canvas_patterns/group_example.dart +++ b/packages/stem/example/canvas_patterns/group_example.dart @@ -21,7 +21,6 @@ Future main() async { ), ); const groupHandle = 'squares-demo'; - await app.backend.initGroup(GroupDescriptor(id: groupHandle, expected: 3)); final dispatch = await app.canvas.group([ task('square', args: {'value': 2}), task('square', args: {'value': 3}), diff --git a/packages/stem/lib/src/canvas/canvas.dart b/packages/stem/lib/src/canvas/canvas.dart index c9dac1ce..5e79500c 100644 --- a/packages/stem/lib/src/canvas/canvas.dart +++ b/packages/stem/lib/src/canvas/canvas.dart @@ -331,7 +331,7 @@ class Canvas { String? groupId, }) async { final id = groupId ?? _generateId('grp'); - if (groupId == null) { + if (groupId == null || await backend.getGroup(id) == null) { await backend.initGroup( GroupDescriptor(id: id, expected: signatures.length), ); diff --git a/packages/stem/test/unit/canvas/canvas_test.dart b/packages/stem/test/unit/canvas/canvas_test.dart index 09b904da..42023d26 100644 --- a/packages/stem/test/unit/canvas/canvas_test.dart +++ b/packages/stem/test/unit/canvas/canvas_test.dart @@ -56,6 +56,27 @@ void main() { await dispatch.dispose(); }); + test('group auto-initializes an explicit groupId when missing', () async { + const groupId = 'group-explicit-id'; + final dispatch = await canvas.group([ + task('echo', args: {'value': 4}), + task('echo', args: {'value': 6}), + ], groupId: groupId); + + final received = await dispatch.results + .map((result) => result.value) + .toList(); + final typed = received.whereType().toList(); + expect(typed, containsAll([4, 6])); + + final group = await backend.getGroup(groupId); + expect(group, isNotNull); + expect(group!.expected, equals(2)); + expect(group.results.length, equals(2)); + + await dispatch.dispose(); + }); + test('chain returns typed payload', () async { final result = await canvas.chain([ task('echo', args: {'value': 1}), From d4a7e687a4dfa880991249f203987ac9f1157059 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 21:41:00 -0500 Subject: [PATCH 092/302] Add workflow run detail app helper --- packages/stem/CHANGELOG.md | 2 ++ .../example/annotated_workflows/bin/main.dart | 4 +-- .../stem/example/ecommerce/lib/src/app.dart | 2 +- .../stem/lib/src/bootstrap/workflow_app.dart | 9 +++++++ .../stem/test/bootstrap/stem_app_test.dart | 27 ++++++++++++++++++- 5 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 87db1c45..af9340f2 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -133,6 +133,8 @@ needs. - Made `Canvas.group(..., groupId: ...)` auto-initialize missing groups so custom group ids no longer require a manual `backend.initGroup(...)` first. +- Added `StemWorkflowApp.viewRunDetail(...)` so app-level workflow inspection + no longer needs to reach through `workflowApp.runtime`. - Removed the remaining `client.stem` leak from the microservice enqueuer example and clarified in the README/docs that `FlowContext` and `WorkflowScriptStepContext` share the same child-workflow helper surface. diff --git a/packages/stem/example/annotated_workflows/bin/main.dart b/packages/stem/example/annotated_workflows/bin/main.dart index 15b86fed..afeecaa1 100644 --- a/packages/stem/example/annotated_workflows/bin/main.dart +++ b/packages/stem/example/annotated_workflows/bin/main.dart @@ -26,7 +26,7 @@ Future main() async { ); print('Script result: ${jsonEncode(scriptResult?.value?.toJson())}'); - final scriptDetail = await app.runtime.viewRunDetail(scriptResult!.runId); + final scriptDetail = await app.viewRunDetail(scriptResult!.runId); final scriptCheckpoints = scriptDetail?.checkpoints .map((checkpoint) => checkpoint.baseCheckpointName) .join(' -> '); @@ -50,7 +50,7 @@ Future main() async { ); print('Context script result: ${jsonEncode(contextResult?.value?.toJson())}'); - final contextDetail = await app.runtime.viewRunDetail(contextResult!.runId); + final contextDetail = await app.viewRunDetail(contextResult!.runId); final contextCheckpoints = contextDetail?.checkpoints .map((checkpoint) => checkpoint.baseCheckpointName) .join(' -> '); diff --git a/packages/stem/example/ecommerce/lib/src/app.dart b/packages/stem/example/ecommerce/lib/src/app.dart index 991910cf..c0c0c30b 100644 --- a/packages/stem/example/ecommerce/lib/src/app.dart +++ b/packages/stem/example/ecommerce/lib/src/app.dart @@ -167,7 +167,7 @@ class EcommerceServer { return _json(200, {'order': order}); }) ..get('/runs/', (Request request, String runId) async { - final detail = await workflowApp.runtime.viewRunDetail(runId); + final detail = await workflowApp.viewRunDetail(runId); if (detail == null) { return _error(404, 'Workflow run not found.', {'runId': runId}); } diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index e354ce44..638b5735 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -32,6 +32,7 @@ import 'package:stem/src/workflow/core/workflow_store.dart'; import 'package:stem/src/workflow/runtime/workflow_introspection.dart'; import 'package:stem/src/workflow/runtime/workflow_registry.dart'; import 'package:stem/src/workflow/runtime/workflow_runtime.dart'; +import 'package:stem/src/workflow/runtime/workflow_views.dart'; /// Helper that bootstraps a workflow runtime on top of [StemApp]. /// @@ -259,6 +260,14 @@ class StemWorkflowApp /// ``` Future getRun(String runId) => store.get(runId); + /// Returns the combined run + checkpoint detail view for [runId]. + /// + /// This is a convenience wrapper over [WorkflowRuntime.viewRunDetail] so + /// callers do not need to reach through [runtime] for common inspection. + Future viewRunDetail(String runId) { + return runtime.viewRunDetail(runId); + } + /// Polls the workflow store until the run reaches a terminal state. /// /// When the workflow completes successfully the persisted result is surfaced diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index 45dc0b40..b37028cd 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -63,7 +63,7 @@ void main() { expect((await app.getTaskStatus(taskId))?.state, TaskState.succeeded); final dispatch = await app.canvas.group([ - task('test.status.task', args: const {}), + task('test.status.task'), ]); try { final groupStatus = await _waitForGroupStatus( @@ -684,6 +684,31 @@ void main() { } }); + test('StemWorkflowApp exposes run detail helper', () async { + final flow = Flow( + name: 'workflow.detail.helper', + build: (builder) { + builder.step('hello', (ctx) async => 'detail-ok'); + }, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + final runId = await workflowApp.startWorkflow('workflow.detail.helper'); + final result = await workflowApp.waitForCompletion( + runId, + timeout: const Duration(seconds: 2), + ); + expect(result?.value, 'detail-ok'); + + final detail = await workflowApp.viewRunDetail(runId); + expect(detail, isNotNull); + expect(detail!.run.runId, equals(runId)); + } finally { + await workflowApp.shutdown(); + } + }); + test( 'workflow codecs persist encoded checkpoints and decode typed results', () async { From 4342d20fb77940b53aee7afbaa678f34674fc651 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 21:43:41 -0500 Subject: [PATCH 093/302] Add workflow execute-run app helper --- packages/stem/CHANGELOG.md | 2 ++ packages/stem/example/durable_watchers.dart | 4 +-- packages/stem/example/persistent_sleep.dart | 4 +-- .../example/workflows/versioned_rewind.dart | 4 +-- .../stem/lib/src/bootstrap/workflow_app.dart | 9 +++++++ .../stem/test/bootstrap/stem_app_test.dart | 25 +++++++++++++++++++ 6 files changed, 42 insertions(+), 6 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index af9340f2..8fb6055b 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -135,6 +135,8 @@ custom group ids no longer require a manual `backend.initGroup(...)` first. - Added `StemWorkflowApp.viewRunDetail(...)` so app-level workflow inspection no longer needs to reach through `workflowApp.runtime`. +- Added `StemWorkflowApp.executeRun(...)` so examples and app code can drive a + workflow run directly without reaching through `workflowApp.runtime`. - Removed the remaining `client.stem` leak from the microservice enqueuer example and clarified in the README/docs that `FlowContext` and `WorkflowScriptStepContext` share the same child-workflow helper surface. diff --git a/packages/stem/example/durable_watchers.dart b/packages/stem/example/durable_watchers.dart index ac938067..8cd610d6 100644 --- a/packages/stem/example/durable_watchers.dart +++ b/packages/stem/example/durable_watchers.dart @@ -47,7 +47,7 @@ Future main() async { .startWith(app); // Drive the run until it suspends on the watcher. - await app.runtime.executeRun(runId); + await app.executeRun(runId); final watchers = await app.store.listWatchers(shipmentReadyEvent.topic); for (final watcher in watchers) { @@ -62,7 +62,7 @@ Future main() async { const _ShipmentReadyEvent(trackingId: 'ZX-42'), ); - await app.runtime.executeRun(runId); + await app.executeRun(runId); final completed = await shipmentWorkflowRef.waitFor(app, runId); print('Workflow completed with result: ${completed?.value}'); diff --git a/packages/stem/example/persistent_sleep.dart b/packages/stem/example/persistent_sleep.dart index 38a70e24..9c61cff0 100644 --- a/packages/stem/example/persistent_sleep.dart +++ b/packages/stem/example/persistent_sleep.dart @@ -27,7 +27,7 @@ Future main() async { ); final runId = await sleepLoopRef.startWith(app); - await app.runtime.executeRun(runId); + await app.executeRun(runId); // After the delay elapses, the runtime should resume without the step // manually inspecting resume data. @@ -36,7 +36,7 @@ Future main() async { for (final id in due) { final state = await app.store.get(id); await app.store.markResumed(id, data: state?.suspensionData); - await app.runtime.executeRun(id); + await app.executeRun(id); } final completed = await sleepLoopRef.waitFor(app, runId); diff --git a/packages/stem/example/workflows/versioned_rewind.dart b/packages/stem/example/workflows/versioned_rewind.dart index f010a887..dbbe267c 100644 --- a/packages/stem/example/workflows/versioned_rewind.dart +++ b/packages/stem/example/workflows/versioned_rewind.dart @@ -20,12 +20,12 @@ Future main() async { ); final runId = await versionedWorkflowRef.startWith(app); - await app.runtime.executeRun(runId); + await app.executeRun(runId); // Rewind and execute again to append a new iteration checkpoint. await app.store.rewindToStep(runId, 'repeat'); await app.store.markRunning(runId); - await app.runtime.executeRun(runId); + await app.executeRun(runId); final entries = await app.store.listSteps(runId); for (final entry in entries) { diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index 638b5735..d85bd57a 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -268,6 +268,15 @@ class StemWorkflowApp return runtime.viewRunDetail(runId); } + /// Executes the workflow run identified by [runId]. + /// + /// This is a convenience wrapper over [WorkflowRuntime.executeRun] for + /// examples and application code that need direct run driving without + /// reaching through [runtime]. + Future executeRun(String runId) { + return runtime.executeRun(runId); + } + /// Polls the workflow store until the run reaches a terminal state. /// /// When the workflow completes successfully the persisted result is surfaced diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index b37028cd..2eb5ff77 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -709,6 +709,31 @@ void main() { } }); + test('StemWorkflowApp exposes executeRun helper', () async { + final flow = Flow( + name: 'workflow.execute.helper', + build: (builder) { + builder.step('hello', (ctx) async => 'execute-ok'); + }, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + final runId = await workflowApp.startWorkflow( + 'workflow.execute.helper', + ); + await workflowApp.executeRun(runId); + + final result = await workflowApp.waitForCompletion( + runId, + timeout: const Duration(seconds: 2), + ); + expect(result?.value, 'execute-ok'); + } finally { + await workflowApp.shutdown(); + } + }); + test( 'workflow codecs persist encoded checkpoints and decode typed results', () async { From 5a2c6a68869ca47a8be8e6893f00c2a8cb434c30 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 21:46:47 -0500 Subject: [PATCH 094/302] Add workflow run view app helpers --- packages/stem/CHANGELOG.md | 3 ++ .../stem/lib/src/bootstrap/workflow_app.dart | 25 ++++++++++++++ .../stem/test/bootstrap/stem_app_test.dart | 34 +++++++++++++++++++ .../example/bin/runtime_metadata_views.dart | 10 +++--- 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 8fb6055b..e9017a10 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -137,6 +137,9 @@ no longer needs to reach through `workflowApp.runtime`. - Added `StemWorkflowApp.executeRun(...)` so examples and app code can drive a workflow run directly without reaching through `workflowApp.runtime`. +- Added `StemWorkflowApp.viewRun(...)`, `viewCheckpoints(...)`, and + `listRunViews(...)` so app-level workflow inspection can stay on the app + surface instead of reaching through `workflowApp.runtime`. - Removed the remaining `client.stem` leak from the microservice enqueuer example and clarified in the README/docs that `FlowContext` and `WorkflowScriptStepContext` share the same child-workflow helper surface. diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index d85bd57a..ced15df0 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -260,6 +260,16 @@ class StemWorkflowApp /// ``` Future getRun(String runId) => store.get(runId); + /// Returns the normalized run view for [runId], or `null` if not found. + Future viewRun(String runId) { + return runtime.viewRun(runId); + } + + /// Returns persisted checkpoint views for [runId]. + Future> viewCheckpoints(String runId) { + return runtime.viewCheckpoints(runId); + } + /// Returns the combined run + checkpoint detail view for [runId]. /// /// This is a convenience wrapper over [WorkflowRuntime.viewRunDetail] so @@ -268,6 +278,21 @@ class StemWorkflowApp return runtime.viewRunDetail(runId); } + /// Returns normalized workflow run views filtered by workflow/status. + Future> listRunViews({ + String? workflow, + WorkflowStatus? status, + int limit = 50, + int offset = 0, + }) { + return runtime.listRunViews( + workflow: workflow, + status: status, + limit: limit, + offset: offset, + ); + } + /// Executes the workflow run identified by [runId]. /// /// This is a convenience wrapper over [WorkflowRuntime.executeRun] for diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index 2eb5ff77..501c9e55 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -709,6 +709,40 @@ void main() { } }); + test('StemWorkflowApp exposes run view helpers', () async { + final flow = Flow( + name: 'workflow.views.helper', + build: (builder) { + builder.step('hello', (ctx) async => 'views-ok'); + }, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + final runId = await workflowApp.startWorkflow('workflow.views.helper'); + final result = await workflowApp.waitForCompletion( + runId, + timeout: const Duration(seconds: 2), + ); + expect(result?.value, 'views-ok'); + + final runView = await workflowApp.viewRun(runId); + expect(runView, isNotNull); + expect(runView!.runId, equals(runId)); + + final checkpoints = await workflowApp.viewCheckpoints(runId); + expect(checkpoints, hasLength(1)); + expect(checkpoints.single.baseCheckpointName, equals('hello')); + + final runViews = await workflowApp.listRunViews( + workflow: 'workflow.views.helper', + ); + expect(runViews.map((view) => view.runId), contains(runId)); + } finally { + await workflowApp.shutdown(); + } + }); + test('StemWorkflowApp exposes executeRun helper', () async { final flow = Flow( name: 'workflow.execute.helper', diff --git a/packages/stem_builder/example/bin/runtime_metadata_views.dart b/packages/stem_builder/example/bin/runtime_metadata_views.dart index e42151d5..63602bfc 100644 --- a/packages/stem_builder/example/bin/runtime_metadata_views.dart +++ b/packages/stem_builder/example/bin/runtime_metadata_views.dart @@ -29,16 +29,16 @@ Future main() async { runtime, 'runtime metadata', ); - await runtime.executeRun(flowRunId); + await app.executeRun(flowRunId); final scriptRunId = await StemWorkflowDefinitions.userSignup .startWith( runtime, 'dev@stem.dev', ); - await runtime.executeRun(scriptRunId); + await app.executeRun(scriptRunId); - final runViews = await runtime.listRunViews(limit: 10); + final runViews = await app.listRunViews(limit: 10); print('\n--- Run views ---'); print( const JsonEncoder.withIndent( @@ -46,8 +46,8 @@ Future main() async { ).convert(runViews.map((view) => view.toJson()).toList()), ); - final flowDetail = await runtime.viewRunDetail(flowRunId); - final scriptDetail = await runtime.viewRunDetail(scriptRunId); + final flowDetail = await app.viewRunDetail(flowRunId); + final scriptDetail = await app.viewRunDetail(scriptRunId); print('\n--- Flow run detail ---'); print(const JsonEncoder.withIndent(' ').convert(flowDetail?.toJson())); From b9fabee5db090894a169fc55fd242b3d4046f7e9 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 21:49:42 -0500 Subject: [PATCH 095/302] Add workflow manifest app helper --- packages/stem/CHANGELOG.md | 2 ++ .../stem/lib/src/bootstrap/workflow_app.dart | 6 ++++++ .../stem/test/bootstrap/stem_app_test.dart | 21 +++++++++++++++++++ packages/stem_builder/example/bin/main.dart | 7 +++---- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index e9017a10..4d1c542c 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -140,6 +140,8 @@ - Added `StemWorkflowApp.viewRun(...)`, `viewCheckpoints(...)`, and `listRunViews(...)` so app-level workflow inspection can stay on the app surface instead of reaching through `workflowApp.runtime`. +- Added `StemWorkflowApp.workflowManifest()` so manifest inspection can stay on + the app surface instead of reaching through `workflowApp.runtime`. - Removed the remaining `client.stem` leak from the microservice enqueuer example and clarified in the README/docs that `FlowContext` and `WorkflowScriptStepContext` share the same child-workflow helper surface. diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index ced15df0..ff2d5036 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -30,6 +30,7 @@ import 'package:stem/src/workflow/core/workflow_script.dart'; import 'package:stem/src/workflow/core/workflow_status.dart'; import 'package:stem/src/workflow/core/workflow_store.dart'; import 'package:stem/src/workflow/runtime/workflow_introspection.dart'; +import 'package:stem/src/workflow/runtime/workflow_manifest.dart'; import 'package:stem/src/workflow/runtime/workflow_registry.dart'; import 'package:stem/src/workflow/runtime/workflow_runtime.dart'; import 'package:stem/src/workflow/runtime/workflow_views.dart'; @@ -293,6 +294,11 @@ class StemWorkflowApp ); } + /// Returns the manifest entries for workflows registered with this app. + List workflowManifest() { + return runtime.workflowManifest(); + } + /// Executes the workflow run identified by [runId]. /// /// This is a convenience wrapper over [WorkflowRuntime.executeRun] for diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index 501c9e55..04ca5ed2 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -709,6 +709,27 @@ void main() { } }); + test('StemWorkflowApp exposes workflow manifest helper', () async { + final flow = Flow( + name: 'workflow.manifest.helper', + build: (builder) { + builder.step('hello', (ctx) async => 'manifest-ok'); + }, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + final manifest = workflowApp.workflowManifest(); + final entry = manifest.singleWhere( + (item) => item.name == 'workflow.manifest.helper', + ); + expect(entry.kind, equals(WorkflowDefinitionKind.flow)); + expect(entry.steps.single.name, equals('hello')); + } finally { + await workflowApp.shutdown(); + } + }); + test('StemWorkflowApp exposes run view helpers', () async { final flow = Flow( name: 'workflow.views.helper', diff --git a/packages/stem_builder/example/bin/main.dart b/packages/stem_builder/example/bin/main.dart index a7bff21c..d1b9b7d5 100644 --- a/packages/stem_builder/example/bin/main.dart +++ b/packages/stem_builder/example/bin/main.dart @@ -18,8 +18,7 @@ Future main() async { final app = await StemWorkflowApp.inMemory(module: stemModule); try { - final runtime = app.runtime; - final runtimeManifest = runtime + final runtimeManifest = app .workflowManifest() .map((entry) => entry.toJson()) .toList(growable: false); @@ -27,10 +26,10 @@ Future main() async { print(const JsonEncoder.withIndent(' ').convert(runtimeManifest)); final runId = await StemWorkflowDefinitions.flow.startWith( - runtime, + app, 'Stem Builder', ); - await runtime.executeRun(runId); + await app.executeRun(runId); final result = await StemWorkflowDefinitions.flow.waitFor( app, runId, From 1746032fb636870abb928b9ccbbb0c682f14f4d5 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 21:52:49 -0500 Subject: [PATCH 096/302] Add workflow module registration helper --- packages/stem/CHANGELOG.md | 3 ++ .../example/docs_snippets/lib/workflows.dart | 5 +-- .../stem/lib/src/bootstrap/workflow_app.dart | 12 +++++- .../stem/test/bootstrap/stem_app_test.dart | 40 +++++++++++++++++++ 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 4d1c542c..a4450e97 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -142,6 +142,9 @@ surface instead of reaching through `workflowApp.runtime`. - Added `StemWorkflowApp.workflowManifest()` so manifest inspection can stay on the app surface instead of reaching through `workflowApp.runtime`. +- Added `StemWorkflowApp.registerModule(...)` so manual builder/module + registration no longer needs to reach through `app.runtime.registry` and + `app.app.registry`. - Removed the remaining `client.stem` leak from the microservice enqueuer example and clarified in the README/docs that `FlowContext` and `WorkflowScriptStepContext` share the same child-workflow helper surface. diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index ca894091..2678abb3 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -232,10 +232,7 @@ Future sendEmail( Future registerAnnotatedDefinitions(StemWorkflowApp app) async { // Generated by stem_builder. - stemModule.registerInto( - workflows: app.runtime.registry, - tasks: app.app.registry, - ); + app.registerModule(stemModule); } // #endregion workflows-annotated diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index ff2d5036..d0608332 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -261,6 +261,15 @@ class StemWorkflowApp /// ``` Future getRun(String runId) => store.get(runId); + /// Registers all tasks and workflows from [module] into this app. + /// + /// This is a convenience helper for manual registration flows that need to + /// attach generated definitions after bootstrap. + void registerModule(StemModule module) { + registerModuleTaskHandlers(app.registry, module.tasks); + module.registerInto(workflows: runtime.registry); + } + /// Returns the normalized run view for [runId], or `null` if not found. Future viewRun(String runId) { return runtime.viewRun(runId); @@ -748,7 +757,8 @@ void _validateReusableStemApp( StemApp app, StemWorkerConfig workerConfig, ) { - final requiredQueues = workerConfig.subscription?.resolveQueues( + final requiredQueues = + workerConfig.subscription?.resolveQueues( workerConfig.queue, ) ?? [workerConfig.queue]; diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index 04ca5ed2..9e266358 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -730,6 +730,46 @@ void main() { } }); + test( + 'StemWorkflowApp registers module definitions after bootstrap', + () async { + final taskHandler = FunctionTaskHandler.inline( + name: 'workflow.module.task', + entrypoint: (context, args) async => 'module-task-ok', + ); + final flow = Flow( + name: 'workflow.module.flow', + build: (builder) { + builder.step('hello', (ctx) async => 'module-flow-ok'); + }, + ); + final module = StemModule(flows: [flow], tasks: [taskHandler]); + + final workflowApp = await StemWorkflowApp.inMemory(); + try { + workflowApp.registerModule(module); + + expect( + workflowApp.app.registry.resolve('workflow.module.task'), + isNotNull, + ); + expect( + workflowApp.runtime.registry.lookup('workflow.module.flow'), + isNotNull, + ); + + final runId = await workflowApp.startWorkflow('workflow.module.flow'); + final workflowResult = await workflowApp.waitForCompletion( + runId, + timeout: const Duration(seconds: 2), + ); + expect(workflowResult?.value, equals('module-flow-ok')); + } finally { + await workflowApp.shutdown(); + } + }, + ); + test('StemWorkflowApp exposes run view helpers', () async { final flow = Flow( name: 'workflow.views.helper', From 9cbfab8d1db64f73ca967d5e11f0a9180d08bc8d Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 21:54:22 -0500 Subject: [PATCH 097/302] Add workflow rewind app helper --- packages/stem/CHANGELOG.md | 3 ++ .../example/workflows/versioned_rewind.dart | 9 +++-- .../stem/lib/src/bootstrap/workflow_app.dart | 9 +++++ .../stem/test/bootstrap/stem_app_test.dart | 33 +++++++++++++++++++ 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index a4450e97..76c68c3f 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -145,6 +145,9 @@ - Added `StemWorkflowApp.registerModule(...)` so manual builder/module registration no longer needs to reach through `app.runtime.registry` and `app.app.registry`. +- Added `StemWorkflowApp.rewindToCheckpoint(...)` so replay-oriented flows no + longer need to call `store.rewindToStep(...)` and `store.markRunning(...)` + directly. - Removed the remaining `client.stem` leak from the microservice enqueuer example and clarified in the README/docs that `FlowContext` and `WorkflowScriptStepContext` share the same child-workflow helper surface. diff --git a/packages/stem/example/workflows/versioned_rewind.dart b/packages/stem/example/workflows/versioned_rewind.dart index dbbe267c..5f5b3c03 100644 --- a/packages/stem/example/workflows/versioned_rewind.dart +++ b/packages/stem/example/workflows/versioned_rewind.dart @@ -23,13 +23,12 @@ Future main() async { await app.executeRun(runId); // Rewind and execute again to append a new iteration checkpoint. - await app.store.rewindToStep(runId, 'repeat'); - await app.store.markRunning(runId); + await app.rewindToCheckpoint(runId, 'repeat'); await app.executeRun(runId); - final entries = await app.store.listSteps(runId); - for (final entry in entries) { - print('${entry.name}: ${entry.value}'); + final checkpoints = await app.viewCheckpoints(runId); + for (final checkpoint in checkpoints) { + print('${checkpoint.checkpointName}: ${checkpoint.value}'); } print('Iterations executed: $iterations'); final completed = await versionedWorkflowRef.waitFor(app, runId); diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index d0608332..34d0154c 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -317,6 +317,15 @@ class StemWorkflowApp return runtime.executeRun(runId); } + /// Rewinds [runId] to [checkpointName] and marks it runnable again. + /// + /// This is a convenience wrapper for replay-oriented workflows that need to + /// resume execution from an earlier persisted checkpoint. + Future rewindToCheckpoint(String runId, String checkpointName) async { + await store.rewindToStep(runId, checkpointName); + await store.markRunning(runId); + } + /// Polls the workflow store until the run reaches a terminal state. /// /// When the workflow completes successfully the persisted result is surfaced diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index 9e266358..adbc3931 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -829,6 +829,39 @@ void main() { } }); + test('StemWorkflowApp exposes rewind helper', () async { + final iterations = []; + final flow = Flow( + name: 'workflow.rewind.helper', + build: (builder) { + builder + ..step('repeat', (ctx) async { + iterations.add(ctx.iteration); + return 'iteration-${ctx.iteration}'; + }, autoVersion: true) + ..step('tail', (ctx) async => ctx.previousResult! as String); + }, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + final runId = await workflowApp.startWorkflow('workflow.rewind.helper'); + await workflowApp.executeRun(runId); + + await workflowApp.rewindToCheckpoint(runId, 'repeat'); + await workflowApp.executeRun(runId); + + final checkpoints = await workflowApp.viewCheckpoints(runId); + expect( + checkpoints.map((checkpoint) => checkpoint.checkpointName), + containsAll(['repeat#0', 'tail']), + ); + expect(iterations, equals([0, 0])); + } finally { + await workflowApp.shutdown(); + } + }); + test( 'workflow codecs persist encoded checkpoints and decode typed results', () async { From 1bc8e88cb0e24ab047b13899b0d344bfae3995f9 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 21:56:22 -0500 Subject: [PATCH 098/302] Add workflow watcher app helper --- packages/stem/CHANGELOG.md | 2 ++ packages/stem/example/durable_watchers.dart | 2 +- .../stem/lib/src/bootstrap/workflow_app.dart | 6 ++++ .../stem/test/bootstrap/stem_app_test.dart | 33 +++++++++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 76c68c3f..054df505 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -148,6 +148,8 @@ - Added `StemWorkflowApp.rewindToCheckpoint(...)` so replay-oriented flows no longer need to call `store.rewindToStep(...)` and `store.markRunning(...)` directly. +- Added `StemWorkflowApp.listWatchers(...)` so event-watcher inspection no + longer needs to reach through `app.store`. - Removed the remaining `client.stem` leak from the microservice enqueuer example and clarified in the README/docs that `FlowContext` and `WorkflowScriptStepContext` share the same child-workflow helper surface. diff --git a/packages/stem/example/durable_watchers.dart b/packages/stem/example/durable_watchers.dart index 8cd610d6..6fb47442 100644 --- a/packages/stem/example/durable_watchers.dart +++ b/packages/stem/example/durable_watchers.dart @@ -49,7 +49,7 @@ Future main() async { // Drive the run until it suspends on the watcher. await app.executeRun(runId); - final watchers = await app.store.listWatchers(shipmentReadyEvent.topic); + final watchers = await app.listWatchers(shipmentReadyEvent.topic); for (final watcher in watchers) { print( 'Run ${watcher.runId} waiting on ${watcher.topic} (step ${watcher.stepName})', diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index 34d0154c..67b6b3f3 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -29,6 +29,7 @@ import 'package:stem/src/workflow/core/workflow_result.dart'; import 'package:stem/src/workflow/core/workflow_script.dart'; import 'package:stem/src/workflow/core/workflow_status.dart'; import 'package:stem/src/workflow/core/workflow_store.dart'; +import 'package:stem/src/workflow/core/workflow_watcher.dart'; import 'package:stem/src/workflow/runtime/workflow_introspection.dart'; import 'package:stem/src/workflow/runtime/workflow_manifest.dart'; import 'package:stem/src/workflow/runtime/workflow_registry.dart'; @@ -326,6 +327,11 @@ class StemWorkflowApp await store.markRunning(runId); } + /// Lists event watchers registered for [topic]. + Future> listWatchers(String topic) { + return store.listWatchers(topic); + } + /// Polls the workflow store until the run reaches a terminal state. /// /// When the workflow completes successfully the persisted result is surfaced diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index adbc3931..1a8f2120 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -862,6 +862,39 @@ void main() { } }); + test('StemWorkflowApp exposes watcher helper', () async { + final script = WorkflowScript( + name: 'workflow.watchers.helper', + run: (script) async { + final payload = await script.step('wait', (step) async { + await step.awaitEvent( + 'watchers.helper.topic', + deadline: DateTime.now().add(const Duration(minutes: 5)), + ); + return 'waiting'; + }); + return payload; + }, + ); + + final workflowApp = await StemWorkflowApp.inMemory(scripts: [script]); + try { + final runId = await workflowApp.startWorkflow( + 'workflow.watchers.helper', + ); + await workflowApp.executeRun(runId); + + final watchers = await workflowApp.listWatchers( + 'watchers.helper.topic', + ); + expect(watchers, hasLength(1)); + expect(watchers.single.runId, equals(runId)); + expect(watchers.single.stepName, equals('wait')); + } finally { + await workflowApp.shutdown(); + } + }); + test( 'workflow codecs persist encoded checkpoints and decode typed results', () async { From 271feb40c4a9ea8dc50dafb5407e6c6e80ecc3fd Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 21:57:44 -0500 Subject: [PATCH 099/302] Add workflow due-run resume helper --- packages/stem/CHANGELOG.md | 3 ++ packages/stem/example/persistent_sleep.dart | 4 +- .../stem/lib/src/bootstrap/workflow_app.dart | 12 ++++++ .../stem/test/bootstrap/stem_app_test.dart | 41 +++++++++++++++++++ 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 054df505..c241f6d8 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -150,6 +150,9 @@ directly. - Added `StemWorkflowApp.listWatchers(...)` so event-watcher inspection no longer needs to reach through `app.store`. +- Added `StemWorkflowApp.resumeDueRuns(...)` so sleep-based workflows no longer + need to call `store.dueRuns(...)`, `store.get(...)`, and `store.markResumed( + ...)` directly. - Removed the remaining `client.stem` leak from the microservice enqueuer example and clarified in the README/docs that `FlowContext` and `WorkflowScriptStepContext` share the same child-workflow helper surface. diff --git a/packages/stem/example/persistent_sleep.dart b/packages/stem/example/persistent_sleep.dart index 9c61cff0..8177c280 100644 --- a/packages/stem/example/persistent_sleep.dart +++ b/packages/stem/example/persistent_sleep.dart @@ -32,10 +32,8 @@ Future main() async { // After the delay elapses, the runtime should resume without the step // manually inspecting resume data. await Future.delayed(const Duration(milliseconds: 150)); - final due = await app.store.dueRuns(DateTime.now()); + final due = await app.resumeDueRuns(DateTime.now()); for (final id in due) { - final state = await app.store.get(id); - await app.store.markResumed(id, data: state?.suspensionData); await app.executeRun(id); } diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index 67b6b3f3..91e015c6 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -332,6 +332,18 @@ class StemWorkflowApp return store.listWatchers(topic); } + /// Marks all runs due at [now] as resumed and returns their ids. + Future> resumeDueRuns([DateTime? now]) async { + final due = await store.dueRuns(now ?? DateTime.now()); + final resumed = []; + for (final runId in due) { + final state = await store.get(runId); + await store.markResumed(runId, data: state?.suspensionData); + resumed.add(runId); + } + return resumed; + } + /// Polls the workflow store until the run reaches a terminal state. /// /// When the workflow completes successfully the persisted result is surfaced diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index 1a8f2120..b387a73c 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -895,6 +895,47 @@ void main() { } }); + test('StemWorkflowApp exposes due-run resume helper', () async { + var iterations = 0; + final flow = Flow( + name: 'workflow.resume.due.helper', + build: (builder) { + builder.step('loop', (ctx) async { + iterations += 1; + if (iterations == 1) { + ctx.sleep(const Duration(milliseconds: 25)); + return 'waiting'; + } + return 'resumed'; + }); + }, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + final runId = await workflowApp.startWorkflow( + 'workflow.resume.due.helper', + ); + await workflowApp.executeRun(runId); + + await Future.delayed(const Duration(milliseconds: 35)); + final resumed = await workflowApp.resumeDueRuns(DateTime.now()); + expect(resumed, contains(runId)); + + for (final id in resumed) { + await workflowApp.executeRun(id); + } + + final result = await workflowApp.waitForCompletion( + runId, + timeout: const Duration(seconds: 2), + ); + expect(result?.value, equals('resumed')); + } finally { + await workflowApp.shutdown(); + } + }); + test( 'workflow codecs persist encoded checkpoints and decode typed results', () async { From 993e50387c2636e68380dd798d46611acd4db3ad Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 21:59:41 -0500 Subject: [PATCH 100/302] Add workflow registration app helper --- packages/stem/CHANGELOG.md | 2 ++ .../example/docs_snippets/lib/workflows.dart | 2 +- .../stem/lib/src/bootstrap/workflow_app.dart | 5 ++++ .../stem/test/bootstrap/stem_app_test.dart | 25 +++++++++++++++++++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index c241f6d8..5f2dd7ca 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -145,6 +145,8 @@ - Added `StemWorkflowApp.registerModule(...)` so manual builder/module registration no longer needs to reach through `app.runtime.registry` and `app.app.registry`. +- Added `StemWorkflowApp.registerWorkflow(...)` so manual workflow definition + registration no longer needs to reach through `workflowApp.runtime`. - Added `StemWorkflowApp.rewindToCheckpoint(...)` so replay-oriented flows no longer need to call `store.rewindToStep(...)` and `store.markRunning(...)` directly. diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index 2678abb3..60e83daf 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -93,7 +93,7 @@ class ApprovalsFlow { } Future registerFlow(StemWorkflowApp workflowApp) async { - workflowApp.runtime.registerWorkflow(ApprovalsFlow.flow.definition); + workflowApp.registerWorkflow(ApprovalsFlow.flow.definition); } // #endregion workflows-flow diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index 91e015c6..9fe2dde4 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -271,6 +271,11 @@ class StemWorkflowApp module.registerInto(workflows: runtime.registry); } + /// Registers [definition] into this app's workflow registry. + void registerWorkflow(WorkflowDefinition definition) { + runtime.registerWorkflow(definition); + } + /// Returns the normalized run view for [runId], or `null` if not found. Future viewRun(String runId) { return runtime.viewRun(runId); diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index b387a73c..426a14ed 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -770,6 +770,31 @@ void main() { }, ); + test('StemWorkflowApp exposes workflow registration helper', () async { + final flow = Flow( + name: 'workflow.register.helper', + build: (builder) { + builder.step('hello', (ctx) async => 'register-ok'); + }, + ); + + final workflowApp = await StemWorkflowApp.inMemory(); + try { + workflowApp.registerWorkflow(flow.definition); + + final runId = await workflowApp.startWorkflow( + 'workflow.register.helper', + ); + final result = await workflowApp.waitForCompletion( + runId, + timeout: const Duration(seconds: 2), + ); + expect(result?.value, equals('register-ok')); + } finally { + await workflowApp.shutdown(); + } + }); + test('StemWorkflowApp exposes run view helpers', () async { final flow = Flow( name: 'workflow.views.helper', From 4ea1817ad295de114d8b4ea47a5615031dec1eb3 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 22:03:48 -0500 Subject: [PATCH 101/302] Add flow and script registration app helpers --- packages/stem/CHANGELOG.md | 2 + .../example/docs_snippets/lib/workflows.dart | 2 +- .../stem/lib/src/bootstrap/workflow_app.dart | 10 ++++ .../stem/test/bootstrap/stem_app_test.dart | 46 +++++++++++++++++++ 4 files changed, 59 insertions(+), 1 deletion(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 5f2dd7ca..687c7bdc 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -147,6 +147,8 @@ `app.app.registry`. - Added `StemWorkflowApp.registerWorkflow(...)` so manual workflow definition registration no longer needs to reach through `workflowApp.runtime`. +- Added `StemWorkflowApp.registerFlow(...)` and `registerScript(...)` so manual + flow/script registration no longer needs to pass `.definition`. - Added `StemWorkflowApp.rewindToCheckpoint(...)` so replay-oriented flows no longer need to call `store.rewindToStep(...)` and `store.markRunning(...)` directly. diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index 60e83daf..5762216e 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -93,7 +93,7 @@ class ApprovalsFlow { } Future registerFlow(StemWorkflowApp workflowApp) async { - workflowApp.registerWorkflow(ApprovalsFlow.flow.definition); + workflowApp.registerFlow(ApprovalsFlow.flow); } // #endregion workflows-flow diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index 9fe2dde4..ad587d25 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -276,6 +276,16 @@ class StemWorkflowApp runtime.registerWorkflow(definition); } + /// Registers [flow] into this app's workflow registry. + void registerFlow(Flow flow) { + registerWorkflow(flow.definition); + } + + /// Registers [script] into this app's workflow registry. + void registerScript(WorkflowScript script) { + registerWorkflow(script.definition); + } + /// Returns the normalized run view for [runId], or `null` if not found. Future viewRun(String runId) { return runtime.viewRun(runId); diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index 426a14ed..49da5c66 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -795,6 +795,52 @@ void main() { } }); + test( + 'StemWorkflowApp exposes flow and script registration helpers', + () async { + final flow = Flow( + name: 'workflow.register.flow.helper', + build: (builder) { + builder.step('hello', (ctx) async => 'flow-register-ok'); + }, + ); + final script = WorkflowScript( + name: 'workflow.register.script.helper', + run: (script) => script.step( + 'hello', + (step) async => 'script-register-ok', + ), + ); + + final workflowApp = await StemWorkflowApp.inMemory(); + try { + workflowApp + ..registerFlow(flow) + ..registerScript(script); + + final flowRunId = await workflowApp.startWorkflow( + 'workflow.register.flow.helper', + ); + final flowResult = await workflowApp.waitForCompletion( + flowRunId, + timeout: const Duration(seconds: 2), + ); + expect(flowResult?.value, equals('flow-register-ok')); + + final scriptRunId = await workflowApp.startWorkflow( + 'workflow.register.script.helper', + ); + final scriptResult = await workflowApp.waitForCompletion( + scriptRunId, + timeout: const Duration(seconds: 2), + ); + expect(scriptResult?.value, equals('script-register-ok')); + } finally { + await workflowApp.shutdown(); + } + }, + ); + test('StemWorkflowApp exposes run view helpers', () async { final flow = Flow( name: 'workflow.views.helper', From dda2a48e0abd17dd90824fb86dcc66cf3bcd57ad Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 22:10:39 -0500 Subject: [PATCH 102/302] Add custom workflow queue bootstrap options --- packages/stem/CHANGELOG.md | 3 + .../stem/lib/src/bootstrap/stem_client.dart | 4 + .../stem/lib/src/bootstrap/stem_module.dart | 16 +++ .../stem/lib/src/bootstrap/workflow_app.dart | 32 ++++++ .../workflow_module_bootstrap_test.dart | 105 ++++++++++++++++++ 5 files changed, 160 insertions(+) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 687c7bdc..17036d4a 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -149,6 +149,9 @@ registration no longer needs to reach through `workflowApp.runtime`. - Added `StemWorkflowApp.registerFlow(...)` and `registerScript(...)` so manual flow/script registration no longer needs to pass `.definition`. +- Added `continuationQueue:` and `executionQueue:` to `StemWorkflowApp` + bootstrap helpers so custom workflow channel wiring no longer needs manual + `WorkflowRuntime` construction. - Added `StemWorkflowApp.rewindToCheckpoint(...)` so replay-oriented flows no longer need to call `store.rewindToStep(...)` and `store.markRunning(...)` directly. diff --git a/packages/stem/lib/src/bootstrap/stem_client.dart b/packages/stem/lib/src/bootstrap/stem_client.dart index 1dd9083a..0c308c08 100644 --- a/packages/stem/lib/src/bootstrap/stem_client.dart +++ b/packages/stem/lib/src/bootstrap/stem_client.dart @@ -304,6 +304,8 @@ abstract class StemClient implements TaskResultCaller { WorkflowStoreFactory? storeFactory, WorkflowEventBusFactory? eventBusFactory, StemWorkerConfig workerConfig = const StemWorkerConfig(queue: 'workflow'), + String? continuationQueue, + String? executionQueue, Duration pollInterval = const Duration(milliseconds: 500), Duration leaseExtension = const Duration(seconds: 30), WorkflowIntrospectionSink? introspectionSink, @@ -318,6 +320,8 @@ abstract class StemClient implements TaskResultCaller { storeFactory: storeFactory, eventBusFactory: eventBusFactory, workerConfig: workerConfig, + continuationQueue: continuationQueue, + executionQueue: executionQueue, pollInterval: pollInterval, leaseExtension: leaseExtension, introspectionSink: introspectionSink, diff --git a/packages/stem/lib/src/bootstrap/stem_module.dart b/packages/stem/lib/src/bootstrap/stem_module.dart index a634dfb9..6a57df47 100644 --- a/packages/stem/lib/src/bootstrap/stem_module.dart +++ b/packages/stem/lib/src/bootstrap/stem_module.dart @@ -93,6 +93,8 @@ class StemModule { /// runnable when the inferred queues are used to bootstrap a worker. List inferredWorkerQueues({ String workflowQueue = 'workflow', + String? continuationQueue, + String? executionQueue, Iterable> additionalTasks = const [], }) { final queues = SplayTreeSet(); @@ -100,6 +102,16 @@ class StemModule { if (normalizedWorkflowQueue.isNotEmpty) { queues.add(normalizedWorkflowQueue); } + final normalizedContinuationQueue = continuationQueue?.trim(); + if (normalizedContinuationQueue != null && + normalizedContinuationQueue.isNotEmpty) { + queues.add(normalizedContinuationQueue); + } + final normalizedExecutionQueue = executionQueue?.trim(); + if (normalizedExecutionQueue != null && + normalizedExecutionQueue.isNotEmpty) { + queues.add(normalizedExecutionQueue); + } void addTaskQueue(TaskHandler handler) { final queue = handler.options.queue.trim(); @@ -119,10 +131,14 @@ class StemModule { /// worker's default queue configuration to remain unchanged. RoutingSubscription? inferWorkerSubscription({ String workflowQueue = 'workflow', + String? continuationQueue, + String? executionQueue, Iterable> additionalTasks = const [], }) { final queues = inferredWorkerQueues( workflowQueue: workflowQueue, + continuationQueue: continuationQueue, + executionQueue: executionQueue, additionalTasks: additionalTasks, ); if (queues.length <= 1) { diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index ad587d25..a5b604aa 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -490,6 +490,8 @@ class StemWorkflowApp WorkflowStoreFactory? storeFactory, WorkflowEventBusFactory? eventBusFactory, StemWorkerConfig workerConfig = const StemWorkerConfig(queue: 'workflow'), + String? continuationQueue, + String? executionQueue, Duration pollInterval = const Duration(milliseconds: 500), Duration leaseExtension = const Duration(seconds: 30), WorkflowRegistry? workflowRegistry, @@ -508,6 +510,8 @@ class StemWorkflowApp workerConfig, module: effectiveModule, tasks: tasks, + continuationQueue: continuationQueue, + executionQueue: executionQueue, ); final appInstance = stemApp ?? @@ -540,6 +544,8 @@ class StemWorkflowApp pollInterval: pollInterval, leaseExtension: leaseExtension, queue: resolvedWorkerConfig.queue, + continuationQueue: continuationQueue, + executionQueue: executionQueue, registry: workflowRegistry, introspectionSink: introspectionSink, ); @@ -588,6 +594,8 @@ class StemWorkflowApp Iterable scripts = const [], Iterable> tasks = const [], StemWorkerConfig workerConfig = const StemWorkerConfig(queue: 'workflow'), + String? continuationQueue, + String? executionQueue, Duration pollInterval = const Duration(milliseconds: 500), Duration leaseExtension = const Duration(seconds: 30), WorkflowRegistry? workflowRegistry, @@ -608,6 +616,8 @@ class StemWorkflowApp storeFactory: WorkflowStoreFactory.inMemory(), eventBusFactory: WorkflowEventBusFactory.inMemory(), workerConfig: workerConfig, + continuationQueue: continuationQueue, + executionQueue: executionQueue, pollInterval: pollInterval, leaseExtension: leaseExtension, workflowRegistry: workflowRegistry, @@ -636,6 +646,8 @@ class StemWorkflowApp Iterable adapters = const [], StemStoreOverrides overrides = const StemStoreOverrides(), StemWorkerConfig workerConfig = const StemWorkerConfig(queue: 'workflow'), + String? continuationQueue, + String? executionQueue, bool uniqueTasks = false, Duration uniqueTaskDefaultTtl = const Duration(minutes: 5), String uniqueTaskNamespace = 'stem:unique', @@ -656,6 +668,8 @@ class StemWorkflowApp workerConfig, module: module, tasks: tasks, + continuationQueue: continuationQueue, + executionQueue: executionQueue, ); final stack = StemStack.fromUrl( url, @@ -693,6 +707,8 @@ class StemWorkflowApp storeFactory: stack.workflowStore, eventBusFactory: eventBusFactory, workerConfig: resolvedWorkerConfig, + continuationQueue: continuationQueue, + executionQueue: executionQueue, pollInterval: pollInterval, leaseExtension: leaseExtension, workflowRegistry: workflowRegistry, @@ -726,6 +742,8 @@ class StemWorkflowApp WorkflowStoreFactory? storeFactory, WorkflowEventBusFactory? eventBusFactory, StemWorkerConfig workerConfig = const StemWorkerConfig(queue: 'workflow'), + String? continuationQueue, + String? executionQueue, Duration pollInterval = const Duration(milliseconds: 500), Duration leaseExtension = const Duration(seconds: 30), WorkflowIntrospectionSink? introspectionSink, @@ -734,6 +752,8 @@ class StemWorkflowApp workerConfig, module: module, tasks: tasks, + continuationQueue: continuationQueue, + executionQueue: executionQueue, ); final appInstance = await StemApp.fromClient( client, @@ -748,6 +768,8 @@ class StemWorkflowApp storeFactory: storeFactory, eventBusFactory: eventBusFactory, workerConfig: resolvedWorkerConfig, + continuationQueue: continuationQueue, + executionQueue: executionQueue, pollInterval: pollInterval, leaseExtension: leaseExtension, workflowRegistry: client.workflowRegistry, @@ -772,6 +794,8 @@ extension StemAppWorkflowExtension on StemApp { WorkflowStoreFactory? storeFactory, WorkflowEventBusFactory? eventBusFactory, StemWorkerConfig workerConfig = const StemWorkerConfig(queue: 'workflow'), + String? continuationQueue, + String? executionQueue, Duration pollInterval = const Duration(milliseconds: 500), Duration leaseExtension = const Duration(seconds: 30), WorkflowRegistry? workflowRegistry, @@ -787,6 +811,8 @@ extension StemAppWorkflowExtension on StemApp { storeFactory: storeFactory, eventBusFactory: eventBusFactory, workerConfig: workerConfig, + continuationQueue: continuationQueue, + executionQueue: executionQueue, pollInterval: pollInterval, leaseExtension: leaseExtension, workflowRegistry: workflowRegistry, @@ -843,6 +869,8 @@ StemWorkerConfig _resolveWorkflowWorkerConfig( StemWorkerConfig workerConfig, { StemModule? module, Iterable> tasks = const [], + String? continuationQueue, + String? executionQueue, }) { if (workerConfig.subscription != null) { return workerConfig; @@ -851,12 +879,16 @@ StemWorkerConfig _resolveWorkflowWorkerConfig( final inferredSubscription = module?.inferWorkerSubscription( workflowQueue: workerConfig.queue, + continuationQueue: continuationQueue, + executionQueue: executionQueue, additionalTasks: tasks, ) ?? (() { final tempModule = StemModule(tasks: tasks); return tempModule.inferWorkerSubscription( workflowQueue: workerConfig.queue, + continuationQueue: continuationQueue, + executionQueue: executionQueue, ); })(); diff --git a/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart b/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart index e16219c3..ea47422a 100644 --- a/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart +++ b/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart @@ -32,6 +32,69 @@ void main() { } }); + test( + 'StemWorkflowApp.inMemory infers continuation and execution queues', + () async { + final helperTask = FunctionTaskHandler( + name: 'workflow.module.custom-queues-helper', + entrypoint: (context, args) async => 'queued-ok', + runInIsolate: false, + ); + final workflowApp = await StemWorkflowApp.inMemory( + module: StemModule(tasks: [helperTask]), + continuationQueue: 'workflow-continue', + executionQueue: 'workflow-step', + ); + try { + expect( + workflowApp.app.worker.subscription.queues, + unorderedEquals([ + 'workflow', + 'workflow-continue', + 'workflow-step', + 'default', + ]), + ); + } finally { + await workflowApp.shutdown(); + } + }, + ); + + test( + 'StemClient.createWorkflowApp forwards continuation and execution ' + 'queues', + () async { + final helperTask = FunctionTaskHandler( + name: 'workflow.module.client-custom-queues-helper', + entrypoint: (context, args) async => 'queued-ok', + runInIsolate: false, + ); + final client = await StemClient.inMemory( + module: StemModule(tasks: [helperTask]), + ); + + final workflowApp = await client.createWorkflowApp( + continuationQueue: 'workflow-continue', + executionQueue: 'workflow-step', + ); + try { + expect( + workflowApp.app.worker.subscription.queues, + unorderedEquals([ + 'workflow', + 'workflow-continue', + 'workflow-step', + 'default', + ]), + ); + } finally { + await workflowApp.shutdown(); + await client.close(); + } + }, + ); + test( 'explicit workflow subscription overrides inferred module queues', () async { @@ -54,5 +117,47 @@ void main() { } }, ); + + test( + 'StemApp.createWorkflowApp rejects missing continuation/execution ' + 'coverage', + () async { + final helperTask = FunctionTaskHandler( + name: 'workflow.module.missing-custom-queues-helper', + entrypoint: (context, args) async => 'queued-ok', + runInIsolate: false, + ); + final stemApp = await StemApp.inMemory( + module: StemModule(tasks: [helperTask]), + workerConfig: StemWorkerConfig( + queue: 'workflow', + subscription: RoutingSubscription( + queues: ['workflow', 'default'], + ), + ), + ); + + try { + await expectLater( + () => stemApp.createWorkflowApp( + continuationQueue: 'workflow-continue', + executionQueue: 'workflow-step', + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + allOf( + contains('workflow-continue'), + contains('workflow-step'), + ), + ), + ), + ); + } finally { + await stemApp.close(); + } + }, + ); }); } From c5a2346775798812f834c366d80d5433dedc3581 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 22:12:51 -0500 Subject: [PATCH 103/302] Clarify workflow happy-path docs --- packages/stem/CHANGELOG.md | 3 +++ packages/stem/README.md | 13 +++++++------ packages/stem_builder/CHANGELOG.md | 2 ++ packages/stem_builder/README.md | 5 +++-- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 17036d4a..de58e11d 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -152,6 +152,9 @@ - Added `continuationQueue:` and `executionQueue:` to `StemWorkflowApp` bootstrap helpers so custom workflow channel wiring no longer needs manual `WorkflowRuntime` construction. +- Clarified the README workflow guidance to prefer `StemWorkflowApp` + helpers in the happy path and reserve direct `WorkflowRuntime` usage for + low-level scenarios. - Added `StemWorkflowApp.rewindToCheckpoint(...)` so replay-oriented flows no longer need to call `store.rewindToStep(...)` and `store.markRunning(...)` directly. diff --git a/packages/stem/README.md b/packages/stem/README.md index d7254423..b3bebda6 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -993,11 +993,12 @@ backend metadata under `stem.unique.duplicates`. - Awaited events behave the same way: the emitted payload is delivered via `takeResumeData()` / `takeResumeValue(codec: ...)` when the run resumes. -- When you have a DTO event, emit it through `runtime.emitValue(...)` / - `workflowApp.emitValue(...)` with a `PayloadCodec`, or bundle the topic - and codec once in a `WorkflowEventRef` and use `event.emitWith(...)` - together with `waitForEventRef(...)`. Event payloads still serialize onto the - existing `Map` wire format. +- When you have a DTO event, emit it through `workflowApp.emitValue(...)` (or + `runtime.emitValue(...)` when you are intentionally using the low-level + runtime) with a `PayloadCodec`, or bundle the topic and codec once in a + `WorkflowEventRef` and use `event.emitWith(...)` together with + `waitForEventRef(...)`. Event payloads still serialize onto the existing + `Map` wire format. - Only return values you want persisted. If a handler returns `null`, the runtime treats it as "no result yet" and will run the step again on resume. - Derive outbound idempotency tokens with `ctx.idempotencyKey('charge')` so @@ -1021,7 +1022,7 @@ flow.step( }, ); -final runId = await runtime.startWorkflow( +final runId = await workflowApp.startWorkflow( 'demo.workflow', params: const {'userId': '42'}, cancellationPolicy: const WorkflowCancellationPolicy( diff --git a/packages/stem_builder/CHANGELOG.md b/packages/stem_builder/CHANGELOG.md index d7ebb7f6..48887400 100644 --- a/packages/stem_builder/CHANGELOG.md +++ b/packages/stem_builder/CHANGELOG.md @@ -13,6 +13,8 @@ - Refreshed generated child-workflow examples and docs to use the unified `startWith(...)` / `startAndWaitWith(...)` helper surface inside durable workflow contexts. +- Clarified the builder README to treat direct `WorkflowRuntime` usage as a + low-level path and prefer app helpers for the common case. - Added typed workflow starter generation and app helper output for annotated workflow/task definitions. - Switched generated output to per-file `part` generation using `.stem.g.dart` diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index b50fd7bf..6399ef77 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -261,7 +261,8 @@ If you reuse an existing `StemApp`, its worker subscription stays in charge. Workflow-side queue inference only applies when `StemWorkflowApp` is creating the worker for you. -The generated workflow refs work on `WorkflowRuntime` too: +When you are intentionally using the low-level `WorkflowRuntime`, the +generated workflow refs work there too: ```dart final runtime = workflowApp.runtime; @@ -269,7 +270,7 @@ final runId = await StemWorkflowDefinitions.userSignup.startWith( runtime, 'user@example.com', ); -await runtime.executeRun(runId); +await workflowApp.executeRun(runId); ``` Annotated tasks also get generated definitions: From 56146a5713e09aa7b40cb4260f35229bdf4dbced Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 22:14:47 -0500 Subject: [PATCH 104/302] Refresh workflow docs snippets --- packages/stem/CHANGELOG.md | 2 ++ packages/stem/README.md | 4 +++- packages/stem/example/docs_snippets/lib/workflows.dart | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index de58e11d..314a7fbd 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -155,6 +155,8 @@ - Clarified the README workflow guidance to prefer `StemWorkflowApp` helpers in the happy path and reserve direct `WorkflowRuntime` usage for low-level scenarios. +- Refreshed workflow docs snippets to match the app-level workflow helper + surface, including `resumeDueRuns(...)` guidance for fake-clock tests. - Added `StemWorkflowApp.rewindToCheckpoint(...)` so replay-oriented flows no longer need to call `store.rewindToStep(...)` and `store.markRunning(...)` directly. diff --git a/packages/stem/README.md b/packages/stem/README.md index b3bebda6..56eba3e0 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -1113,7 +1113,9 @@ expect(recorded.name, 'tasks.email'); ``` - `FakeWorkflowClock` keeps workflow tests deterministic. Inject the same clock - into your runtime and store, then advance it directly instead of sleeping: + into your runtime and store, then advance it directly instead of sleeping. + At the app layer, prefer `workflowApp.resumeDueRuns(clock.now())` once that + same fake clock is wired into the store backing the app: ```dart final clock = FakeWorkflowClock(DateTime.utc(2024, 1, 1)); diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index 5762216e..7321beea 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -29,7 +29,7 @@ ApprovalDraft _decodeApprovalDraft(Object? payload) { } // #region workflows-runtime -Future bootstrapWorkflowRuntime() async { +Future bootstrapWorkflowApp() async { // #region workflows-app-create final workflowApp = await StemWorkflowApp.fromUrl( 'redis://127.0.0.1:56379', From 38093371ebee1d3b1e691ddcba8eae3fc415b0cc Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 22:16:32 -0500 Subject: [PATCH 105/302] Add bulk workflow registration app helpers --- packages/stem/CHANGELOG.md | 2 + .../example/docs_snippets/lib/workflows.dart | 6 ++- .../stem/lib/src/bootstrap/workflow_app.dart | 10 ++++ .../stem/test/bootstrap/stem_app_test.dart | 46 +++++++++++++++++++ 4 files changed, 63 insertions(+), 1 deletion(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 314a7fbd..cac547b1 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -149,6 +149,8 @@ registration no longer needs to reach through `workflowApp.runtime`. - Added `StemWorkflowApp.registerFlow(...)` and `registerScript(...)` so manual flow/script registration no longer needs to pass `.definition`. +- Added `StemWorkflowApp.registerFlows(...)` and `registerScripts(...)` so + manual bulk registration no longer needs repeated helper calls. - Added `continuationQueue:` and `executionQueue:` to `StemWorkflowApp` bootstrap helpers so custom workflow channel wiring no longer needs manual `WorkflowRuntime` construction. diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index 7321beea..6f5bed1c 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -93,7 +93,7 @@ class ApprovalsFlow { } Future registerFlow(StemWorkflowApp workflowApp) async { - workflowApp.registerFlow(ApprovalsFlow.flow); + workflowApp.registerFlows([ApprovalsFlow.flow]); } // #endregion workflows-flow @@ -121,6 +121,10 @@ final retryScript = WorkflowScript( ); final retryDefinition = retryScript.definition; + +Future registerScript(StemWorkflowApp workflowApp) async { + workflowApp.registerScripts([retryScript]); +} // #endregion workflows-script // #region workflows-run diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index a5b604aa..d06f99bf 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -281,11 +281,21 @@ class StemWorkflowApp registerWorkflow(flow.definition); } + /// Registers [flows] into this app's workflow registry. + void registerFlows(Iterable flows) { + flows.forEach(registerFlow); + } + /// Registers [script] into this app's workflow registry. void registerScript(WorkflowScript script) { registerWorkflow(script.definition); } + /// Registers [scripts] into this app's workflow registry. + void registerScripts(Iterable scripts) { + scripts.forEach(registerScript); + } + /// Returns the normalized run view for [runId], or `null` if not found. Future viewRun(String runId) { return runtime.viewRun(runId); diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index 49da5c66..5b71dae4 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -841,6 +841,52 @@ void main() { }, ); + test( + 'StemWorkflowApp exposes bulk flow and script registration helpers', + () async { + final flow = Flow( + name: 'workflow.register.flows.helper', + build: (builder) { + builder.step('hello', (ctx) async => 'flows-register-ok'); + }, + ); + final script = WorkflowScript( + name: 'workflow.register.scripts.helper', + run: (script) => script.step( + 'hello', + (step) async => 'scripts-register-ok', + ), + ); + + final workflowApp = await StemWorkflowApp.inMemory(); + try { + workflowApp + ..registerFlows([flow]) + ..registerScripts([script]); + + final flowRunId = await workflowApp.startWorkflow( + 'workflow.register.flows.helper', + ); + final flowResult = await workflowApp.waitForCompletion( + flowRunId, + timeout: const Duration(seconds: 2), + ); + expect(flowResult?.value, equals('flows-register-ok')); + + final scriptRunId = await workflowApp.startWorkflow( + 'workflow.register.scripts.helper', + ); + final scriptResult = await workflowApp.waitForCompletion( + scriptRunId, + timeout: const Duration(seconds: 2), + ); + expect(scriptResult?.value, equals('scripts-register-ok')); + } finally { + await workflowApp.shutdown(); + } + }, + ); + test('StemWorkflowApp exposes run view helpers', () async { final flow = Flow( name: 'workflow.views.helper', From 9b89b2d941d0884737e420b1382b1520a0420a42 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 22:17:28 -0500 Subject: [PATCH 106/302] Add bulk definition registration app helper --- packages/stem/CHANGELOG.md | 2 ++ .../example/docs_snippets/lib/workflows.dart | 4 +++ .../stem/lib/src/bootstrap/workflow_app.dart | 5 ++++ .../stem/test/bootstrap/stem_app_test.dart | 25 +++++++++++++++++++ 4 files changed, 36 insertions(+) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index cac547b1..39223c2f 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -149,6 +149,8 @@ registration no longer needs to reach through `workflowApp.runtime`. - Added `StemWorkflowApp.registerFlow(...)` and `registerScript(...)` so manual flow/script registration no longer needs to pass `.definition`. +- Added `StemWorkflowApp.registerWorkflows(...)` so manual bulk definition + registration no longer needs repeated helper calls. - Added `StemWorkflowApp.registerFlows(...)` and `registerScripts(...)` so manual bulk registration no longer needs repeated helper calls. - Added `continuationQueue:` and `executionQueue:` to `StemWorkflowApp` diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index 6f5bed1c..0b1c76e9 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -95,6 +95,10 @@ class ApprovalsFlow { Future registerFlow(StemWorkflowApp workflowApp) async { workflowApp.registerFlows([ApprovalsFlow.flow]); } + +Future registerWorkflowDefinition(StemWorkflowApp workflowApp) async { + workflowApp.registerWorkflows([ApprovalsFlow.flow.definition]); +} // #endregion workflows-flow // #region workflows-script diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index d06f99bf..0c47c9ff 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -276,6 +276,11 @@ class StemWorkflowApp runtime.registerWorkflow(definition); } + /// Registers [definitions] into this app's workflow registry. + void registerWorkflows(Iterable definitions) { + definitions.forEach(registerWorkflow); + } + /// Registers [flow] into this app's workflow registry. void registerFlow(Flow flow) { registerWorkflow(flow.definition); diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index 5b71dae4..dcb013c0 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -887,6 +887,31 @@ void main() { }, ); + test('StemWorkflowApp exposes bulk workflow registration helper', () async { + final definition = WorkflowDefinition.flow( + name: 'workflow.register.definitions.helper', + build: (builder) { + builder.step('hello', (ctx) async => 'definitions-register-ok'); + }, + ); + + final workflowApp = await StemWorkflowApp.inMemory(); + try { + workflowApp.registerWorkflows([definition]); + + final runId = await workflowApp.startWorkflow( + 'workflow.register.definitions.helper', + ); + final result = await workflowApp.waitForCompletion( + runId, + timeout: const Duration(seconds: 2), + ); + expect(result?.value, equals('definitions-register-ok')); + } finally { + await workflowApp.shutdown(); + } + }); + test('StemWorkflowApp exposes run view helpers', () async { final flow = Flow( name: 'workflow.views.helper', From 4c62a720de172fc92f87909e8f6a70e9b055e2f1 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 22:19:47 -0500 Subject: [PATCH 107/302] Document workflow queue and registration helpers --- .site/docs/workflows/getting-started.md | 13 +++++++++++++ packages/stem/CHANGELOG.md | 2 ++ packages/stem/README.md | 12 ++++++++++++ 3 files changed, 27 insertions(+) diff --git a/.site/docs/workflows/getting-started.md b/.site/docs/workflows/getting-started.md index cc66cb73..bb2380ec 100644 --- a/.site/docs/workflows/getting-started.md +++ b/.site/docs/workflows/getting-started.md @@ -14,6 +14,11 @@ This is the quickest path to a working durable workflow in Stem. Pass normal task handlers through `tasks:` if the workflow also needs to enqueue regular Stem tasks. +If you need separate workflow lanes, pass `continuationQueue:` and +`executionQueue:` into the `StemWorkflowApp.*` bootstrap helpers. When the app +is creating the managed worker for you, those queue names are inferred into +the worker subscription automatically. + ## 2. Start the managed worker ```dart title="bin/workflows.dart" file=/../packages/stem/example/docs_snippets/lib/workflows.dart#workflows-app-start @@ -54,6 +59,14 @@ If your service already owns a `StemApp`, layer workflows on top of it with underlying app must already subscribe to the workflow queue plus the task queues your workflows need. +For late registration, use the app helpers instead of reaching through the +runtime registry: + +- `registerWorkflow(...)` / `registerWorkflows(...)` +- `registerFlow(...)` / `registerFlows(...)` +- `registerScript(...)` / `registerScripts(...)` +- `registerModule(...)` + ## 5. Move to the right next page - If you need a mental model first, read [Flows and Scripts](./flows-and-scripts.md). diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 39223c2f..ec757a51 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -161,6 +161,8 @@ low-level scenarios. - Refreshed workflow docs snippets to match the app-level workflow helper surface, including `resumeDueRuns(...)` guidance for fake-clock tests. +- Documented the new bulk registration helpers and custom workflow queue + options in the README and workflow guide. - Added `StemWorkflowApp.rewindToCheckpoint(...)` so replay-oriented flows no longer need to call `store.rewindToStep(...)` and `store.markRunning(...)` directly. diff --git a/packages/stem/README.md b/packages/stem/README.md index 56eba3e0..b55e0f26 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -323,6 +323,18 @@ print(result?.state.status); // WorkflowStatus.completed await app.shutdown(); ``` +If you need separate workflow lanes, pass `continuationQueue:` and +`executionQueue:` into the `StemWorkflowApp.*` bootstrap helpers. When +`StemWorkflowApp` is creating the managed worker for you, those queue names are +inferred into the worker subscription automatically. + +For late registration, prefer the app helpers: + +- `registerWorkflow(...)` / `registerWorkflows(...)` +- `registerFlow(...)` / `registerFlows(...)` +- `registerScript(...)` / `registerScripts(...)` +- `registerModule(...)` + ### Workflow script facade Prefer the high-level `WorkflowScript` facade when you want to author a From 50c05c162cb080307e32a581a31dfa5cf96f532e Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 22:28:53 -0500 Subject: [PATCH 108/302] Prefer direct context-aware checkpoint calls --- .site/docs/core-concepts/stem-builder.md | 4 +- .site/docs/workflows/annotated-workflows.md | 10 +- packages/stem/CHANGELOG.md | 3 + packages/stem/README.md | 3 +- .../example/annotated_workflows/README.md | 1 + .../annotated_workflows/lib/definitions.dart | 52 +++--- packages/stem_builder/CHANGELOG.md | 3 + packages/stem_builder/README.md | 8 +- .../lib/src/stem_registry_builder.dart | 56 +++--- .../test/stem_registry_builder_test.dart | 167 ++++++++++++------ 10 files changed, 183 insertions(+), 124 deletions(-) diff --git a/.site/docs/core-concepts/stem-builder.md b/.site/docs/core-concepts/stem-builder.md index 134acde0..c78cdcf0 100644 --- a/.site/docs/core-concepts/stem-builder.md +++ b/.site/docs/core-concepts/stem-builder.md @@ -161,8 +161,8 @@ app is creating the worker itself. - Plain `run(...)` is best when called checkpoint methods only need serializable parameters. -- When you need runtime metadata or an explicit `script.step(...)`, add an - optional named injected context parameter: +- When you need runtime metadata, add an optional named injected context + parameter: - `WorkflowScriptContext? context` on `run(...)` - `WorkflowScriptStepContext? context` on the checkpoint method - DTO classes are supported when they provide: diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index 51fed5c6..4329b387 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -94,8 +94,8 @@ class UserSignupWorkflow { The generator rewrites those calls into durable checkpoint boundaries in the generated proxy class. -When you need runtime metadata or an explicit `script.step(...)`, add an -optional named injected context parameter: +When you need runtime metadata, add an optional named injected context +parameter: ```dart @WorkflowDefn(name: 'annotated.context_script', kind: WorkflowKind.script) @@ -104,11 +104,7 @@ class AnnotatedContextScriptWorkflow { String email, {WorkflowScriptContext? context} ) async { - final script = context!; - return script.step>( - 'enter-context-step', - (ctx) => captureContext(email, context: ctx), - ); + return captureContext(email); } @WorkflowStep(name: 'capture-context') diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index ec757a51..f884f4ed 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Updated the public annotated workflow example and workflow docs to keep + context-aware script workflows on the direct annotated checkpoint path, + removing the redundant outer `script.step(...)` wrapper from the happy path. - Made `StemApp` lazy-start its managed worker on first enqueue/wait and `app.canvas` dispatch calls so in-memory and module-backed task apps no longer need an explicit `start()` in the common case. diff --git a/packages/stem/README.md b/packages/stem/README.md index b55e0f26..4fdc413f 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -561,8 +561,7 @@ Future builderExampleTask( Script workflows use one authoring model: - start with a plain `run(String email, ...)` method -- add an optional named injected context when you need runtime metadata or an - explicit `script.step(...)` wrapper: +- add an optional named injected context when you need runtime metadata: - `Future run(String email, {WorkflowScriptContext? context})` - `Future capture(String email, {WorkflowScriptStepContext? context})` - direct checkpoint method calls still stay the default happy path diff --git a/packages/stem/example/annotated_workflows/README.md b/packages/stem/example/annotated_workflows/README.md index e5f88ff4..8791ffeb 100644 --- a/packages/stem/example/annotated_workflows/README.md +++ b/packages/stem/example/annotated_workflows/README.md @@ -14,6 +14,7 @@ It now demonstrates the generated script-proxy behavior explicitly: - a second script workflow uses optional named context injection (`WorkflowScriptContext? context` / `WorkflowScriptStepContext? context`) to expose `runId`, `workflow`, `stepName`, `stepIndex`, and idempotency keys + while still calling its annotated checkpoint directly from `run(...)` - a script checkpoint starting and waiting on a child workflow through `StemWorkflowDefinitions.*.startAndWaitWith(context, value)` - a plain script workflow that returns a codec-backed DTO result and persists a diff --git a/packages/stem/example/annotated_workflows/lib/definitions.dart b/packages/stem/example/annotated_workflows/lib/definitions.dart index 7e6552b8..be9c4127 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.dart @@ -194,12 +194,11 @@ class AnnotatedFlowWorkflow { if (!ctx.sleepUntilResumed(const Duration(milliseconds: 50))) { return null; } - final childResult = await StemWorkflowDefinitions.script - .startAndWaitWith( - ctx, - const WelcomeRequest(email: 'flow-child@example.com'), - timeout: const Duration(seconds: 2), - ); + final childResult = await StemWorkflowDefinitions.script.startAndWaitWith( + ctx, + const WelcomeRequest(email: 'flow-child@example.com'), + timeout: const Duration(seconds: 2), + ); return { 'workflow': ctx.workflow, 'runId': ctx.runId, @@ -261,30 +260,25 @@ class AnnotatedScriptWorkflow { @WorkflowDefn(name: 'annotated.context_script', kind: WorkflowKind.script) class AnnotatedContextScriptWorkflow { Future run( - WelcomeRequest request, - {WorkflowScriptContext? context} - ) async { - final script = context!; - return script.step( - 'enter-context-step', - (ctx) => captureContext(request, context: ctx), - ); + WelcomeRequest request, { + WorkflowScriptContext? context, + }) async { + return captureContext(request); } @WorkflowStep(name: 'capture-context') Future captureContext( - WelcomeRequest request, - {WorkflowScriptStepContext? context} - ) async { + WelcomeRequest request, { + WorkflowScriptStepContext? context, + }) async { final ctx = context!; final normalizedEmail = await normalizeEmail(request.email); final subject = await buildWelcomeSubject(normalizedEmail); - final childResult = await StemWorkflowDefinitions.script - .startAndWaitWith( - ctx, - WelcomeRequest(email: normalizedEmail), - timeout: const Duration(seconds: 2), - ); + final childResult = await StemWorkflowDefinitions.script.startAndWaitWith( + ctx, + WelcomeRequest(email: normalizedEmail), + timeout: const Duration(seconds: 2), + ); return ContextCaptureResult( workflow: ctx.workflow, runId: ctx.runId, @@ -312,9 +306,9 @@ class AnnotatedContextScriptWorkflow { @TaskDefn(name: 'send_email', options: TaskOptions(maxRetries: 1)) Future sendEmail( - Map args, - {TaskInvocationContext? context} -) async { + Map args, { + TaskInvocationContext? context, +}) async { final ctx = context!; ctx.heartbeat(); // No-op task for example purposes. @@ -322,9 +316,9 @@ Future sendEmail( @TaskDefn(name: 'send_email_typed', options: TaskOptions(maxRetries: 1)) Future sendEmailTyped( - EmailDispatch dispatch, - {TaskInvocationContext? context} -) async { + EmailDispatch dispatch, { + TaskInvocationContext? context, +}) async { final ctx = context!; ctx.heartbeat(); await ctx.progress( diff --git a/packages/stem_builder/CHANGELOG.md b/packages/stem_builder/CHANGELOG.md index 48887400..74f184a6 100644 --- a/packages/stem_builder/CHANGELOG.md +++ b/packages/stem_builder/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.0 +- Warned when a manual `script.step(...)` wrapper redundantly encloses an + annotated checkpoint, including context-aware checkpoints that can now use + direct annotated method calls with injected workflow-step context. - Flattened generated single-argument workflow/task definitions so one-field annotated workflows/tasks emit direct typed refs instead of named-record wrappers. diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index 6399ef77..805d4fbb 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -62,8 +62,9 @@ Future helloTask( ``` Script workflows can use a plain `run(...)` method with no extra annotation. -When you need runtime metadata or an explicit `script.step(...)`, add an -optional named `WorkflowScriptContext? context` parameter. +When you need runtime metadata, add an optional named +`WorkflowScriptContext? context` parameter. The direct annotated checkpoint +call still stays the default path. The intended usage is to call annotated checkpoint methods directly from `run(...)`: @@ -90,8 +91,7 @@ Conceptually: Script workflows use one entry model: - start with a plain direct-call `run(String email, ...)` -- add an optional named injected context when you need runtime metadata or an - explicit `script.step(...)` wrapper +- add an optional named injected context when you need runtime metadata - `Future run(String email, {WorkflowScriptContext? context})` - `Future checkpoint(String email, {WorkflowScriptStepContext? context})` - direct annotated checkpoint calls stay the default path diff --git a/packages/stem_builder/lib/src/stem_registry_builder.dart b/packages/stem_builder/lib/src/stem_registry_builder.dart index 7c907f22..2bdac8a6 100644 --- a/packages/stem_builder/lib/src/stem_registry_builder.dart +++ b/packages/stem_builder/lib/src/stem_registry_builder.dart @@ -315,8 +315,9 @@ class StemRegistryBuilder implements Builder { importAlias: '', className: classElement.displayName, steps: steps, - resultTypeCode: - steps.isEmpty ? 'Object?' : (steps.last.stepValueTypeCode ?? 'Object?'), + resultTypeCode: steps.isEmpty + ? 'Object?' + : (steps.last.stepValueTypeCode ?? 'Object?'), resultPayloadCodecTypeCode: steps.isEmpty ? null : steps.last.stepValuePayloadCodecTypeCode, @@ -606,7 +607,9 @@ class StemRegistryBuilder implements Builder { ); final remaining = parameters - .where((parameter) => !identical(parameter, contextParameter?.parameter)) + .where( + (parameter) => !identical(parameter, contextParameter?.parameter), + ) .toList(growable: false); final legacyMapSignature = contextParameter != null && @@ -1042,7 +1045,7 @@ Future _diagnoseScriptCheckpointPatterns( for (final methodName in invocation.annotatedMethodCalls) { final step = stepsByMethod[methodName]; - if (step == null || step.acceptsScriptStepContext) { + if (step == null) { continue; } final wrapperName = invocation.stepName ?? ''; @@ -1076,7 +1079,8 @@ class _ManualScriptStepVisitor extends RecursiveAstVisitor { @override void visitMethodInvocation(MethodInvocation node) { - if (node.methodName.name == 'step' && node.argumentList.arguments.length >= 2) { + if (node.methodName.name == 'step' && + node.argumentList.arguments.length >= 2) { final nameArg = node.argumentList.arguments.first; final callbackArg = node.argumentList.arguments[1]; final callback = callbackArg is FunctionExpression ? callbackArg : null; @@ -1651,20 +1655,17 @@ class _RegistryEmitter { for (final workflow in workflows) { final fieldName = fieldNames[workflow]!; final argsTypeCode = _workflowArgsTypeCode(workflow); - final valueParameters = - workflow.kind == WorkflowKind.script - ? workflow.runValueParameters - : workflow.steps.first.valueParameters; + final valueParameters = workflow.kind == WorkflowKind.script + ? workflow.runValueParameters + : workflow.steps.first.valueParameters; final usesNoArgsDefinition = valueParameters.isEmpty; final singleParameter = _singleValueParameter(valueParameters); - final refType = - usesNoArgsDefinition - ? 'NoArgsWorkflowRef<${workflow.resultTypeCode}>' - : 'WorkflowRef<$argsTypeCode, ${workflow.resultTypeCode}>'; - final constructorType = - usesNoArgsDefinition - ? 'NoArgsWorkflowRef<${workflow.resultTypeCode}>' - : 'WorkflowRef<$argsTypeCode, ${workflow.resultTypeCode}>'; + final refType = usesNoArgsDefinition + ? 'NoArgsWorkflowRef<${workflow.resultTypeCode}>' + : 'WorkflowRef<$argsTypeCode, ${workflow.resultTypeCode}>'; + final constructorType = usesNoArgsDefinition + ? 'NoArgsWorkflowRef<${workflow.resultTypeCode}>' + : 'WorkflowRef<$argsTypeCode, ${workflow.resultTypeCode}>'; buffer.writeln(' static final $refType $fieldName = $constructorType('); buffer.writeln(' name: ${_string(workflow.name)},'); if (!usesNoArgsDefinition) { @@ -1899,7 +1900,8 @@ class _RegistryEmitter { 'final List> _stemTasks = >[', ); for (final task in tasks) { - final entrypoint = task.adapterName ?? _qualify(task.importAlias, task.function); + final entrypoint = + task.adapterName ?? _qualify(task.importAlias, task.function); final metadataCode = _taskMetadataCode(task); buffer.writeln(' FunctionTaskHandler('); buffer.writeln(' name: ${_string(task.name)},'); @@ -1961,7 +1963,9 @@ class _RegistryEmitter { buffer.writeln(' },'); } if (task.options != null) { - buffer.writeln(' defaultOptions: ${_dartObjectToCode(task.options!)},'); + buffer.writeln( + ' defaultOptions: ${_dartObjectToCode(task.options!)},', + ); } if (task.metadata != null) { buffer.writeln(' metadata: ${_dartObjectToCode(task.metadata!)},'); @@ -1994,7 +1998,7 @@ class _RegistryEmitter { if (task.usesLegacyMapArgs) 'args' else - ...task.valueParameters.map((param) => _decodeArg('args', param)), + ...task.valueParameters.map((param) => _decodeArg('args', param)), ], named: { if (task.acceptsTaskContext && task.taskContextIsNamed) @@ -2134,7 +2138,10 @@ class _RegistryEmitter { 'as ${parameter.typeCode})'; } - String _encodeValueExpression(String expression, _ValueParameterInfo parameter) { + String _encodeValueExpression( + String expression, + _ValueParameterInfo parameter, + ) { final codecTypeCode = parameter.payloadCodecTypeCode; if (codecTypeCode == null) { return expression; @@ -2163,10 +2170,9 @@ class _RegistryEmitter { } String _workflowArgsTypeCode(_WorkflowInfo workflow) { - final parameters = - workflow.kind == WorkflowKind.script - ? workflow.runValueParameters - : workflow.steps.first.valueParameters; + final parameters = workflow.kind == WorkflowKind.script + ? workflow.runValueParameters + : workflow.steps.first.valueParameters; if (parameters.isEmpty) { return '()'; } diff --git a/packages/stem_builder/test/stem_registry_builder_test.dart b/packages/stem_builder/test/stem_registry_builder_test.dart index fd70ad78..11e5e168 100644 --- a/packages/stem_builder/test/stem_registry_builder_test.dart +++ b/packages/stem_builder/test/stem_registry_builder_test.dart @@ -206,23 +206,23 @@ Future sendEmail( AssetId('stem', 'lib/stem.dart'), stubStem, ), - outputs: { - 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( - allOf([ - contains('StemWorkflowDefinitions'), - contains('StemTaskDefinitions'), - contains('NoArgsWorkflowRef'), - contains('Flow('), - contains('WorkflowScript('), - contains('stemModule = StemModule('), - contains('FunctionTaskHandler'), - contains("part of 'workflows.dart';"), - isNot(contains('StemGeneratedTaskEnqueuer')), - isNot(contains('StemGeneratedTaskResults')), - isNot(contains('waitForSendEmail(')), - ]), - ), - }, + outputs: { + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( + allOf([ + contains('StemWorkflowDefinitions'), + contains('StemTaskDefinitions'), + contains('NoArgsWorkflowRef'), + contains('Flow('), + contains('WorkflowScript('), + contains('stemModule = StemModule('), + contains('FunctionTaskHandler'), + contains("part of 'workflows.dart';"), + isNot(contains('StemGeneratedTaskEnqueuer')), + isNot(contains('StemGeneratedTaskResults')), + isNot(contains('waitForSendEmail(')), + ]), + ), + }, ); }); @@ -412,7 +412,7 @@ Future sendEmail(EmailRequest request) async => request.email; outputs: { 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( allOf([ - contains( + contains( 'static final TaskDefinition emailSend =', ), isNot(contains('enqueueEmailSend(')), @@ -447,7 +447,7 @@ class SignupWorkflow { outputs: { 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( allOf([ - contains( + contains( 'static final WorkflowRef signupWorkflow =', ), isNot(contains('startSignupWorkflow(')), @@ -460,7 +460,7 @@ class SignupWorkflow { }); test('generates typed workflow refs for annotated flows', () async { - const input = ''' + const input = r''' import 'package:stem/stem.dart'; part 'workflows.stem.g.dart'; @@ -484,7 +484,7 @@ class GreetingFlow { outputs: { 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( allOf([ - contains( + contains( 'static final WorkflowRef greetingFlow =', ), isNot(contains('startGreetingFlow(')), @@ -658,7 +658,7 @@ class DuplicateCheckpointWorkflow { test( 'rejects manual checkpoint names that conflict with annotated ones', () async { - const input = ''' + const input = ''' import 'package:stem/stem.dart'; part 'workflows.stem.g.dart'; @@ -675,23 +675,24 @@ class DuplicateManualCheckpointWorkflow { } '''; - final result = await testBuilder( - stemRegistryBuilder(BuilderOptions.empty), - {'stem_builder|lib/workflows.dart': input}, - rootPackage: 'stem_builder', - readerWriter: TestReaderWriter(rootPackage: 'stem_builder') - ..testing.writeString( - AssetId('stem', 'lib/stem.dart'), - stubStem, - ), - ); - expect(result.succeeded, isFalse); - expect(result.errors.join('\n'), contains('manual checkpoint')); - expect( - result.errors.join('\n'), - contains('conflicts with annotated checkpoint'), - ); - }); + final result = await testBuilder( + stemRegistryBuilder(BuilderOptions.empty), + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + ); + expect(result.succeeded, isFalse); + expect(result.errors.join('\n'), contains('manual checkpoint')); + expect( + result.errors.join('\n'), + contains('conflicts with annotated checkpoint'), + ); + }, + ); test( 'warns when manual script.step wraps an annotated checkpoint call', @@ -741,6 +742,61 @@ class MixedCheckpointWorkflow { }, ); + test( + 'warns when manual script.step wraps a context-aware annotated ' + 'checkpoint call', + () async { + const input = ''' +import 'package:stem/stem.dart'; + +part 'workflows.stem.g.dart'; + +@WorkflowDefn(kind: WorkflowKind.script) +class MixedContextCheckpointWorkflow { + @WorkflowRun() + Future run(WorkflowScriptContext script) async { + await script.step( + 'outer-wrapper', + (ctx) => capture('user@example.com', context: ctx), + ); + } + + @WorkflowStep(name: 'capture') + Future capture( + String email, { + WorkflowScriptStepContext? context, + }) async {} +} +'''; + + final records = []; + await testBuilders( + [stemRegistryBuilder(BuilderOptions.empty)], + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + onLog: records.add, + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + ); + + expect( + records, + contains( + warningLogOf( + allOf([ + contains('wraps annotated checkpoint "capture"'), + contains('outer-wrapper'), + contains('avoid nested checkpoints'), + ]), + ), + ), + ); + }, + ); + test( 'decodes serializable @workflow.run parameters from script params', () async { @@ -1094,7 +1150,7 @@ Future typedTask( test( 'generates codec-backed DTO helpers for workflow and task types', () async { - const input = ''' + const input = ''' import 'package:stem/stem.dart'; part 'workflows.stem.g.dart'; @@ -1131,17 +1187,17 @@ Future dtoTask( ) async => request; '''; - await testBuilder( - stemRegistryBuilder(BuilderOptions.empty), - {'stem_builder|lib/workflows.dart': input}, - rootPackage: 'stem_builder', - readerWriter: TestReaderWriter(rootPackage: 'stem_builder') - ..testing.writeString( - AssetId('stem', 'lib/stem.dart'), - stubStem, - ), - outputs: { - 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( + await testBuilder( + stemRegistryBuilder(BuilderOptions.empty), + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + outputs: { + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( allOf([ contains('abstract final class StemPayloadCodecs'), contains('PayloadCodec emailRequest ='), @@ -1166,10 +1222,11 @@ Future dtoTask( contains('valueCodec: StemPayloadCodecs.emailRequest,'), contains('resultCodec: StemPayloadCodecs.emailRequest,'), ]), - ), - }, - ); - }); + ), + }, + ); + }, + ); test('rejects non-serializable workflow step parameter types', () async { const input = ''' From f2ba087dbb59f8da49f8643804a062a4d1ab13f5 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 22:33:40 -0500 Subject: [PATCH 109/302] Add fluent workflow start builders --- .site/docs/workflows/starting-and-waiting.md | 14 ++++ packages/stem/CHANGELOG.md | 4 + packages/stem/README.md | 14 ++++ .../lib/src/workflow/core/workflow_ref.dart | 79 +++++++++++++++++++ .../workflow/workflow_runtime_ref_test.dart | 66 ++++++++++++++++ 5 files changed, 177 insertions(+) diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 5aa01a61..85370b81 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -46,6 +46,20 @@ final result = await approvalsRef.waitFor(workflowApp, runId); Use this path when you want the same typed start/wait surface as generated workflow refs, but the workflow itself is still hand-written. +When you want to add advanced start options fluently, use the workflow start +builder: + +```dart +final runId = await approvalsRef + .startBuilder(const ApprovalDraft(documentId: 'doc-42')) + .parentRunId('parent-run') + .ttl(const Duration(hours: 1)) + .cancellationPolicy( + const WorkflowCancellationPolicy(maxRuntime: Duration(minutes: 10)), + ) + .startWith(workflowApp); +``` + `refWithCodec(...)` is the manual DTO path. The codec still needs to encode to `Map` because workflow params are stored as a map. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index f884f4ed..05ae588f 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.1 +- Added `WorkflowStartBuilder` plus `WorkflowRef.startBuilder(...)` / + `NoArgsWorkflowRef.startBuilder()` so typed workflow refs can fluently set + `parentRunId`, `ttl`, and `WorkflowCancellationPolicy` without dropping to + raw workflow-name APIs. - Updated the public annotated workflow example and workflow docs to keep context-aware script workflows on the direct annotated checkpoint path, removing the redundant outer `script.step(...)` wrapper from the happy path. diff --git a/packages/stem/README.md b/packages/stem/README.md index 4fdc413f..55d15a28 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -466,6 +466,20 @@ print(result?.value); await app.close(); ``` +When you need advanced start options without dropping back to raw workflow +names, use the fluent workflow start builder: + +```dart +final runId = await approvalsRef + .startBuilder(const ApprovalDraft(documentId: 'doc-42')) + .parentRunId('parent-run') + .ttl(const Duration(hours: 1)) + .cancellationPolicy( + const WorkflowCancellationPolicy(maxRuntime: Duration(minutes: 10)), + ) + .startWith(app); +``` + Use `refWithCodec(...)` when your manual workflow start params are DTOs that already have a `PayloadCodec`. The codec still needs to encode to `Map` because workflow params are persisted as a map. diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 833fc477..5d12d00d 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -83,6 +83,11 @@ class WorkflowRef { ); } + /// Creates a fluent builder for this workflow start. + WorkflowStartBuilder startBuilder(TParams params) { + return WorkflowStartBuilder(definition: this, params: params); + } + /// Decodes a final workflow result payload. TResult decode(Object? payload) { if (payload == null) { @@ -170,6 +175,11 @@ class NoArgsWorkflowRef { ); } + /// Creates a fluent builder for this workflow start. + WorkflowStartBuilder<(), TResult> startBuilder() { + return asRef.startBuilder(()); + } + /// Starts this workflow ref directly with [caller]. Future startWith( WorkflowCaller caller, { @@ -296,6 +306,52 @@ class WorkflowStartCall { } } +/// Fluent builder used to construct rich workflow start requests. +class WorkflowStartBuilder { + /// Creates a fluent builder for workflow starts. + WorkflowStartBuilder({required this.definition, required this.params}); + + /// Workflow definition used to construct the start call. + final WorkflowRef definition; + + /// Typed parameters for the workflow invocation. + final TParams params; + + String? _parentRunId; + Duration? _ttl; + WorkflowCancellationPolicy? _cancellationPolicy; + + /// Sets the parent workflow run id for this start. + WorkflowStartBuilder parentRunId(String parentRunId) { + _parentRunId = parentRunId; + return this; + } + + /// Sets the retention TTL for this run. + WorkflowStartBuilder ttl(Duration ttl) { + _ttl = ttl; + return this; + } + + /// Sets the cancellation policy for this run. + WorkflowStartBuilder cancellationPolicy( + WorkflowCancellationPolicy cancellationPolicy, + ) { + _cancellationPolicy = cancellationPolicy; + return this; + } + + /// Builds the [WorkflowStartCall] with accumulated overrides. + WorkflowStartCall build() { + return definition.call( + params, + parentRunId: _parentRunId, + ttl: _ttl, + cancellationPolicy: _cancellationPolicy, + ); + } +} + /// Convenience helpers for dispatching prebuilt [WorkflowStartCall] instances. extension WorkflowStartCallExtension on WorkflowStartCall { @@ -320,6 +376,29 @@ extension WorkflowStartCallExtension } } +/// Convenience helpers for dispatching [WorkflowStartBuilder] instances. +extension WorkflowStartBuilderExtension + on WorkflowStartBuilder { + /// Builds this workflow call and starts it with the provided [caller]. + Future startWith(WorkflowCaller caller) { + return build().startWith(caller); + } + + /// Builds this workflow call, starts it with [caller], and waits for the + /// result. + Future?> startAndWaitWith( + WorkflowCaller caller, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return build().startAndWaitWith( + caller, + pollInterval: pollInterval, + timeout: timeout, + ); + } +} + /// Convenience helpers for waiting on typed workflow refs using a generic /// [WorkflowCaller]. extension WorkflowRefExtension diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 52645f51..3b165418 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -233,6 +233,72 @@ void main() { } }); + test('workflow refs expose fluent start builders', () async { + final flow = Flow( + name: 'runtime.ref.builder.flow', + build: (builder) { + builder.step('hello', (ctx) async { + final name = ctx.params['name'] as String? ?? 'world'; + return 'hello $name'; + }); + }, + ); + final script = WorkflowScript( + name: 'runtime.ref.builder.script', + run: (context) async => 'hello script', + ); + + final workflowRef = flow.ref>( + encodeParams: (params) => params, + ); + final scriptRef = script.ref0(); + + final workflowApp = await StemWorkflowApp.inMemory( + flows: [flow], + scripts: [script], + ); + try { + await workflowApp.start(); + + final flowBuilder = workflowRef + .startBuilder(const {'name': 'builder'}) + .ttl(const Duration(minutes: 5)) + .parentRunId('parent-builder'); + final builtFlowCall = flowBuilder.build(); + final runId = await flowBuilder.startWith(workflowApp.runtime); + final result = await workflowRef.waitFor( + workflowApp.runtime, + runId, + timeout: const Duration(seconds: 2), + ); + final state = await workflowApp.getRun(runId); + + expect(builtFlowCall.parentRunId, 'parent-builder'); + expect(builtFlowCall.ttl, const Duration(minutes: 5)); + expect(result?.value, 'hello builder'); + expect(state?.parentRunId, 'parent-builder'); + + final scriptBuilder = scriptRef.startBuilder().cancellationPolicy( + const WorkflowCancellationPolicy( + maxRunDuration: Duration(seconds: 5), + ), + ); + final builtScriptCall = scriptBuilder.build(); + final oneShot = await scriptBuilder.startAndWaitWith( + workflowApp.runtime, + timeout: const Duration(seconds: 2), + ); + + expect( + builtScriptCall.cancellationPolicy?.maxRunDuration, + const Duration(seconds: 5), + ); + expect(oneShot?.value, 'hello script'); + } finally { + await workflowApp.shutdown(); + } + }); + test('typed workflow events emit directly from the event ref', () async { final flow = Flow( name: 'runtime.ref.event.flow', From 3807b1ded0c4357a0180d53fad2190dd2b75e132 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 22:40:02 -0500 Subject: [PATCH 110/302] Add typed await-event workflow helpers --- .../docs/workflows/suspensions-and-events.md | 2 +- packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 6 ++- .../src/workflow/core/workflow_resume.dart | 27 ++++++++++ .../unit/workflow/workflow_resume_test.dart | 53 +++++++++++++++++++ 5 files changed, 88 insertions(+), 3 deletions(-) diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index 7c0ade2e..c6955163 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -67,7 +67,7 @@ transport shape. When the topic and codec travel together in your codebase, prefer a typed `WorkflowEventRef` and `event.emitWith(...)` together with -`waitForEventRef(...)`. +`waitForEventRef(...)` or `awaitEventRef(...)`. ## Inspect waiting runs diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 05ae588f..83e222dd 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Added `awaitEventRef(...)` on flow and script checkpoint resume helpers so + typed `WorkflowEventRef` values now cover both the common wait-for-value + path and the lower-level suspend-first path. - Added `WorkflowStartBuilder` plus `WorkflowRef.startBuilder(...)` / `NoArgsWorkflowRef.startBuilder()` so typed workflow refs can fluently set `parentRunId`, `ttl`, and `WorkflowCancellationPolicy` without dropping to diff --git a/packages/stem/README.md b/packages/stem/README.md index 55d15a28..7a3b80d7 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -388,6 +388,8 @@ Inside a script checkpoint you can access the same metadata as `FlowContext`: - `step.waitForEventValue(...)` handles the common wait-for-one-event path. - `step.waitForEventRef(...)` handles the same path when you already have a typed `WorkflowEventRef`. +- `step.awaitEventRef(...)` keeps the lower-level suspend-first path on that + same typed event ref instead of dropping back to a raw topic string. - `step.takeResumeData()` and `step.takeResumeValue(codec: ...)` surface payloads from sleeps or awaited events when you need lower-level control. @@ -1022,8 +1024,8 @@ backend metadata under `stem.unique.duplicates`. `runtime.emitValue(...)` when you are intentionally using the low-level runtime) with a `PayloadCodec`, or bundle the topic and codec once in a `WorkflowEventRef` and use `event.emitWith(...)` together with - `waitForEventRef(...)`. Event payloads still serialize onto the existing - `Map` wire format. + `waitForEventRef(...)` or `awaitEventRef(...)`. Event payloads still + serialize onto the existing `Map` wire format. - Only return values you want persisted. If a handler returns `null`, the runtime treats it as "no result yet" and will run the step again on resume. - Derive outbound idempotency tokens with `ctx.idempotencyKey('charge')` so diff --git a/packages/stem/lib/src/workflow/core/workflow_resume.dart b/packages/stem/lib/src/workflow/core/workflow_resume.dart index 327fca35..80b5cc20 100644 --- a/packages/stem/lib/src/workflow/core/workflow_resume.dart +++ b/packages/stem/lib/src/workflow/core/workflow_resume.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/workflow/core/flow_context.dart'; +import 'package:stem/src/workflow/core/flow_step.dart'; import 'package:stem/src/workflow/core/workflow_event_ref.dart'; import 'package:stem/src/workflow/core/workflow_script_context.dart'; @@ -85,6 +86,19 @@ extension FlowContextResumeValues on FlowContext { codec: event.codec, ); } + + /// Registers an event wait using a typed [event] reference. + FlowStepControl awaitEventRef( + WorkflowEventRef event, { + DateTime? deadline, + Map? data, + }) { + return awaitEvent( + event.topic, + deadline: deadline, + data: data, + ); + } } /// Typed resume helpers for durable script checkpoints. @@ -145,4 +159,17 @@ extension WorkflowScriptStepResumeValues on WorkflowScriptStepContext { codec: event.codec, ); } + + /// Registers an event wait using a typed [event] reference. + Future awaitEventRef( + WorkflowEventRef event, { + DateTime? deadline, + Map? data, + }) { + return awaitEvent( + event.topic, + deadline: deadline, + data: data, + ); + } } diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart index 76a48ed7..1aa68c70 100644 --- a/packages/stem/test/unit/workflow/workflow_resume_test.dart +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -163,6 +163,33 @@ void main() { expect(resumed?.message, 'approved'); }); + test('FlowContext.awaitEventRef reuses the event topic', () { + const event = WorkflowEventRef<_ResumePayload>( + topic: 'demo.event', + codec: _resumePayloadCodec, + ); + final deadline = DateTime.parse('2026-01-01T00:00:00Z'); + final context = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + ); + + final control = context.awaitEventRef( + event, + deadline: deadline, + data: const {'source': 'flow'}, + ); + + expect(control.type, FlowControlType.waitForEvent); + expect(control.topic, 'demo.event'); + expect(control.deadline, deadline); + expect(control.data, containsPair('source', 'flow')); + }); + test( 'WorkflowScriptStepContext helpers suspend once and decode resumed values', () { @@ -220,6 +247,28 @@ void main() { expect(resumedValue?.message, 'approved'); }); + test( + 'WorkflowScriptStepContext.awaitEventRef reuses the event topic', + () async { + const event = WorkflowEventRef<_ResumePayload>( + topic: 'demo.event', + codec: _resumePayloadCodec, + ); + final deadline = DateTime.parse('2026-01-01T00:00:00Z'); + final context = _FakeWorkflowScriptStepContext(); + + await context.awaitEventRef( + event, + deadline: deadline, + data: const {'source': 'script'}, + ); + + expect(context.awaitedTopics, ['demo.event']); + expect(context.awaitedDeadline, deadline); + expect(context.awaitedData, containsPair('source', 'script')); + }, + ); + test( 'WorkflowScriptStepContext.enqueue delegates to the configured enqueuer', () async { @@ -288,6 +337,8 @@ class _FakeWorkflowScriptStepContext implements WorkflowScriptStepContext { final TaskEnqueuer? _enqueuer; final WorkflowCaller? _workflows; final List awaitedTopics = []; + DateTime? awaitedDeadline; + Map? awaitedData; final List sleepCalls = []; @override @@ -324,6 +375,8 @@ class _FakeWorkflowScriptStepContext implements WorkflowScriptStepContext { Map? data, }) async { awaitedTopics.add(topic); + awaitedDeadline = deadline; + awaitedData = data == null ? null : Map.from(data); } @override From 90977479fe5d9f98eab62b13267f15484dee86c1 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 22:46:29 -0500 Subject: [PATCH 111/302] Clarify producer happy-path APIs --- .site/docs/core-concepts/producer.md | 30 +++++++++++-------- packages/stem/CHANGELOG.md | 3 ++ .../example/docs_snippets/lib/producer.dart | 7 ++--- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/.site/docs/core-concepts/producer.md b/.site/docs/core-concepts/producer.md index 6f2dc312..56bcbbbc 100644 --- a/.site/docs/core-concepts/producer.md +++ b/.site/docs/core-concepts/producer.md @@ -5,13 +5,14 @@ sidebar_position: 2 slug: /core-concepts/producer --- -Enqueue tasks from your Dart services using `Stem.enqueue`. Start with the -in-memory broker, then opt into Redis/Postgres as needed. +Enqueue tasks from your Dart services through a `TaskEnqueuer` surface such as +`StemClient`, `StemApp`, or `StemWorkflowApp`. Start with the in-memory broker, +then opt into Redis/Postgres as needed. import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -## Enqueue tasks +## Raw Enqueue @@ -39,19 +40,23 @@ import TabItem from '@theme/TabItem'; ## Typed Enqueue Helpers -When you need compile-time guarantees for task arguments and result types, wrap -your handler in a `TaskDefinition`. The definition knows how to encode args and -decode results, and exposes a fluent builder for overrides (headers, meta, -options, scheduling): +For the common producer path, prefer `TaskDefinition`. The +definition owns argument encoding, result decoding, and default publish +metadata, while exposing direct helpers and a fluent builder for overrides +(headers, meta, options, scheduling): ```dart title="bin/producer_typed.dart" file=/../packages/stem/example/docs_snippets/lib/producer.dart#producer-typed ``` Typed helpers are also available on `Canvas` (`definition.toSignature`) so -group/chain/chord APIs produce strongly typed `TaskResult` streams. -Need to tweak headers/meta/queue at call sites? Wrap the definition in a -`TaskEnqueueBuilder` and invoke `await builder.enqueue(stem);`. +group/chain/chord APIs produce strongly typed `TaskResult` streams. Need to +tweak headers/meta/queue at call sites? Wrap the definition in a +`TaskEnqueueBuilder` and invoke `await builder.enqueue(enqueuer);`. + +Raw task-name strings still work, but they are the lower-level interop path. +Reach for them when the task name is truly dynamic or you are crossing a +boundary that does not have the generated/manual `TaskDefinition`. ## Enqueue options @@ -68,7 +73,7 @@ unsupported. Example: ```dart -await stem.enqueue( +await enqueuer.enqueue( 'tasks.email', args: {'to': 'ops@example.com'}, enqueueOptions: TaskEnqueueOptions( @@ -86,7 +91,8 @@ await stem.enqueue( ## Tips -- Reuse a single `Stem` instance; create it during application bootstrap. +- Reuse a single `TaskEnqueuer` implementation; in most apps that means + `StemClient`, `StemApp`, or `StemWorkflowApp`. - Capture the returned task id when you need to poll status from the result backend. - Use `TaskOptions` to set queue, retries, priority, isolation, and visibility timeouts. - `meta` is stored with result backend entries—great for audit trails. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 83e222dd..2b31aaec 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -5,6 +5,9 @@ - Added `awaitEventRef(...)` on flow and script checkpoint resume helpers so typed `WorkflowEventRef` values now cover both the common wait-for-value path and the lower-level suspend-first path. +- Reframed the producer docs to treat `TaskDefinition` and shared + `TaskEnqueuer` surfaces as the happy path, keeping raw name-based enqueue as + the lower-level interop option. - Added `WorkflowStartBuilder` plus `WorkflowRef.startBuilder(...)` / `NoArgsWorkflowRef.startBuilder()` so typed workflow refs can fluently set `parentRunId`, `ttl`, and `WorkflowCancellationPolicy` without dropping to diff --git a/packages/stem/example/docs_snippets/lib/producer.dart b/packages/stem/example/docs_snippets/lib/producer.dart index 147f5838..4e64b474 100644 --- a/packages/stem/example/docs_snippets/lib/producer.dart +++ b/packages/stem/example/docs_snippets/lib/producer.dart @@ -121,21 +121,20 @@ class GenerateReportTask extends TaskHandler { @override Future call(TaskContext context, Map args) async { - final id = args['reportId'] as String; - return await generateReport(id); + final id = args['reportId'] as String?; + return generateReport(id!); } } Future enqueueTyped() async { final app = await StemApp.inMemory(tasks: [GenerateReportTask()]); - final taskId = await GenerateReportTask.definition.enqueue( + final result = await GenerateReportTask.definition.enqueueAndWait( app, const ReportPayload(reportId: 'monthly-2025-10'), options: const TaskOptions(priority: 5), headers: const {'x-requested-by': 'analytics'}, ); - final result = await app.waitForTask(taskId); print(result?.value); await app.close(); } From 2266f50eb8d34cd1e375562cecbee4d5590ca654 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 22:54:08 -0500 Subject: [PATCH 112/302] Add task enqueue-and-wait builders --- .site/docs/core-concepts/producer.md | 3 +- .site/docs/core-concepts/tasks.md | 7 +- packages/stem/CHANGELOG.md | 2 + packages/stem/README.md | 6 +- packages/stem/lib/src/core/stem.dart | 15 +++ .../unit/core/task_enqueue_builder_test.dart | 93 ++++++++++++++++--- 6 files changed, 106 insertions(+), 20 deletions(-) diff --git a/.site/docs/core-concepts/producer.md b/.site/docs/core-concepts/producer.md index 56bcbbbc..1df0d33b 100644 --- a/.site/docs/core-concepts/producer.md +++ b/.site/docs/core-concepts/producer.md @@ -52,7 +52,8 @@ metadata, while exposing direct helpers and a fluent builder for overrides Typed helpers are also available on `Canvas` (`definition.toSignature`) so group/chain/chord APIs produce strongly typed `TaskResult` streams. Need to tweak headers/meta/queue at call sites? Wrap the definition in a -`TaskEnqueueBuilder` and invoke `await builder.enqueue(enqueuer);`. +`TaskEnqueueBuilder` and invoke `await builder.enqueue(enqueuer);` or +`await builder.enqueueAndWait(caller);`. Raw task-name strings still work, but they are the lower-level interop path. Reach for them when the task name is truly dynamic or you are crossing a diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 1db07780..e9e73658 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -37,8 +37,8 @@ routing, retry behavior, timeouts, and isolation. Stem ships with `TaskDefinition` so producers get compile-time checks for required arguments and result types. A definition bundles the task name, argument encoder, optional metadata, and default `TaskOptions`. Build a -call with `.call(args)` or `TaskEnqueueBuilder` and hand it to `Stem.enqueueCall` -or `Canvas` helpers. For the common path, use the direct +call with `.call(args)` or `TaskEnqueueBuilder` and hand it to any +`TaskResultCaller` / `TaskEnqueuer` surface. For the common path, use the direct `definition.enqueue(stem, args)` / `definition.enqueueAndWait(...)` helpers and drop down to `.call(args)` only when you need a reusable prebuilt request: @@ -57,6 +57,9 @@ If your manual task args are DTOs, prefer codec still needs to encode to `Map` because task args are published as a map. +`TaskEnqueueBuilder` also supports `enqueueAndWait(...)`, so fluent per-call +overrides no longer force a separate manual wait step. + For tasks with no producer inputs, use `TaskDefinition.noArgs(...)` instead. That gives you direct `enqueue(...)` / `enqueueAndWait(...)` helpers without passing a fake empty map and the same diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 2b31aaec..07e7445b 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -8,6 +8,8 @@ - Reframed the producer docs to treat `TaskDefinition` and shared `TaskEnqueuer` surfaces as the happy path, keeping raw name-based enqueue as the lower-level interop option. +- Added `TaskEnqueueBuilder.enqueueAndWait(...)` so fluent per-call task + overrides no longer require a separate manual wait step. - Added `WorkflowStartBuilder` plus `WorkflowRef.startBuilder(...)` / `NoArgsWorkflowRef.startBuilder()` so typed workflow refs can fluently set `parentRunId`, `ttl`, and `WorkflowCancellationPolicy` without dropping to diff --git a/packages/stem/README.md b/packages/stem/README.md index 7a3b80d7..8771fb7e 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -255,14 +255,16 @@ the result and the task metadata advertises the right result encoder. You can also build requests fluently with the `TaskEnqueueBuilder`: ```dart -final taskId = await TaskEnqueueBuilder( +final result = await TaskEnqueueBuilder( definition: HelloTask.definition, args: const HelloArgs(name: 'Tenant A'), ) ..header('x-tenant', 'tenant-a') ..priority(5) ..delay(const Duration(seconds: 30)) - .enqueue(stem); + .enqueueAndWait(stem); + +print(result?.value); ``` ### Enqueue from inside a task diff --git a/packages/stem/lib/src/core/stem.dart b/packages/stem/lib/src/core/stem.dart index 4f45baef..4f2334b0 100644 --- a/packages/stem/lib/src/core/stem.dart +++ b/packages/stem/lib/src/core/stem.dart @@ -1103,6 +1103,21 @@ extension TaskEnqueueBuilderExtension call.copyWith(meta: Map.unmodifiable(mergedMeta)), ); } + + /// Builds this request, enqueues it with [caller], and waits for the typed + /// task result. + Future?> enqueueAndWait( + TaskResultCaller caller, { + Duration? timeout, + TaskEnqueueOptions? enqueueOptions, + }) async { + final call = build(); + final taskId = await call.enqueue( + caller, + enqueueOptions: enqueueOptions, + ); + return call.definition.waitFor(caller, taskId, timeout: timeout); + } } /// Convenience helpers for dispatching prebuilt [TaskCall] instances. diff --git a/packages/stem/test/unit/core/task_enqueue_builder_test.dart b/packages/stem/test/unit/core/task_enqueue_builder_test.dart index 93f5e591..8b3bbd56 100644 --- a/packages/stem/test/unit/core/task_enqueue_builder_test.dart +++ b/packages/stem/test/unit/core/task_enqueue_builder_test.dart @@ -63,6 +63,29 @@ void main() { expect(call.options?.priority, 9); }); + test( + 'TaskEnqueueBuilder.enqueueAndWait reuses typed result decoding', + () async { + final definition = TaskDefinition, String>( + name: 'demo.task', + encodeArgs: (args) => args, + decodeResult: (payload) => 'decoded:$payload', + ); + final caller = _RecordingTaskResultCaller(); + + final result = await TaskEnqueueBuilder( + definition: definition, + args: const {'a': 1}, + ).header('h1', 'v1').enqueueAndWait(caller); + + expect(caller.lastCall, isNotNull); + expect(caller.lastCall!.name, 'demo.task'); + expect(caller.lastCall!.headers, containsPair('h1', 'v1')); + expect(caller.waitedTaskId, 'task-1'); + expect(result?.value, 'decoded:stored'); + }, + ); + test('TaskCall.copyWith updates headers and meta', () { final definition = TaskDefinition, Object?>( name: 'demo.task', @@ -100,21 +123,21 @@ void main() { test( 'NoArgsTaskDefinition.enqueue uses the TaskEnqueuer surface', () async { - final definition = TaskDefinition.noArgs(name: 'demo.no_args'); - final enqueuer = _RecordingTaskEnqueuer(); - - final taskId = await definition.enqueue( - enqueuer, - headers: const {'h': 'v'}, - meta: const {'m': 1}, - ); - - expect(taskId, 'task-1'); - expect(enqueuer.lastCall, isNotNull); - expect(enqueuer.lastCall!.name, 'demo.no_args'); - expect(enqueuer.lastCall!.encodeArgs(), isEmpty); - expect(enqueuer.lastCall!.headers, containsPair('h', 'v')); - expect(enqueuer.lastCall!.meta, containsPair('m', 1)); + final definition = TaskDefinition.noArgs(name: 'demo.no_args'); + final enqueuer = _RecordingTaskEnqueuer(); + + final taskId = await definition.enqueue( + enqueuer, + headers: const {'h': 'v'}, + meta: const {'m': 1}, + ); + + expect(taskId, 'task-1'); + expect(enqueuer.lastCall, isNotNull); + expect(enqueuer.lastCall!.name, 'demo.no_args'); + expect(enqueuer.lastCall!.encodeArgs(), isEmpty); + expect(enqueuer.lastCall!.headers, containsPair('h', 'v')); + expect(enqueuer.lastCall!.meta, containsPair('m', 1)); }, ); } @@ -144,3 +167,43 @@ class _RecordingTaskEnqueuer implements TaskEnqueuer { return 'task-1'; } } + +class _RecordingTaskResultCaller extends _RecordingTaskEnqueuer + implements TaskResultCaller { + String? waitedTaskId; + + @override + Future getTaskStatus(String taskId) async => null; + + @override + Future getGroupStatus(String groupId) async => null; + + @override + Future?> waitForTask( + String taskId, { + Duration? timeout, + TResult Function(Object? payload)? decode, + }) async { + waitedTaskId = taskId; + return TaskResult( + taskId: taskId, + status: TaskStatus(id: taskId, state: TaskState.succeeded, attempt: 0), + value: decode?.call('stored'), + rawPayload: 'stored', + ); + } + + @override + Future?> + waitForTaskDefinition( + String taskId, + TaskDefinition definition, { + Duration? timeout, + }) async { + return waitForTask( + taskId, + timeout: timeout, + decode: definition.decodeResult, + ); + } +} From 5f58070a82ec3c7a29660c6e5a8086a5bc3a5811 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 22:57:26 -0500 Subject: [PATCH 113/302] Add typed workflow event calls --- .../docs/workflows/suspensions-and-events.md | 5 ++- packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 7 ++-- .../example/workflows/sleep_and_event.dart | 31 +++++++------- .../src/workflow/core/workflow_event_ref.dart | 30 ++++++++++++++ .../workflow/workflow_runtime_ref_test.dart | 40 +++++++++++++++++++ 6 files changed, 96 insertions(+), 20 deletions(-) diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index c6955163..ae76e6c8 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -66,8 +66,9 @@ wire format. `emitValue(...)` is a DTO/codec convenience layer, not a new transport shape. When the topic and codec travel together in your codebase, prefer a typed -`WorkflowEventRef` and `event.emitWith(...)` together with -`waitForEventRef(...)` or `awaitEventRef(...)`. +`WorkflowEventRef` and `event.emitWith(...)` or +`event.call(value).emitWith(...)` together with `waitForEventRef(...)` or +`awaitEventRef(...)`. ## Inspect waiting runs diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 07e7445b..cd952c1e 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -10,6 +10,9 @@ the lower-level interop option. - Added `TaskEnqueueBuilder.enqueueAndWait(...)` so fluent per-call task overrides no longer require a separate manual wait step. +- Added `WorkflowEventRef.call(value)` plus `WorkflowEventCall.emitWith(...)` + so typed workflow events now have the same prebuilt-call ergonomics as tasks + and workflow starts. - Added `WorkflowStartBuilder` plus `WorkflowRef.startBuilder(...)` / `NoArgsWorkflowRef.startBuilder()` so typed workflow refs can fluently set `parentRunId`, `ttl`, and `WorkflowCancellationPolicy` without dropping to diff --git a/packages/stem/README.md b/packages/stem/README.md index 8771fb7e..62960745 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -1025,9 +1025,10 @@ backend metadata under `stem.unique.duplicates`. - When you have a DTO event, emit it through `workflowApp.emitValue(...)` (or `runtime.emitValue(...)` when you are intentionally using the low-level runtime) with a `PayloadCodec`, or bundle the topic and codec once in a - `WorkflowEventRef` and use `event.emitWith(...)` together with - `waitForEventRef(...)` or `awaitEventRef(...)`. Event payloads still - serialize onto the existing `Map` wire format. + `WorkflowEventRef` and use `event.emitWith(...)` or + `event.call(value).emitWith(...)` together with `waitForEventRef(...)` or + `awaitEventRef(...)`. Event payloads still serialize onto the existing + `Map` wire format. - Only return values you want persisted. If a handler returns `null`, the runtime treats it as "no result yet" and will run the step again on resume. - Derive outbound idempotency tokens with `ctx.idempotencyKey('charge')` so diff --git a/packages/stem/example/workflows/sleep_and_event.dart b/packages/stem/example/workflows/sleep_and_event.dart index 5756473a..0ed1a7fb 100644 --- a/packages/stem/example/workflows/sleep_and_event.dart +++ b/packages/stem/example/workflows/sleep_and_event.dart @@ -1,5 +1,6 @@ // Demonstrates sleep and external event resumption. // Run with: dart run example/workflows/sleep_and_event.dart +// ignore_for_file: avoid_print import 'dart:async'; @@ -13,20 +14,20 @@ Future main() async { final sleepAndEvent = Flow( name: 'durable.sleep.event', build: (flow) { - flow.step('initial', (ctx) async { - if (!ctx.sleepUntilResumed(const Duration(milliseconds: 200))) { - return null; - } - return 'awake'; - }); - - flow.step('await-event', (ctx) async { - final payload = ctx.waitForEventRef(demoEvent); - if (payload == null) { - return null; - } - return payload['message']; - }); + flow + ..step('initial', (ctx) async { + if (!ctx.sleepUntilResumed(const Duration(milliseconds: 200))) { + return null; + } + return 'awake'; + }) + ..step('await-event', (ctx) async { + final payload = ctx.waitForEventRef(demoEvent); + if (payload == null) { + return null; + } + return payload['message']; + }); }, ); final sleepAndEventRef = sleepAndEvent.ref0(); @@ -47,7 +48,7 @@ Future main() async { await Future.delayed(const Duration(milliseconds: 50)); } - await demoEvent.emitWith(app, {'message': 'event received'}); + await demoEvent.call({'message': 'event received'}).emitWith(app); final result = await sleepAndEventRef.waitFor(app, runId); print('Workflow $runId resumed and completed with: ${result?.value}'); diff --git a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart index dd3d746b..80265d60 100644 --- a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart @@ -31,6 +31,28 @@ class WorkflowEventRef { /// Optional codec for encoding and decoding event payloads. final PayloadCodec? codec; + + /// Builds a typed event emission call from [value]. + WorkflowEventCall call(T value) { + return WorkflowEventCall._(event: this, value: value); + } +} + +/// Typed event emission request built from a [WorkflowEventRef]. +class WorkflowEventCall { + const WorkflowEventCall._({ + required this.event, + required this.value, + }); + + /// Reference used to build this event emission. + final WorkflowEventRef event; + + /// Typed event payload. + final T value; + + /// Durable topic name derived from [event]. + String get topic => event.topic; } /// Convenience helpers for dispatching typed workflow events. @@ -40,3 +62,11 @@ extension WorkflowEventRefExtension on WorkflowEventRef { return emitter.emitEvent(this, value); } } + +/// Convenience helpers for dispatching prebuilt [WorkflowEventCall] instances. +extension WorkflowEventCallExtension on WorkflowEventCall { + /// Emits this typed event with the provided [emitter]. + Future emitWith(WorkflowEventEmitter emitter) { + return emitter.emitEvent(event, value); + } +} diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 3b165418..143c2070 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -336,5 +336,45 @@ void main() { await workflowApp.shutdown(); } }); + + test( + 'typed workflow event calls emit from the prebuilt call surface', + () async { + final flow = Flow( + name: 'runtime.ref.event.call.flow', + build: (builder) { + builder.step('wait', (ctx) async { + final payload = ctx.waitForEventRef(_userUpdatedEvent); + if (payload == null) { + return null; + } + return 'hello ${payload.name}'; + }); + }, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + await workflowApp.start(); + + final runId = await flow.ref0().startWith(workflowApp); + await workflowApp.runtime.executeRun(runId); + + await _userUpdatedEvent + .call(const _GreetingParams(name: 'call')) + .emitWith(workflowApp); + await workflowApp.runtime.executeRun(runId); + + final result = await workflowApp.waitForCompletion( + runId, + timeout: const Duration(seconds: 2), + ); + + expect(result?.value, 'hello call'); + } finally { + await workflowApp.shutdown(); + } + }, + ); }); } From 8ebfccf1ca511fb7ef2047eff499a041d82a243f Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 23:11:55 -0500 Subject: [PATCH 114/302] Add child workflow helpers to task contexts --- .site/docs/core-concepts/tasks.md | 5 + packages/stem/CHANGELOG.md | 3 + packages/stem/README.md | 25 ++ .../stem/lib/src/bootstrap/workflow_app.dart | 1 + packages/stem/lib/src/core/contracts.dart | 61 +++- .../lib/src/core/function_task_handler.dart | 1 + .../stem/lib/src/core/task_invocation.dart | 267 +++++++++++++++++- packages/stem/lib/src/worker/worker.dart | 81 +++++- .../unit/core/task_context_enqueue_test.dart | 121 ++++++++ .../test/unit/core/task_invocation_test.dart | 180 ++++++++++++ ...task_context_enqueue_integration_test.dart | 57 ++++ 11 files changed, 796 insertions(+), 6 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index e9e73658..ba9bf277 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -120,6 +120,11 @@ Inside isolate entrypoints: ``` +When a task runs inside a workflow-enabled runtime like `StemWorkflowApp`, +both `TaskContext` and `TaskInvocationContext` also implement +`WorkflowCaller`, so handlers and isolate entrypoints can start or wait for +typed child workflows without dropping to raw workflow-name APIs. + ### Retry from a running task Handlers can request a retry directly from the context: diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index cd952c1e..b99a12ac 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Made `TaskContext` and `TaskInvocationContext` implement + `WorkflowCaller` when a workflow runtime is attached, so inline handlers and + isolate entrypoints can start and wait for typed child workflows directly. - Added `awaitEventRef(...)` on flow and script checkpoint resume helpers so typed `WorkflowEventRef` values now cover both the common wait-for-value path and the lower-level suspend-first path. diff --git a/packages/stem/README.md b/packages/stem/README.md index 62960745..876406e0 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -299,6 +299,31 @@ class ParentTask implements TaskHandler { } ``` +When a task runs inside a workflow-enabled runtime like `StemWorkflowApp`, +`TaskContext` and `TaskInvocationContext` can also start typed child workflows: + +```dart +final childWorkflow = Flow( + name: 'demo.child.workflow', + build: (flow) { + flow.step('complete', (ctx) async => 'done'); + }, +); + +final childWorkflowRef = childWorkflow.ref0(); + +class ParentTask implements TaskHandler { + @override + String get name => 'demo.parent'; + + @override + Future call(TaskContext context, Map args) async { + final result = await childWorkflowRef.startAndWaitWith(context); + return result?.value ?? 'missing'; + } +} +``` + ### Bootstrap helpers Spin up a full runtime in one call using the bootstrap APIs: diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index 0c47c9ff..dbce3813 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -569,6 +569,7 @@ class StemWorkflowApp appInstance.registry, [...moduleTasks, ...tasks], ); + appInstance.worker.workflows = runtime; appInstance.register(runtime.workflowRunnerHandler()); [ diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 97d9cd10..94492dab 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -40,6 +40,9 @@ import 'package:stem/src/core/task_invocation.dart'; import 'package:stem/src/core/task_payload_encoder.dart'; import 'package:stem/src/observability/heartbeat.dart'; import 'package:stem/src/scheduler/schedule_spec.dart'; +import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; +import 'package:stem/src/workflow/core/workflow_ref.dart'; +import 'package:stem/src/workflow/core/workflow_result.dart'; /// Subscription describing the queues and broadcast channels a worker should /// consume from. @@ -1683,7 +1686,7 @@ class TaskEnqueueScope { } /// Context passed to handler implementations during execution. -class TaskContext implements TaskEnqueuer { +class TaskContext implements TaskEnqueuer, WorkflowCaller { /// Creates a task execution context for a handler invocation. TaskContext({ required this.id, @@ -1694,6 +1697,7 @@ class TaskContext implements TaskEnqueuer { required this.extendLease, required this.progress, this.enqueuer, + this.workflows, }); /// The unique identifier of the task. @@ -1724,6 +1728,9 @@ class TaskContext implements TaskEnqueuer { /// Optional enqueuer for scheduling additional tasks. final TaskEnqueuer? enqueuer; + /// Optional workflow caller for starting child workflows. + final WorkflowCaller? workflows; + /// Enqueue a task with default context propagation. /// /// Headers and metadata from this context are merged into the enqueue @@ -1811,6 +1818,58 @@ class TaskContext implements TaskEnqueuer { ); } + @override + Future startWorkflowRef( + WorkflowRef definition, + TParams params, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + final delegate = workflows; + if (delegate == null) { + throw StateError('TaskContext has no workflow caller configured'); + } + return delegate.startWorkflowRef( + definition, + params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + + @override + Future startWorkflowCall( + WorkflowStartCall call, + ) { + final delegate = workflows; + if (delegate == null) { + throw StateError('TaskContext has no workflow caller configured'); + } + return delegate.startWorkflowCall(call); + } + + @override + Future?> + waitForWorkflowRef( + String runId, + WorkflowRef definition, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + final delegate = workflows; + if (delegate == null) { + throw StateError('TaskContext has no workflow caller configured'); + } + return delegate.waitForWorkflowRef( + runId, + definition, + pollInterval: pollInterval, + timeout: timeout, + ); + } + /// Alias for [enqueue]. Future spawn( String name, { diff --git a/packages/stem/lib/src/core/function_task_handler.dart b/packages/stem/lib/src/core/function_task_handler.dart index 059b68ee..6f03db6d 100644 --- a/packages/stem/lib/src/core/function_task_handler.dart +++ b/packages/stem/lib/src/core/function_task_handler.dart @@ -61,6 +61,7 @@ class FunctionTaskHandler implements TaskHandler { extendLease: context.extendLease, progress: (percent, {data}) => context.progress(percent, data: data), enqueuer: context.enqueuer, + workflows: context.workflows, ); final result = await _entrypoint(invocationContext, args); return result as R; diff --git a/packages/stem/lib/src/core/task_invocation.dart b/packages/stem/lib/src/core/task_invocation.dart index be92e09c..dc742507 100644 --- a/packages/stem/lib/src/core/task_invocation.dart +++ b/packages/stem/lib/src/core/task_invocation.dart @@ -37,6 +37,9 @@ import 'dart:async'; import 'dart:isolate'; import 'package:stem/src/core/contracts.dart'; +import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; +import 'package:stem/src/workflow/core/workflow_ref.dart'; +import 'package:stem/src/workflow/core/workflow_result.dart'; /// Signature for task entrypoints that can run inside isolate executors. typedef TaskEntrypoint = @@ -89,6 +92,30 @@ class EnqueueTaskSignal extends TaskInvocationSignal { final SendPort replyPort; } +/// Request to start a workflow from an isolate. +class StartWorkflowSignal extends TaskInvocationSignal { + /// Creates a workflow start request signal. + const StartWorkflowSignal(this.request, this.replyPort); + + /// Workflow start request payload. + final StartWorkflowRequest request; + + /// Port to deliver the response. + final SendPort replyPort; +} + +/// Request to wait for a workflow from an isolate. +class WaitForWorkflowSignal extends TaskInvocationSignal { + /// Creates a workflow wait request signal. + const WaitForWorkflowSignal(this.request, this.replyPort); + + /// Workflow wait request payload. + final WaitForWorkflowRequest request; + + /// Port to deliver the response. + final SendPort replyPort; +} + /// Enqueue request payload for isolate communication. class TaskEnqueueRequest { /// Creates an enqueue request payload. @@ -97,8 +124,8 @@ class TaskEnqueueRequest { required this.args, required this.headers, required this.options, - this.notBefore, required this.meta, + this.notBefore, this.enqueueOptions, }); @@ -136,8 +163,82 @@ class TaskEnqueueResponse { final String? error; } +/// Workflow start request payload for isolate communication. +class StartWorkflowRequest { + /// Creates a workflow start request payload. + const StartWorkflowRequest({ + required this.workflowName, + required this.params, + this.parentRunId, + this.ttlMs, + this.cancellationPolicy, + }); + + /// Workflow name to start. + final String workflowName; + + /// Encoded workflow params. + final Map params; + + /// Optional parent workflow run id. + final String? parentRunId; + + /// Optional run TTL in milliseconds. + final int? ttlMs; + + /// Optional serialized cancellation policy. + final Map? cancellationPolicy; +} + +/// Response payload for isolate workflow start requests. +class StartWorkflowResponse { + /// Creates a workflow start response payload. + const StartWorkflowResponse({this.runId, this.error}); + + /// Started workflow run id on success. + final String? runId; + + /// Error message when workflow start fails. + final String? error; +} + +/// Workflow wait request payload for isolate communication. +class WaitForWorkflowRequest { + /// Creates a workflow wait request payload. + const WaitForWorkflowRequest({ + required this.runId, + required this.workflowName, + this.pollIntervalMs, + this.timeoutMs, + }); + + /// Workflow run id to wait on. + final String runId; + + /// Workflow name used for result decoding. + final String workflowName; + + /// Poll interval in milliseconds. + final int? pollIntervalMs; + + /// Timeout in milliseconds. + final int? timeoutMs; +} + +/// Response payload for isolate workflow wait requests. +class WaitForWorkflowResponse { + /// Creates a workflow wait response payload. + const WaitForWorkflowResponse({this.result, this.error}); + + /// Serialized workflow result payload. + final Map? result; + + /// Error message when workflow wait fails. + final String? error; +} + /// Context exposed to task entrypoints regardless of execution environment. -class TaskInvocationContext implements TaskEnqueuer { +class TaskInvocationContext implements TaskEnqueuer, WorkflowCaller { /// Context implementation used when executing locally in the same isolate. factory TaskInvocationContext.local({ required String id, @@ -152,6 +253,7 @@ class TaskInvocationContext implements TaskEnqueuer { }) progress, TaskEnqueuer? enqueuer, + WorkflowCaller? workflows, }) => TaskInvocationContext._( id: id, headers: headers, @@ -161,6 +263,7 @@ class TaskInvocationContext implements TaskEnqueuer { extendLease: extendLease, progress: progress, enqueuer: enqueuer, + workflows: workflows, ); /// Context implementation used when executing inside a worker isolate. @@ -180,6 +283,7 @@ class TaskInvocationContext implements TaskEnqueuer { progress: (percent, {data}) async => controlPort.send(ProgressSignal(percent, data: data)), enqueuer: _RemoteTaskEnqueuer(controlPort), + workflows: _RemoteWorkflowCaller(controlPort), ); /// Internal constructor shared by local and isolate contexts. @@ -196,10 +300,12 @@ class TaskInvocationContext implements TaskEnqueuer { }) progress, TaskEnqueuer? enqueuer, + WorkflowCaller? workflows, }) : _heartbeat = heartbeat, _extendLease = extendLease, _progress = progress, - _enqueuer = enqueuer; + _enqueuer = enqueuer, + _workflows = workflows; /// The unique identifier of the task. final String id; @@ -224,6 +330,9 @@ class TaskInvocationContext implements TaskEnqueuer { /// Optional delegate used to enqueue tasks from within the invocation. final TaskEnqueuer? _enqueuer; + /// Optional delegate used to start child workflows from the invocation. + final WorkflowCaller? _workflows; + /// Notify the worker that the task is still running. void heartbeat() => _heartbeat(); @@ -319,6 +428,64 @@ class TaskInvocationContext implements TaskEnqueuer { ); } + @override + Future startWorkflowRef( + WorkflowRef definition, + TParams params, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + final delegate = _workflows; + if (delegate == null) { + throw StateError( + 'TaskInvocationContext has no workflow caller configured', + ); + } + return delegate.startWorkflowRef( + definition, + params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + + @override + Future startWorkflowCall( + WorkflowStartCall call, + ) { + final delegate = _workflows; + if (delegate == null) { + throw StateError( + 'TaskInvocationContext has no workflow caller configured', + ); + } + return delegate.startWorkflowCall(call); + } + + @override + Future?> + waitForWorkflowRef( + String runId, + WorkflowRef definition, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + final delegate = _workflows; + if (delegate == null) { + throw StateError( + 'TaskInvocationContext has no workflow caller configured', + ); + } + return delegate.waitForWorkflowRef( + runId, + definition, + pollInterval: pollInterval, + timeout: timeout, + ); + } + /// Build a fluent enqueue request for this invocation. /// /// Use [TaskEnqueueBuilder.build] + [enqueueCall] to dispatch. @@ -432,3 +599,97 @@ class _RemoteTaskEnqueuer implements TaskEnqueuer { ); } } + +class _RemoteWorkflowCaller implements WorkflowCaller { + _RemoteWorkflowCaller(this._controlPort); + + final SendPort _controlPort; + + @override + Future startWorkflowRef( + WorkflowRef definition, + TParams params, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) async { + final responsePort = ReceivePort(); + _controlPort.send( + StartWorkflowSignal( + StartWorkflowRequest( + workflowName: definition.name, + params: definition.encodeParams(params), + parentRunId: parentRunId, + ttlMs: ttl?.inMilliseconds, + cancellationPolicy: cancellationPolicy?.toJson(), + ), + responsePort.sendPort, + ), + ); + final response = await responsePort.first; + responsePort.close(); + if (response is StartWorkflowResponse) { + if (response.error != null) { + throw StateError(response.error!); + } + return response.runId ?? ''; + } + throw StateError('Unexpected workflow start response: $response'); + } + + @override + Future startWorkflowCall( + WorkflowStartCall call, + ) { + return startWorkflowRef( + call.definition, + call.params, + parentRunId: call.parentRunId, + ttl: call.ttl, + cancellationPolicy: call.cancellationPolicy, + ); + } + + @override + Future?> + waitForWorkflowRef( + String runId, + WorkflowRef definition, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) async { + final responsePort = ReceivePort(); + _controlPort.send( + WaitForWorkflowSignal( + WaitForWorkflowRequest( + runId: runId, + workflowName: definition.name, + pollIntervalMs: pollInterval.inMilliseconds, + timeoutMs: timeout?.inMilliseconds, + ), + responsePort.sendPort, + ), + ); + final response = await responsePort.first; + responsePort.close(); + if (response is WaitForWorkflowResponse) { + if (response.error != null) { + throw StateError(response.error!); + } + final resultJson = response.result; + if (resultJson == null) { + return null; + } + final raw = WorkflowResult.fromJson(resultJson); + return WorkflowResult( + runId: raw.runId, + status: raw.status, + state: raw.state, + value: raw.rawResult == null ? null : definition.decode(raw.rawResult), + rawResult: raw.rawResult, + timedOut: raw.timedOut, + ); + } + throw StateError('Unexpected workflow wait response: $response'); + } +} diff --git a/packages/stem/lib/src/worker/worker.dart b/packages/stem/lib/src/worker/worker.dart index a7548f80..ed2ad09f 100644 --- a/packages/stem/lib/src/worker/worker.dart +++ b/packages/stem/lib/src/worker/worker.dart @@ -123,6 +123,8 @@ import 'package:stem/src/signals/emitter.dart'; import 'package:stem/src/signals/payloads.dart'; import 'package:stem/src/worker/isolate_pool.dart'; import 'package:stem/src/worker/worker_config.dart'; +import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; +import 'package:stem/src/workflow/core/workflow_ref.dart'; /// Shutdown modes for workers. /// @@ -270,6 +272,8 @@ class Worker { /// is created and populated from [tasks]. /// - [enqueuer]: [Stem] instance for spawning child tasks from handlers. /// Created automatically if not provided. + /// - [workflows]: Optional workflow caller used when task handlers need to + /// start or wait for child workflows. /// - [rateLimiter]: Enforces per-task rate limits. Rate limits are defined /// on individual handlers via [TaskOptions.rateLimit]. /// - [middleware]: List of middleware for intercepting task lifecycle events. @@ -304,6 +308,7 @@ class Worker { Iterable> tasks = const [], TaskRegistry? registry, Stem? enqueuer, + WorkflowCaller? workflows, RateLimiter? rateLimiter, List middleware = const [], RevokeStore? revokeStore, @@ -330,6 +335,7 @@ class Worker { }) : this._( broker: broker, enqueuer: enqueuer, + workflows: workflows, registry: _resolveTaskRegistry(registry, tasks), backend: backend, rateLimiter: rateLimiter, @@ -362,6 +368,7 @@ class Worker { required this.registry, required this.backend, required Stem? enqueuer, + this.workflows, this.rateLimiter, this.middleware = const [], this.revokeStore, @@ -425,7 +432,6 @@ class Worker { signer: signer, encoderRegistry: payloadEncoders, ); - _maxConcurrency = this.concurrency; final autoscaleMax = @@ -551,6 +557,10 @@ class Worker { /// Enqueuer used by task contexts for spawning new work. Stem? _enqueuer; + /// Workflow caller used by task contexts for child workflow operations. + /// Active workflow caller used by task handlers, if configured. + WorkflowCaller? workflows; + static final math.Random _random = math.Random(); /// Resolved routing subscription for this worker. @@ -968,6 +978,7 @@ class Worker { _reportProgress(envelope, progress, data: data); }, enqueuer: _enqueuer, + workflows: workflows, ); await _signals.taskPrerun(envelope, _workerInfoSnapshot, context); @@ -1938,7 +1949,7 @@ class Worker { envelope, extra: { 'error': error.message, - if (error.keyId != null) 'keyId': error.keyId!, + if (error.keyId != null) 'keyId': error.keyId, }, ), ), @@ -4516,10 +4527,76 @@ class Worker { TaskEnqueueResponse(error: error.toString()), ); } + } else if (signal is StartWorkflowSignal) { + try { + final workflows = this.workflows; + if (workflows == null) { + signal.replyPort.send( + const StartWorkflowResponse( + error: 'No workflow caller configured', + ), + ); + return; + } + final runId = await workflows.startWorkflowRef( + _workerWorkflowRef(signal.request.workflowName), + signal.request.params, + parentRunId: signal.request.parentRunId, + ttl: signal.request.ttlMs == null + ? null + : Duration(milliseconds: signal.request.ttlMs!), + cancellationPolicy: WorkflowCancellationPolicy.fromJson( + signal.request.cancellationPolicy, + ), + ); + signal.replyPort.send(StartWorkflowResponse(runId: runId)); + } on Exception catch (error) { + signal.replyPort.send( + StartWorkflowResponse(error: error.toString()), + ); + } + } else if (signal is WaitForWorkflowSignal) { + try { + final workflows = this.workflows; + if (workflows == null) { + signal.replyPort.send( + const WaitForWorkflowResponse( + error: 'No workflow caller configured', + ), + ); + return; + } + final result = await workflows.waitForWorkflowRef( + signal.request.runId, + _workerWorkflowRef(signal.request.workflowName), + pollInterval: signal.request.pollIntervalMs == null + ? const Duration(milliseconds: 100) + : Duration(milliseconds: signal.request.pollIntervalMs!), + timeout: signal.request.timeoutMs == null + ? null + : Duration(milliseconds: signal.request.timeoutMs!), + ); + signal.replyPort.send( + WaitForWorkflowResponse( + result: result?.toJson(), + ), + ); + } on Exception catch (error) { + signal.replyPort.send( + WaitForWorkflowResponse(error: error.toString()), + ); + } } }; } + WorkflowRef, Object?> _workerWorkflowRef(String name) { + return WorkflowRef, Object?>( + name: name, + encodeParams: (params) => params, + ); + } + /// Lazily creates or returns the worker isolate pool. Future _ensureIsolatePool() { final existing = _isolatePool; diff --git a/packages/stem/test/unit/core/task_context_enqueue_test.dart b/packages/stem/test/unit/core/task_context_enqueue_test.dart index c4fc16ff..1af79216 100644 --- a/packages/stem/test/unit/core/task_context_enqueue_test.dart +++ b/packages/stem/test/unit/core/task_context_enqueue_test.dart @@ -211,6 +211,69 @@ void main() { expect(record.options.priority, equals(7)); }); }); + + group('TaskContext workflows', () { + test( + 'delegates typed child workflow starts to the configured caller', + () async { + final workflows = _RecordingWorkflowCaller(); + final context = TaskContext( + id: 'parent-workflow-task', + attempt: 0, + headers: const {}, + meta: const {}, + heartbeat: () {}, + extendLease: (_) async {}, + progress: (_, {data}) async {}, + workflows: workflows, + ); + final definition = WorkflowRef, String>( + name: 'workflow.child', + encodeParams: (params) => params, + ); + + final runId = await context.startWorkflowRef( + definition, + const {'value': 'child'}, + ); + final result = await context.waitForWorkflowRef( + runId, + definition, + ); + + expect(runId, 'run-1'); + expect(workflows.lastWorkflowName, 'workflow.child'); + expect(workflows.lastWorkflowParams, {'value': 'child'}); + expect(workflows.waitedRunId, 'run-1'); + expect(result?.value, 'child-result'); + }, + ); + + test('throws when no workflow caller is configured', () { + final context = TaskContext( + id: 'no-workflows', + attempt: 0, + headers: const {}, + meta: const {}, + heartbeat: () {}, + extendLease: (_) async {}, + progress: (_, {data}) async {}, + ); + final definition = WorkflowRef, String>( + name: 'workflow.child', + encodeParams: (params) => params, + ); + + expect( + () => context.startWorkflowRef(definition, const {'value': 'child'}), + throwsStateError, + ); + expect( + () => context.waitForWorkflowRef('run-1', definition), + throwsStateError, + ); + }); + }); } class _ExampleArgs { @@ -280,3 +343,61 @@ class _RecordingEnqueuer implements TaskEnqueuer { ); } } + +class _RecordingWorkflowCaller implements WorkflowCaller { + String? lastWorkflowName; + Map? lastWorkflowParams; + String? waitedRunId; + + @override + Future startWorkflowRef( + WorkflowRef definition, + TParams params, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) async { + lastWorkflowName = definition.name; + lastWorkflowParams = definition.encodeParams(params); + return 'run-1'; + } + + @override + Future startWorkflowCall( + WorkflowStartCall call, + ) { + return startWorkflowRef( + call.definition, + call.params, + parentRunId: call.parentRunId, + ttl: call.ttl, + cancellationPolicy: call.cancellationPolicy, + ); + } + + @override + Future?> + waitForWorkflowRef( + String runId, + WorkflowRef definition, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) async { + waitedRunId = runId; + return WorkflowResult( + runId: runId, + status: WorkflowStatus.completed, + state: RunState( + id: runId, + workflow: definition.name, + status: WorkflowStatus.completed, + cursor: 0, + params: const {}, + createdAt: DateTime.utc(2026), + result: 'child-result', + ), + value: definition.decode('child-result'), + rawResult: 'child-result', + ); + } +} diff --git a/packages/stem/test/unit/core/task_invocation_test.dart b/packages/stem/test/unit/core/task_invocation_test.dart index d30bb77e..97547f39 100644 --- a/packages/stem/test/unit/core/task_invocation_test.dart +++ b/packages/stem/test/unit/core/task_invocation_test.dart @@ -2,6 +2,11 @@ import 'dart:isolate'; import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/core/task_invocation.dart'; +import 'package:stem/src/workflow/core/run_state.dart'; +import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; +import 'package:stem/src/workflow/core/workflow_ref.dart'; +import 'package:stem/src/workflow/core/workflow_result.dart'; +import 'package:stem/src/workflow/core/workflow_status.dart'; import 'package:test/test.dart'; class _CapturingEnqueuer implements TaskEnqueuer { @@ -42,6 +47,64 @@ class _CapturingEnqueuer implements TaskEnqueuer { } } +class _CapturingWorkflowCaller implements WorkflowCaller { + String? lastWorkflowName; + Map? lastWorkflowParams; + String? waitedRunId; + + @override + Future startWorkflowRef( + WorkflowRef definition, + TParams params, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) async { + lastWorkflowName = definition.name; + lastWorkflowParams = definition.encodeParams(params); + return 'run-1'; + } + + @override + Future startWorkflowCall( + WorkflowStartCall call, + ) { + return startWorkflowRef( + call.definition, + call.params, + parentRunId: call.parentRunId, + ttl: call.ttl, + cancellationPolicy: call.cancellationPolicy, + ); + } + + @override + Future?> + waitForWorkflowRef( + String runId, + WorkflowRef definition, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) async { + waitedRunId = runId; + return WorkflowResult( + runId: runId, + status: WorkflowStatus.completed, + state: RunState( + id: runId, + workflow: definition.name, + status: WorkflowStatus.completed, + cursor: 0, + params: const {}, + createdAt: DateTime.utc(2026), + result: 'workflow-result', + ), + value: definition.decode('workflow-result'), + rawResult: 'workflow-result', + ); + } +} + void main() { test('TaskInvocationContext.local merges headers/meta and lineage', () async { final enqueuer = _CapturingEnqueuer('task-1'); @@ -150,6 +213,36 @@ void main() { }, ); + test('TaskInvocationContext.local delegates typed workflow calls', () async { + final workflows = _CapturingWorkflowCaller(); + final context = TaskInvocationContext.local( + id: 'root-task', + headers: const {}, + meta: const {}, + attempt: 1, + heartbeat: () {}, + extendLease: (_) async {}, + progress: (_, {Map? data}) async {}, + workflows: workflows, + ); + final definition = WorkflowRef, String>( + name: 'workflow.child', + encodeParams: (params) => params, + ); + + final runId = await context.startWorkflowRef( + definition, + const {'value': 'child'}, + ); + final result = await context.waitForWorkflowRef(runId, definition); + + expect(runId, 'run-1'); + expect(workflows.lastWorkflowName, 'workflow.child'); + expect(workflows.lastWorkflowParams, {'value': 'child'}); + expect(workflows.waitedRunId, 'run-1'); + expect(result?.value, 'workflow-result'); + }); + test('TaskInvocationContext.remote sends control signals', () async { final control = ReceivePort(); addTearDown(control.close); @@ -187,6 +280,63 @@ void main() { expect(signals.any((signal) => signal is EnqueueTaskSignal), isTrue); }); + test( + 'TaskInvocationContext.remote proxies workflow start and wait', + () async { + final control = ReceivePort(); + addTearDown(control.close); + + control.listen((message) { + if (message is StartWorkflowSignal) { + message.replyPort.send( + const StartWorkflowResponse(runId: 'remote-run'), + ); + } else if (message is WaitForWorkflowSignal) { + message.replyPort.send( + WaitForWorkflowResponse( + result: WorkflowResult( + runId: message.request.runId, + status: WorkflowStatus.completed, + state: RunState( + id: message.request.runId, + workflow: message.request.workflowName, + status: WorkflowStatus.completed, + cursor: 0, + params: const {}, + createdAt: DateTime.utc(2026), + result: 'workflow-result', + ), + value: 'workflow-result', + rawResult: 'workflow-result', + ).toJson(), + ), + ); + } + }); + + final context = TaskInvocationContext.remote( + id: 'remote-task', + controlPort: control.sendPort, + headers: const {}, + meta: const {}, + attempt: 0, + ); + final definition = WorkflowRef, String>( + name: 'workflow.child', + encodeParams: (params) => params, + ); + + final runId = await context.startWorkflowRef( + definition, + const {'value': 'child'}, + ); + final result = await context.waitForWorkflowRef(runId, definition); + + expect(runId, 'remote-run'); + expect(result?.value, 'workflow-result'); + }, + ); + test('TaskInvocationContext.remote surfaces enqueue errors', () async { final control = ReceivePort(); addTearDown(control.close); @@ -211,6 +361,36 @@ void main() { ); }); + test('TaskInvocationContext.remote surfaces workflow errors', () async { + final control = ReceivePort(); + addTearDown(control.close); + + control.listen((message) { + if (message is StartWorkflowSignal) { + message.replyPort.send( + const StartWorkflowResponse(error: 'workflow nope'), + ); + } + }); + + final context = TaskInvocationContext.remote( + id: 'remote-task', + controlPort: control.sendPort, + headers: const {}, + meta: const {}, + attempt: 0, + ); + final definition = WorkflowRef, String>( + name: 'workflow.child', + encodeParams: (params) => params, + ); + + await expectLater( + () => context.startWorkflowRef(definition, const {'value': 'child'}), + throwsA(isA()), + ); + }); + test('TaskInvocationContext.retry throws TaskRetryRequest', () { final context = TaskInvocationContext.local( id: 'retry-task', diff --git a/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart b/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart index 3c9b57c5..b69b8114 100644 --- a/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart +++ b/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart @@ -13,6 +13,15 @@ final _childDefinition = TaskDefinition<_ChildArgs, void>( encodeArgs: (args) => {'value': args.value}, ); +final Flow _childWorkflow = Flow( + name: 'tasks.child.workflow', + build: (flow) { + flow.step('complete', (context) async => 'workflow-child'); + }, +); + +final NoArgsWorkflowRef _childWorkflowRef = _childWorkflow.ref0(); + void main() { group('TaskInvocationContext enqueue', () { test('enqueues from isolate entrypoint using builder', () async { @@ -56,6 +65,24 @@ void main() { await worker.shutdown(); broker.dispose(); }); + + test('starts child workflows from isolate entrypoints', () async { + final app = await StemWorkflowApp.inMemory( + tasks: [_IsolateStartWorkflowTask()], + flows: [_childWorkflow], + ); + + final taskId = await app.enqueue('tasks.isolate.start.workflow'); + final result = await app.waitForTask( + taskId, + timeout: const Duration(seconds: 3), + ); + + expect(result?.isSucceeded, isTrue); + expect(result?.value, equals('workflow-child')); + + await app.close(); + }); }); test('enqueue + execute round-trip is stable', () async { @@ -454,3 +481,33 @@ FutureOr _isolateEnqueueEntrypoint( await builder.enqueue(context); return null; } + +class _IsolateStartWorkflowTask implements TaskHandler { + @override + String get name => 'tasks.isolate.start.workflow'; + + @override + TaskOptions get options => const TaskOptions(); + + @override + TaskMetadata get metadata => const TaskMetadata(); + + @override + TaskEntrypoint? get isolateEntrypoint => _isolateStartWorkflowEntrypoint; + + @override + Future call(TaskContext context, Map args) async { + return ''; + } +} + +FutureOr _isolateStartWorkflowEntrypoint( + TaskInvocationContext context, + Map args, +) async { + final result = await _childWorkflowRef.startAndWaitWith( + context, + timeout: const Duration(seconds: 2), + ); + return result?.value; +} From 923014dae12542cd4605c5d9f34292af36c94051 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 23:17:28 -0500 Subject: [PATCH 115/302] Add workflow event emitters to task contexts --- .site/docs/core-concepts/tasks.md | 4 + packages/stem/CHANGELOG.md | 4 + packages/stem/README.md | 16 ++- .../stem/lib/src/bootstrap/workflow_app.dart | 1 + packages/stem/lib/src/core/contracts.dart | 30 +++- .../lib/src/core/function_task_handler.dart | 1 + .../stem/lib/src/core/task_invocation.dart | 131 +++++++++++++++++- packages/stem/lib/src/worker/worker.dart | 31 +++++ .../unit/core/task_context_enqueue_test.dart | 65 +++++++++ .../test/unit/core/task_invocation_test.dart | 129 +++++++++++++++++ ...task_context_enqueue_integration_test.dart | 99 +++++++++++++ 11 files changed, 507 insertions(+), 4 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index ba9bf277..cee79ab5 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -125,6 +125,10 @@ both `TaskContext` and `TaskInvocationContext` also implement `WorkflowCaller`, so handlers and isolate entrypoints can start or wait for typed child workflows without dropping to raw workflow-name APIs. +Those same contexts also implement `WorkflowEventEmitter`, so tasks can resume +waiting workflows through `emitValue(...)` or typed `WorkflowEventRef` +instances when a workflow runtime is attached. + ### Retry from a running task Handlers can request a retry directly from the context: diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index b99a12ac..8619715f 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.1 +- Made `TaskContext` and `TaskInvocationContext` implement + `WorkflowEventEmitter` when a workflow runtime is attached, so inline + handlers and isolate entrypoints can resume waiting workflows with + `emitValue(...)` or typed `WorkflowEventRef`. - Made `TaskContext` and `TaskInvocationContext` implement `WorkflowCaller` when a workflow runtime is attached, so inline handlers and isolate entrypoints can start and wait for typed child workflows directly. diff --git a/packages/stem/README.md b/packages/stem/README.md index 876406e0..ee8bc8e1 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -300,7 +300,8 @@ class ParentTask implements TaskHandler { ``` When a task runs inside a workflow-enabled runtime like `StemWorkflowApp`, -`TaskContext` and `TaskInvocationContext` can also start typed child workflows: +`TaskContext` and `TaskInvocationContext` can also start typed child workflows +and emit typed workflow events: ```dart final childWorkflow = Flow( @@ -322,6 +323,19 @@ class ParentTask implements TaskHandler { return result?.value ?? 'missing'; } } + +class NotifyTask implements TaskHandler { + @override + String get name => 'demo.notify'; + + @override + Future call(TaskContext context, Map args) async { + await context.emitValue( + 'demo.child.workflow.ready', + {'status': 'ready'}, + ); + } +} ``` ### Bootstrap helpers diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index dbce3813..048aba1b 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -570,6 +570,7 @@ class StemWorkflowApp [...moduleTasks, ...tasks], ); appInstance.worker.workflows = runtime; + appInstance.worker.workflowEvents = runtime; appInstance.register(runtime.workflowRunnerHandler()); [ diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 94492dab..7341cb0d 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -41,6 +41,7 @@ import 'package:stem/src/core/task_payload_encoder.dart'; import 'package:stem/src/observability/heartbeat.dart'; import 'package:stem/src/scheduler/schedule_spec.dart'; import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; +import 'package:stem/src/workflow/core/workflow_event_ref.dart'; import 'package:stem/src/workflow/core/workflow_ref.dart'; import 'package:stem/src/workflow/core/workflow_result.dart'; @@ -1686,7 +1687,8 @@ class TaskEnqueueScope { } /// Context passed to handler implementations during execution. -class TaskContext implements TaskEnqueuer, WorkflowCaller { +class TaskContext + implements TaskEnqueuer, WorkflowCaller, WorkflowEventEmitter { /// Creates a task execution context for a handler invocation. TaskContext({ required this.id, @@ -1698,6 +1700,7 @@ class TaskContext implements TaskEnqueuer, WorkflowCaller { required this.progress, this.enqueuer, this.workflows, + this.workflowEvents, }); /// The unique identifier of the task. @@ -1731,6 +1734,9 @@ class TaskContext implements TaskEnqueuer, WorkflowCaller { /// Optional workflow caller for starting child workflows. final WorkflowCaller? workflows; + /// Optional workflow event emitter for resuming waiting workflows. + final WorkflowEventEmitter? workflowEvents; + /// Enqueue a task with default context propagation. /// /// Headers and metadata from this context are merged into the enqueue @@ -1870,6 +1876,28 @@ class TaskContext implements TaskEnqueuer, WorkflowCaller { ); } + @override + Future emitValue( + String topic, + T value, { + PayloadCodec? codec, + }) { + final delegate = workflowEvents; + if (delegate == null) { + throw StateError('TaskContext has no workflow event emitter configured'); + } + return delegate.emitValue(topic, value, codec: codec); + } + + @override + Future emitEvent(WorkflowEventRef event, T value) { + final delegate = workflowEvents; + if (delegate == null) { + throw StateError('TaskContext has no workflow event emitter configured'); + } + return delegate.emitEvent(event, value); + } + /// Alias for [enqueue]. Future spawn( String name, { diff --git a/packages/stem/lib/src/core/function_task_handler.dart b/packages/stem/lib/src/core/function_task_handler.dart index 6f03db6d..e642cf45 100644 --- a/packages/stem/lib/src/core/function_task_handler.dart +++ b/packages/stem/lib/src/core/function_task_handler.dart @@ -62,6 +62,7 @@ class FunctionTaskHandler implements TaskHandler { progress: (percent, {data}) => context.progress(percent, data: data), enqueuer: context.enqueuer, workflows: context.workflows, + workflowEvents: context.workflowEvents, ); final result = await _entrypoint(invocationContext, args); return result as R; diff --git a/packages/stem/lib/src/core/task_invocation.dart b/packages/stem/lib/src/core/task_invocation.dart index dc742507..bbfe7fa3 100644 --- a/packages/stem/lib/src/core/task_invocation.dart +++ b/packages/stem/lib/src/core/task_invocation.dart @@ -37,7 +37,9 @@ import 'dart:async'; import 'dart:isolate'; import 'package:stem/src/core/contracts.dart'; +import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; +import 'package:stem/src/workflow/core/workflow_event_ref.dart'; import 'package:stem/src/workflow/core/workflow_ref.dart'; import 'package:stem/src/workflow/core/workflow_result.dart'; @@ -116,6 +118,18 @@ class WaitForWorkflowSignal extends TaskInvocationSignal { final SendPort replyPort; } +/// Request to emit a workflow event from an isolate. +class EmitWorkflowEventSignal extends TaskInvocationSignal { + /// Creates a workflow event emit request signal. + const EmitWorkflowEventSignal(this.request, this.replyPort); + + /// Workflow event emit request payload. + final EmitWorkflowEventRequest request; + + /// Port to deliver the response. + final SendPort replyPort; +} + /// Enqueue request payload for isolate communication. class TaskEnqueueRequest { /// Creates an enqueue request payload. @@ -237,8 +251,33 @@ class WaitForWorkflowResponse { final String? error; } +/// Workflow event emit request payload for isolate communication. +class EmitWorkflowEventRequest { + /// Creates a workflow event emit request payload. + const EmitWorkflowEventRequest({ + required this.topic, + required this.payload, + }); + + /// Workflow event topic to emit. + final String topic; + + /// Encoded workflow event payload. + final Map payload; +} + +/// Response payload for isolate workflow event emit requests. +class EmitWorkflowEventResponse { + /// Creates a workflow event emit response payload. + const EmitWorkflowEventResponse({this.error}); + + /// Error message when workflow event emission fails. + final String? error; +} + /// Context exposed to task entrypoints regardless of execution environment. -class TaskInvocationContext implements TaskEnqueuer, WorkflowCaller { +class TaskInvocationContext + implements TaskEnqueuer, WorkflowCaller, WorkflowEventEmitter { /// Context implementation used when executing locally in the same isolate. factory TaskInvocationContext.local({ required String id, @@ -254,6 +293,7 @@ class TaskInvocationContext implements TaskEnqueuer, WorkflowCaller { progress, TaskEnqueuer? enqueuer, WorkflowCaller? workflows, + WorkflowEventEmitter? workflowEvents, }) => TaskInvocationContext._( id: id, headers: headers, @@ -264,6 +304,7 @@ class TaskInvocationContext implements TaskEnqueuer, WorkflowCaller { progress: progress, enqueuer: enqueuer, workflows: workflows, + workflowEvents: workflowEvents, ); /// Context implementation used when executing inside a worker isolate. @@ -284,6 +325,7 @@ class TaskInvocationContext implements TaskEnqueuer, WorkflowCaller { controlPort.send(ProgressSignal(percent, data: data)), enqueuer: _RemoteTaskEnqueuer(controlPort), workflows: _RemoteWorkflowCaller(controlPort), + workflowEvents: _RemoteWorkflowEventEmitter(controlPort), ); /// Internal constructor shared by local and isolate contexts. @@ -301,11 +343,13 @@ class TaskInvocationContext implements TaskEnqueuer, WorkflowCaller { progress, TaskEnqueuer? enqueuer, WorkflowCaller? workflows, + WorkflowEventEmitter? workflowEvents, }) : _heartbeat = heartbeat, _extendLease = extendLease, _progress = progress, _enqueuer = enqueuer, - _workflows = workflows; + _workflows = workflows, + _workflowEvents = workflowEvents; /// The unique identifier of the task. final String id; @@ -333,6 +377,9 @@ class TaskInvocationContext implements TaskEnqueuer, WorkflowCaller { /// Optional delegate used to start child workflows from the invocation. final WorkflowCaller? _workflows; + /// Optional delegate used to emit workflow events from the invocation. + final WorkflowEventEmitter? _workflowEvents; + /// Notify the worker that the task is still running. void heartbeat() => _heartbeat(); @@ -486,6 +533,32 @@ class TaskInvocationContext implements TaskEnqueuer, WorkflowCaller { ); } + @override + Future emitValue( + String topic, + T value, { + PayloadCodec? codec, + }) { + final delegate = _workflowEvents; + if (delegate == null) { + throw StateError( + 'TaskInvocationContext has no workflow event emitter configured', + ); + } + return delegate.emitValue(topic, value, codec: codec); + } + + @override + Future emitEvent(WorkflowEventRef event, T value) { + final delegate = _workflowEvents; + if (delegate == null) { + throw StateError( + 'TaskInvocationContext has no workflow event emitter configured', + ); + } + return delegate.emitEvent(event, value); + } + /// Build a fluent enqueue request for this invocation. /// /// Use [TaskEnqueueBuilder.build] + [enqueueCall] to dispatch. @@ -693,3 +766,57 @@ class _RemoteWorkflowCaller implements WorkflowCaller { throw StateError('Unexpected workflow wait response: $response'); } } + +class _RemoteWorkflowEventEmitter implements WorkflowEventEmitter { + _RemoteWorkflowEventEmitter(this._controlPort); + + final SendPort _controlPort; + + @override + Future emitValue( + String topic, + T value, { + PayloadCodec? codec, + }) async { + final encoded = codec != null ? codec.encodeDynamic(value) : value; + if (encoded is! Map) { + throw StateError( + 'TaskInvocationContext workflow events must encode to ' + 'Map, got ${encoded.runtimeType}.', + ); + } + final payload = {}; + for (final entry in encoded.entries) { + final key = entry.key; + if (key is! String) { + throw StateError( + 'TaskInvocationContext workflow event payload keys must be strings, ' + 'got ${key.runtimeType}.', + ); + } + payload[key] = entry.value; + } + + final responsePort = ReceivePort(); + _controlPort.send( + EmitWorkflowEventSignal( + EmitWorkflowEventRequest(topic: topic, payload: payload), + responsePort.sendPort, + ), + ); + final response = await responsePort.first; + responsePort.close(); + if (response is EmitWorkflowEventResponse) { + if (response.error != null) { + throw StateError(response.error!); + } + return; + } + throw StateError('Unexpected workflow event response: $response'); + } + + @override + Future emitEvent(WorkflowEventRef event, T value) { + return emitValue(event.topic, value, codec: event.codec); + } +} diff --git a/packages/stem/lib/src/worker/worker.dart b/packages/stem/lib/src/worker/worker.dart index ed2ad09f..1bfa4a25 100644 --- a/packages/stem/lib/src/worker/worker.dart +++ b/packages/stem/lib/src/worker/worker.dart @@ -124,6 +124,7 @@ import 'package:stem/src/signals/payloads.dart'; import 'package:stem/src/worker/isolate_pool.dart'; import 'package:stem/src/worker/worker_config.dart'; import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; +import 'package:stem/src/workflow/core/workflow_event_ref.dart'; import 'package:stem/src/workflow/core/workflow_ref.dart'; /// Shutdown modes for workers. @@ -274,6 +275,8 @@ class Worker { /// Created automatically if not provided. /// - [workflows]: Optional workflow caller used when task handlers need to /// start or wait for child workflows. + /// - [workflowEvents]: Optional workflow event emitter used when task + /// handlers need to resume waiting workflows by topic or typed event ref. /// - [rateLimiter]: Enforces per-task rate limits. Rate limits are defined /// on individual handlers via [TaskOptions.rateLimit]. /// - [middleware]: List of middleware for intercepting task lifecycle events. @@ -309,6 +312,7 @@ class Worker { TaskRegistry? registry, Stem? enqueuer, WorkflowCaller? workflows, + WorkflowEventEmitter? workflowEvents, RateLimiter? rateLimiter, List middleware = const [], RevokeStore? revokeStore, @@ -336,6 +340,7 @@ class Worker { broker: broker, enqueuer: enqueuer, workflows: workflows, + workflowEvents: workflowEvents, registry: _resolveTaskRegistry(registry, tasks), backend: backend, rateLimiter: rateLimiter, @@ -369,6 +374,7 @@ class Worker { required this.backend, required Stem? enqueuer, this.workflows, + this.workflowEvents, this.rateLimiter, this.middleware = const [], this.revokeStore, @@ -561,6 +567,9 @@ class Worker { /// Active workflow caller used by task handlers, if configured. WorkflowCaller? workflows; + /// Workflow event emitter used by task contexts for workflow resumes. + WorkflowEventEmitter? workflowEvents; + static final math.Random _random = math.Random(); /// Resolved routing subscription for this worker. @@ -979,6 +988,7 @@ class Worker { }, enqueuer: _enqueuer, workflows: workflows, + workflowEvents: workflowEvents, ); await _signals.taskPrerun(envelope, _workerInfoSnapshot, context); @@ -4586,6 +4596,27 @@ class Worker { WaitForWorkflowResponse(error: error.toString()), ); } + } else if (signal is EmitWorkflowEventSignal) { + try { + final workflowEvents = this.workflowEvents; + if (workflowEvents == null) { + signal.replyPort.send( + const EmitWorkflowEventResponse( + error: 'No workflow event emitter configured', + ), + ); + return; + } + await workflowEvents.emitValue>( + signal.request.topic, + signal.request.payload, + ); + signal.replyPort.send(const EmitWorkflowEventResponse()); + } on Exception catch (error) { + signal.replyPort.send( + EmitWorkflowEventResponse(error: error.toString()), + ); + } } }; } diff --git a/packages/stem/test/unit/core/task_context_enqueue_test.dart b/packages/stem/test/unit/core/task_context_enqueue_test.dart index 1af79216..f124e6ae 100644 --- a/packages/stem/test/unit/core/task_context_enqueue_test.dart +++ b/packages/stem/test/unit/core/task_context_enqueue_test.dart @@ -274,6 +274,51 @@ void main() { ); }); }); + + group('TaskContext workflow events', () { + test('delegates typed workflow events to the configured emitter', () async { + final workflowEvents = _RecordingWorkflowEventEmitter(); + final context = TaskContext( + id: 'event-task', + attempt: 0, + headers: const {}, + meta: const {}, + heartbeat: () {}, + extendLease: (_) async {}, + progress: (_, {data}) async {}, + workflowEvents: workflowEvents, + ); + const event = WorkflowEventRef>( + topic: 'workflow.ready', + ); + + await context.emitValue('workflow.inline', const {'value': 'inline'}); + await context.emitEvent(event, const {'value': 'event'}); + + expect(workflowEvents.topics, ['workflow.inline', 'workflow.ready']); + expect(workflowEvents.payloads, [ + {'value': 'inline'}, + {'value': 'event'}, + ]); + }); + + test('throws when no workflow event emitter is configured', () { + final context = TaskContext( + id: 'no-workflow-events', + attempt: 0, + headers: const {}, + meta: const {}, + heartbeat: () {}, + extendLease: (_) async {}, + progress: (_, {data}) async {}, + ); + + expect( + () => context.emitValue('workflow.ready', const {'value': true}), + throwsStateError, + ); + }); + }); } class _ExampleArgs { @@ -401,3 +446,23 @@ class _RecordingWorkflowCaller implements WorkflowCaller { ); } } + +class _RecordingWorkflowEventEmitter implements WorkflowEventEmitter { + final List topics = []; + final List> payloads = >[]; + + @override + Future emitValue( + String topic, + T value, { + PayloadCodec? codec, + }) async { + topics.add(topic); + payloads.add(Map.from(value! as Map)); + } + + @override + Future emitEvent(WorkflowEventRef event, T value) { + return emitValue(event.topic, value, codec: event.codec); + } +} diff --git a/packages/stem/test/unit/core/task_invocation_test.dart b/packages/stem/test/unit/core/task_invocation_test.dart index 97547f39..236f39da 100644 --- a/packages/stem/test/unit/core/task_invocation_test.dart +++ b/packages/stem/test/unit/core/task_invocation_test.dart @@ -1,9 +1,11 @@ import 'dart:isolate'; import 'package:stem/src/core/contracts.dart'; +import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/core/task_invocation.dart'; import 'package:stem/src/workflow/core/run_state.dart'; import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; +import 'package:stem/src/workflow/core/workflow_event_ref.dart'; import 'package:stem/src/workflow/core/workflow_ref.dart'; import 'package:stem/src/workflow/core/workflow_result.dart'; import 'package:stem/src/workflow/core/workflow_status.dart'; @@ -105,6 +107,27 @@ class _CapturingWorkflowCaller implements WorkflowCaller { } } +class _CapturingWorkflowEventEmitter implements WorkflowEventEmitter { + final List topics = []; + final List> payloads = >[]; + + @override + Future emitValue( + String topic, + T value, { + PayloadCodec? codec, + }) async { + final encoded = codec != null ? codec.encode(value) : value; + payloads.add(Map.from(encoded! as Map)); + topics.add(topic); + } + + @override + Future emitEvent(WorkflowEventRef event, T value) { + return emitValue(event.topic, value, codec: event.codec); + } +} + void main() { test('TaskInvocationContext.local merges headers/meta and lineage', () async { final enqueuer = _CapturingEnqueuer('task-1'); @@ -243,6 +266,29 @@ void main() { expect(result?.value, 'workflow-result'); }); + test('TaskInvocationContext.local delegates typed workflow events', () async { + final workflowEvents = _CapturingWorkflowEventEmitter(); + final context = TaskInvocationContext.local( + id: 'root-task', + headers: const {}, + meta: const {}, + attempt: 1, + heartbeat: () {}, + extendLease: (_) async {}, + progress: (_, {Map? data}) async {}, + workflowEvents: workflowEvents, + ); + + await context.emitValue('workflow.inline', const {'value': 'inline'}); + await context.emitEvent(_eventRef, const _WorkflowEventPayload('event')); + + expect(workflowEvents.topics, ['workflow.inline', 'workflow.ready']); + expect(workflowEvents.payloads, [ + {'value': 'inline'}, + {'value': 'event'}, + ]); + }); + test('TaskInvocationContext.remote sends control signals', () async { final control = ReceivePort(); addTearDown(control.close); @@ -337,6 +383,35 @@ void main() { }, ); + test( + 'TaskInvocationContext.remote proxies workflow event emission', + () async { + final control = ReceivePort(); + addTearDown(control.close); + + EmitWorkflowEventRequest? request; + control.listen((message) { + if (message is EmitWorkflowEventSignal) { + request = message.request; + message.replyPort.send(const EmitWorkflowEventResponse()); + } + }); + + final context = TaskInvocationContext.remote( + id: 'remote-task', + controlPort: control.sendPort, + headers: const {}, + meta: const {}, + attempt: 0, + ); + + await context.emitEvent(_eventRef, const _WorkflowEventPayload('remote')); + + expect(request?.topic, 'workflow.ready'); + expect(request?.payload, {'value': 'remote'}); + }, + ); + test('TaskInvocationContext.remote surfaces enqueue errors', () async { final control = ReceivePort(); addTearDown(control.close); @@ -391,6 +466,32 @@ void main() { ); }); + test('TaskInvocationContext.remote surfaces workflow event errors', () async { + final control = ReceivePort(); + addTearDown(control.close); + + control.listen((message) { + if (message is EmitWorkflowEventSignal) { + message.replyPort.send( + const EmitWorkflowEventResponse(error: 'event nope'), + ); + } + }); + + final context = TaskInvocationContext.remote( + id: 'remote-task', + controlPort: control.sendPort, + headers: const {}, + meta: const {}, + attempt: 0, + ); + + await expectLater( + () => context.emitEvent(_eventRef, const _WorkflowEventPayload('oops')), + throwsA(isA()), + ); + }); + test('TaskInvocationContext.retry throws TaskRetryRequest', () { final context = TaskInvocationContext.local( id: 'retry-task', @@ -410,4 +511,32 @@ void main() { }); } +class _WorkflowEventPayload { + const _WorkflowEventPayload(this.value); + + final String value; +} + +const PayloadCodec<_WorkflowEventPayload> _eventPayloadCodec = + PayloadCodec<_WorkflowEventPayload>( + encode: _encodeWorkflowEventPayload, + decode: _decodeWorkflowEventPayload, + ); + +const WorkflowEventRef<_WorkflowEventPayload> _eventRef = + WorkflowEventRef<_WorkflowEventPayload>( + topic: 'workflow.ready', + codec: _eventPayloadCodec, + ); + +Map _encodeWorkflowEventPayload(_WorkflowEventPayload value) { + return {'value': value.value}; +} + +_WorkflowEventPayload _decodeWorkflowEventPayload(Object? payload) { + return _WorkflowEventPayload( + (payload! as Map)['value']! as String, + ); +} + Map _encodeArgs(Map args) => args; diff --git a/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart b/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart index b69b8114..2a790b97 100644 --- a/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart +++ b/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart @@ -22,6 +22,36 @@ final Flow _childWorkflow = Flow( final NoArgsWorkflowRef _childWorkflowRef = _childWorkflow.ref0(); +const PayloadCodec<_WorkflowEventPayload> _workflowEventPayloadCodec = + PayloadCodec<_WorkflowEventPayload>( + encode: _encodeWorkflowEventPayload, + decode: _decodeWorkflowEventPayload, + ); + +const WorkflowEventRef<_WorkflowEventPayload> _workflowEventRef = + WorkflowEventRef<_WorkflowEventPayload>( + topic: 'tasks.workflow.ready', + codec: _workflowEventPayloadCodec, + ); + +final Flow _waitingWorkflow = Flow( + name: 'tasks.waiting.workflow', + build: (flow) { + flow.step('wait-for-event', (context) async { + final event = context.waitForEventValue<_WorkflowEventPayload>( + _workflowEventRef.topic, + codec: _workflowEventPayloadCodec, + ); + if (event == null) { + return null; + } + return event.value; + }); + }, +); + +final NoArgsWorkflowRef _waitingWorkflowRef = _waitingWorkflow.ref0(); + void main() { group('TaskInvocationContext enqueue', () { test('enqueues from isolate entrypoint using builder', () async { @@ -83,6 +113,31 @@ void main() { await app.close(); }); + + test('emits workflow events from isolate entrypoints', () async { + final app = await StemWorkflowApp.inMemory( + tasks: [_IsolateEmitWorkflowEventTask()], + flows: [_waitingWorkflow], + ); + + final runId = await _waitingWorkflowRef.startWith(app); + final taskId = await app.enqueue('tasks.isolate.emit.workflow.event'); + + final taskResult = await app.waitForTask( + taskId, + timeout: const Duration(seconds: 3), + ); + final workflowResult = await _waitingWorkflowRef.waitFor( + app, + runId, + timeout: const Duration(seconds: 3), + ); + + expect(taskResult?.isSucceeded, isTrue); + expect(workflowResult?.value, equals('workflow-ready')); + + await app.close(); + }); }); test('enqueue + execute round-trip is stable', () async { @@ -453,6 +508,12 @@ class _ChildArgs { final String value; } +class _WorkflowEventPayload { + const _WorkflowEventPayload(this.value); + + final String value; +} + class _IsolateEnqueueTask implements TaskHandler { @override String get name => 'tasks.isolate.enqueue'; @@ -511,3 +572,41 @@ FutureOr _isolateStartWorkflowEntrypoint( ); return result?.value; } + +class _IsolateEmitWorkflowEventTask implements TaskHandler { + @override + String get name => 'tasks.isolate.emit.workflow.event'; + + @override + TaskOptions get options => const TaskOptions(); + + @override + TaskMetadata get metadata => const TaskMetadata(); + + @override + TaskEntrypoint? get isolateEntrypoint => _isolateEmitWorkflowEventEntrypoint; + + @override + Future call(TaskContext context, Map args) async {} +} + +FutureOr _isolateEmitWorkflowEventEntrypoint( + TaskInvocationContext context, + Map args, +) async { + await context.emitEvent( + _workflowEventRef, + const _WorkflowEventPayload('workflow-ready'), + ); + return null; +} + +Map _encodeWorkflowEventPayload(_WorkflowEventPayload value) { + return {'value': value.value}; +} + +_WorkflowEventPayload _decodeWorkflowEventPayload(Object? payload) { + return _WorkflowEventPayload( + (payload! as Map)['value']! as String, + ); +} From a6ca3e138b644ae868dabf0e1c5aa93648c3843d Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 23:24:25 -0500 Subject: [PATCH 116/302] Add caller-bound workflow start builders --- .site/docs/workflows/annotated-workflows.md | 4 +- .../workflows/context-and-serialization.md | 10 +-- .site/docs/workflows/starting-and-waiting.md | 14 ++++ packages/stem/CHANGELOG.md | 4 + packages/stem/README.md | 8 +- .../example/annotated_workflows/README.md | 6 +- .../annotated_workflows/lib/definitions.dart | 22 ++--- .../lib/src/workflow/core/workflow_ref.dart | 83 +++++++++++++++++++ .../unit/core/task_context_enqueue_test.dart | 34 ++++++++ .../workflow/workflow_runtime_ref_test.dart | 70 ++++++++++++++++ packages/stem_builder/README.md | 4 +- 11 files changed, 235 insertions(+), 24 deletions(-) diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index 4329b387..fe4473f0 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -132,8 +132,8 @@ This keeps one authoring model: When a workflow needs to start another workflow, do it from a durable boundary: - `FlowContext` and `WorkflowScriptStepContext` both implement - `WorkflowCaller`, so use - `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, value)` + `WorkflowCaller`, so prefer + `context.startWorkflowBuilder(definition: ref, params: value).startAndWait()` inside flow steps and checkpoint methods Avoid starting child workflows from the raw `WorkflowScriptContext` body. diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index b1eb0d24..269ccd11 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -39,8 +39,8 @@ Depending on the context type, you can access: - `takeResumeValue(codec: ...)` for typed event-driven resumes - `idempotencyKey(...)` - direct child-workflow start helpers such as - `StemWorkflowDefinitions.someWorkflow.startWith(context, value)` and - `startAndWaitWith(context, value)` + `context.startWorkflowBuilder(definition: ref, params: value).start()` and + `.startAndWait()` - direct task enqueue APIs because `FlowContext`, `WorkflowScriptStepContext`, and `TaskInvocationContext` all implement `TaskEnqueuer` @@ -48,9 +48,9 @@ Depending on the context type, you can access: Child workflow starts belong in durable boundaries: -- `StemWorkflowDefinitions.someWorkflow.startWith(context, value)` inside flow - steps -- `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, value)` +- `context.startWorkflowBuilder(definition: ref, params: value).start()` + inside flow steps +- `context.startWorkflowBuilder(definition: ref, params: value).startAndWait()` inside script checkpoints Do not treat the raw `WorkflowScriptContext` body as a safe place for child diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 85370b81..01b8177b 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -99,6 +99,20 @@ final runId = await StemWorkflowDefinitions.userSignup.startWith( ); ``` +When you already have a `WorkflowCaller` like `FlowContext`, +`WorkflowScriptStepContext`, `WorkflowRuntime`, or `StemWorkflowApp`, prefer +the caller-bound fluent builder: + +```dart +final result = await context + .startWorkflowBuilder( + definition: StemWorkflowDefinitions.userSignup, + params: 'user@example.com', + ) + .ttl(const Duration(hours: 1)) + .startAndWait(timeout: const Duration(seconds: 5)); +``` + If you still need the run identifier for inspection or operator tooling, read it from `result.runId`. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 8619715f..df68b8db 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.1 +- Added `WorkflowCaller.startWorkflowBuilder(...)` / + `startNoArgsWorkflowBuilder(...)` plus a caller-bound fluent builder so + workflow-capable contexts, apps, and runtimes can start child workflows with + the same builder-first ergonomics already used for typed task enqueue. - Made `TaskContext` and `TaskInvocationContext` implement `WorkflowEventEmitter` when a workflow runtime is attached, so inline handlers and isolate entrypoints can resume waiting workflows with diff --git a/packages/stem/README.md b/packages/stem/README.md index ee8bc8e1..f35f341d 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -319,7 +319,9 @@ class ParentTask implements TaskHandler { @override Future call(TaskContext context, Map args) async { - final result = await childWorkflowRef.startAndWaitWith(context); + final result = await context + .startNoArgsWorkflowBuilder(definition: childWorkflowRef) + .startAndWait(); return result?.value ?? 'missing'; } } @@ -639,8 +641,8 @@ Durable workflow contexts enqueue tasks directly: Child workflows belong in durable execution boundaries: - `FlowContext` and `WorkflowScriptStepContext` both implement - `WorkflowCaller`, so use - `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, value)` + `WorkflowCaller`, so prefer + `context.startWorkflowBuilder(definition: ref, params: value).startAndWait()` inside flow steps and script checkpoints - do not start child workflows from the raw `WorkflowScriptContext` body unless you are deliberately managing replay/idempotency yourself diff --git a/packages/stem/example/annotated_workflows/README.md b/packages/stem/example/annotated_workflows/README.md index 8791ffeb..0ed8e0a4 100644 --- a/packages/stem/example/annotated_workflows/README.md +++ b/packages/stem/example/annotated_workflows/README.md @@ -6,7 +6,8 @@ with the `stem_builder` bundle generator. It now demonstrates the generated script-proxy behavior explicitly: - a flow step using `FlowContext` - a flow step starting and waiting on a child workflow through - `StemWorkflowDefinitions.*.startAndWaitWith(context, value)` + `context.startWorkflowBuilder(definition: StemWorkflowDefinitions.*, params: + value).startAndWait()` - `run(WelcomeRequest request)` calls annotated checkpoint methods directly - `prepareWelcome(...)` calls other annotated checkpoints - `deliverWelcome(...)` calls another annotated checkpoint from inside an @@ -16,7 +17,8 @@ It now demonstrates the generated script-proxy behavior explicitly: expose `runId`, `workflow`, `stepName`, `stepIndex`, and idempotency keys while still calling its annotated checkpoint directly from `run(...)` - a script checkpoint starting and waiting on a child workflow through - `StemWorkflowDefinitions.*.startAndWaitWith(context, value)` + `context.startWorkflowBuilder(definition: StemWorkflowDefinitions.*, params: + value).startAndWait()` - a plain script workflow that returns a codec-backed DTO result and persists a codec-backed DTO checkpoint value - a typed `@TaskDefn` using optional named `TaskInvocationContext? context` diff --git a/packages/stem/example/annotated_workflows/lib/definitions.dart b/packages/stem/example/annotated_workflows/lib/definitions.dart index be9c4127..0c524bd1 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.dart @@ -194,11 +194,12 @@ class AnnotatedFlowWorkflow { if (!ctx.sleepUntilResumed(const Duration(milliseconds: 50))) { return null; } - final childResult = await StemWorkflowDefinitions.script.startAndWaitWith( - ctx, - const WelcomeRequest(email: 'flow-child@example.com'), - timeout: const Duration(seconds: 2), - ); + final childResult = await ctx + .startWorkflowBuilder( + definition: StemWorkflowDefinitions.script, + params: const WelcomeRequest(email: 'flow-child@example.com'), + ) + .startAndWait(timeout: const Duration(seconds: 2)); return { 'workflow': ctx.workflow, 'runId': ctx.runId, @@ -274,11 +275,12 @@ class AnnotatedContextScriptWorkflow { final ctx = context!; final normalizedEmail = await normalizeEmail(request.email); final subject = await buildWelcomeSubject(normalizedEmail); - final childResult = await StemWorkflowDefinitions.script.startAndWaitWith( - ctx, - WelcomeRequest(email: normalizedEmail), - timeout: const Duration(seconds: 2), - ); + final childResult = await ctx + .startWorkflowBuilder( + definition: StemWorkflowDefinitions.script, + params: WelcomeRequest(email: normalizedEmail), + ) + .startAndWait(timeout: const Duration(seconds: 2)); return ContextCaptureResult( workflow: ctx.workflow, runId: ctx.runId, diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 5d12d00d..03c5b885 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -399,6 +399,89 @@ extension WorkflowStartBuilderExtension } } +/// Caller-bound fluent workflow start builder. +/// +/// This mirrors the role `TaskInvocationContext.enqueueBuilder(...)` plays for +/// tasks: a workflow-capable caller can create a fluent start request without +/// pivoting back through the workflow ref for dispatch. +class BoundWorkflowStartBuilder { + /// Creates a caller-bound workflow start builder. + BoundWorkflowStartBuilder._({ + required WorkflowCaller caller, + required WorkflowStartBuilder builder, + }) : _caller = caller, + _builder = builder; + + final WorkflowCaller _caller; + final WorkflowStartBuilder _builder; + + /// Sets the parent workflow run id for this start. + BoundWorkflowStartBuilder parentRunId(String parentRunId) { + _builder.parentRunId(parentRunId); + return this; + } + + /// Sets the retention TTL for this run. + BoundWorkflowStartBuilder ttl(Duration ttl) { + _builder.ttl(ttl); + return this; + } + + /// Sets the cancellation policy for this run. + BoundWorkflowStartBuilder cancellationPolicy( + WorkflowCancellationPolicy cancellationPolicy, + ) { + _builder.cancellationPolicy(cancellationPolicy); + return this; + } + + /// Builds the [WorkflowStartCall] with accumulated overrides. + WorkflowStartCall build() => _builder.build(); + + /// Starts the built workflow call with the bound caller. + Future start() => _builder.startWith(_caller); + + /// Starts the built workflow call with the bound caller and waits for the + /// typed workflow result. + Future?> startAndWait({ + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return _builder.startAndWaitWith( + _caller, + pollInterval: pollInterval, + timeout: timeout, + ); + } +} + +/// Convenience helpers for building typed workflow starts directly from a +/// workflow-capable caller. +extension WorkflowCallerBuilderExtension on WorkflowCaller { + /// Creates a caller-bound fluent start builder for a typed workflow ref. + BoundWorkflowStartBuilder + startWorkflowBuilder({ + required WorkflowRef definition, + required TParams params, + }) { + return BoundWorkflowStartBuilder._( + caller: this, + builder: definition.startBuilder(params), + ); + } + + /// Creates a caller-bound fluent start builder for a no-args workflow ref. + BoundWorkflowStartBuilder<(), TResult> + startNoArgsWorkflowBuilder({ + required NoArgsWorkflowRef definition, + }) { + return BoundWorkflowStartBuilder._( + caller: this, + builder: definition.startBuilder(), + ); + } +} + /// Convenience helpers for waiting on typed workflow refs using a generic /// [WorkflowCaller]. extension WorkflowRefExtension diff --git a/packages/stem/test/unit/core/task_context_enqueue_test.dart b/packages/stem/test/unit/core/task_context_enqueue_test.dart index f124e6ae..818a6cda 100644 --- a/packages/stem/test/unit/core/task_context_enqueue_test.dart +++ b/packages/stem/test/unit/core/task_context_enqueue_test.dart @@ -273,6 +273,38 @@ void main() { throwsStateError, ); }); + + test('builds child workflow starts directly from the context', () async { + final workflows = _RecordingWorkflowCaller(); + final context = TaskContext( + id: 'workflow-builder-task', + attempt: 0, + headers: const {}, + meta: const {}, + heartbeat: () {}, + extendLease: (_) async {}, + progress: (_, {data}) async {}, + workflows: workflows, + ); + final definition = WorkflowRef, String>( + name: 'workflow.child', + encodeParams: (params) => params, + ); + + final result = await context + .startWorkflowBuilder( + definition: definition, + params: const {'value': 'child'}, + ) + .parentRunId('parent-task') + .startAndWait(); + + expect(workflows.lastWorkflowName, 'workflow.child'); + expect(workflows.lastWorkflowParams, {'value': 'child'}); + expect(workflows.lastParentRunId, 'parent-task'); + expect(workflows.waitedRunId, 'run-1'); + expect(result?.value, 'child-result'); + }); }); group('TaskContext workflow events', () { @@ -392,6 +424,7 @@ class _RecordingEnqueuer implements TaskEnqueuer { class _RecordingWorkflowCaller implements WorkflowCaller { String? lastWorkflowName; Map? lastWorkflowParams; + String? lastParentRunId; String? waitedRunId; @override @@ -404,6 +437,7 @@ class _RecordingWorkflowCaller implements WorkflowCaller { }) async { lastWorkflowName = definition.name; lastWorkflowParams = definition.encodeParams(params); + lastParentRunId = parentRunId; return 'run-1'; } diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 143c2070..f111e45b 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -299,6 +299,76 @@ void main() { } }); + test('workflow callers expose bound workflow builders', () async { + final flow = Flow( + name: 'runtime.ref.bound.builder.flow', + build: (builder) { + builder.step('hello', (ctx) async { + final name = ctx.params['name'] as String? ?? 'world'; + return 'hello $name'; + }); + }, + ); + final script = WorkflowScript( + name: 'runtime.ref.bound.builder.script', + run: (context) async => 'hello script', + ); + + final workflowRef = flow.ref>( + encodeParams: (params) => params, + ); + final scriptRef = script.ref0(); + + final workflowApp = await StemWorkflowApp.inMemory( + flows: [flow], + scripts: [script], + ); + try { + await workflowApp.start(); + + final flowBuilder = workflowApp.runtime + .startWorkflowBuilder( + definition: workflowRef, + params: const {'name': 'builder'}, + ) + .ttl(const Duration(minutes: 5)) + .parentRunId('parent-bound'); + final builtFlowCall = flowBuilder.build(); + final runId = await flowBuilder.start(); + final result = await workflowRef.waitFor( + workflowApp.runtime, + runId, + timeout: const Duration(seconds: 2), + ); + final state = await workflowApp.getRun(runId); + + expect(builtFlowCall.parentRunId, 'parent-bound'); + expect(builtFlowCall.ttl, const Duration(minutes: 5)); + expect(result?.value, 'hello builder'); + expect(state?.parentRunId, 'parent-bound'); + + final scriptBuilder = workflowApp.runtime + .startNoArgsWorkflowBuilder(definition: scriptRef) + .cancellationPolicy( + const WorkflowCancellationPolicy( + maxRunDuration: Duration(seconds: 5), + ), + ); + final builtScriptCall = scriptBuilder.build(); + final oneShot = await scriptBuilder.startAndWait( + timeout: const Duration(seconds: 2), + ); + + expect( + builtScriptCall.cancellationPolicy?.maxRunDuration, + const Duration(seconds: 5), + ); + expect(oneShot?.value, 'hello script'); + } finally { + await workflowApp.shutdown(); + } + }); + test('typed workflow events emit directly from the event ref', () async { final flow = Flow( name: 'runtime.ref.event.flow', diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index 805d4fbb..7e80df3c 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -111,9 +111,9 @@ Durable workflow contexts enqueue tasks directly: Child workflows should be started from durable boundaries: -- `StemWorkflowDefinitions.someWorkflow.startWith(context, value)` +- `context.startWorkflowBuilder(definition: ref, params: value).start()` inside flow steps -- `StemWorkflowDefinitions.someWorkflow.startAndWaitWith(context, value)` +- `context.startWorkflowBuilder(definition: ref, params: value).startAndWait()` inside script checkpoints Avoid starting child workflows directly from the raw From 5610a3c2c0d98c84a8686ba9cc072ba9ab2047ca Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 23:26:11 -0500 Subject: [PATCH 117/302] Add caller-bound workflow event builders --- .../docs/workflows/suspensions-and-events.md | 6 +-- packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 8 ++-- packages/stem/example/durable_watchers.dart | 10 +++-- .../src/workflow/core/workflow_event_ref.dart | 34 +++++++++++++++ .../workflow/workflow_runtime_ref_test.dart | 41 +++++++++++++++++++ 6 files changed, 91 insertions(+), 11 deletions(-) diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index ae76e6c8..a2a696db 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -66,9 +66,9 @@ wire format. `emitValue(...)` is a DTO/codec convenience layer, not a new transport shape. When the topic and codec travel together in your codebase, prefer a typed -`WorkflowEventRef` and `event.emitWith(...)` or -`event.call(value).emitWith(...)` together with `waitForEventRef(...)` or -`awaitEventRef(...)`. +`WorkflowEventRef` and `emitter.emitEventBuilder(event: ref, value: dto) +.emit()`, `event.emitWith(...)`, or `event.call(value).emitWith(...)` together +with `waitForEventRef(...)` or `awaitEventRef(...)`. ## Inspect waiting runs diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index df68b8db..11915444 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -6,6 +6,9 @@ `startNoArgsWorkflowBuilder(...)` plus a caller-bound fluent builder so workflow-capable contexts, apps, and runtimes can start child workflows with the same builder-first ergonomics already used for typed task enqueue. +- Added `WorkflowEventEmitter.emitEventBuilder(...)` plus a caller-bound typed + event call so apps, runtimes, and task/workflow contexts can emit typed + workflow events without bouncing between emitter-first and ref-first styles. - Made `TaskContext` and `TaskInvocationContext` implement `WorkflowEventEmitter` when a workflow runtime is attached, so inline handlers and isolate entrypoints can resume waiting workflows with diff --git a/packages/stem/README.md b/packages/stem/README.md index f35f341d..246a2985 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -1066,10 +1066,10 @@ backend metadata under `stem.unique.duplicates`. - When you have a DTO event, emit it through `workflowApp.emitValue(...)` (or `runtime.emitValue(...)` when you are intentionally using the low-level runtime) with a `PayloadCodec`, or bundle the topic and codec once in a - `WorkflowEventRef` and use `event.emitWith(...)` or - `event.call(value).emitWith(...)` together with `waitForEventRef(...)` or - `awaitEventRef(...)`. Event payloads still serialize onto the existing - `Map` wire format. + `WorkflowEventRef` and use `emitter.emitEventBuilder(event: ref, value: + dto).emit()`, `event.emitWith(...)`, or `event.call(value).emitWith(...)` + together with `waitForEventRef(...)` or `awaitEventRef(...)`. Event payloads + still serialize onto the existing `Map` wire format. - Only return values you want persisted. If a handler returns `null`, the runtime treats it as "no result yet" and will run the step again on resume. - Derive outbound idempotency tokens with `ctx.idempotencyKey('charge')` so diff --git a/packages/stem/example/durable_watchers.dart b/packages/stem/example/durable_watchers.dart index 6fb47442..7b1a2e89 100644 --- a/packages/stem/example/durable_watchers.dart +++ b/packages/stem/example/durable_watchers.dart @@ -57,10 +57,12 @@ Future main() async { print('Watcher metadata: ${watcher.data}'); } - await shipmentReadyEvent.emitWith( - app, - const _ShipmentReadyEvent(trackingId: 'ZX-42'), - ); + await app + .emitEventBuilder( + event: shipmentReadyEvent, + value: const _ShipmentReadyEvent(trackingId: 'ZX-42'), + ) + .emit(); await app.executeRun(runId); diff --git a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart index 80265d60..a8ac9cc0 100644 --- a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart @@ -70,3 +70,37 @@ extension WorkflowEventCallExtension on WorkflowEventCall { return emitter.emitEvent(event, value); } } + +/// Caller-bound typed workflow event emission call. +class BoundWorkflowEventCall { + /// Creates a caller-bound typed workflow event emission call. + const BoundWorkflowEventCall._({ + required WorkflowEventEmitter emitter, + required WorkflowEventCall call, + }) : _emitter = emitter, + _call = call; + + final WorkflowEventEmitter _emitter; + final WorkflowEventCall _call; + + /// Returns the prebuilt typed workflow event call. + WorkflowEventCall build() => _call; + + /// Emits the bound typed workflow event call. + Future emit() => _call.emitWith(_emitter); +} + +/// Convenience helpers for building typed workflow event calls directly from a +/// workflow event emitter. +extension WorkflowEventEmitterBuilderExtension on WorkflowEventEmitter { + /// Creates a caller-bound typed workflow event call for [event] and [value]. + BoundWorkflowEventCall emitEventBuilder({ + required WorkflowEventRef event, + required T value, + }) { + return BoundWorkflowEventCall._( + emitter: this, + call: event.call(value), + ); + } +} diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index f111e45b..c7fb7698 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -446,5 +446,46 @@ void main() { } }, ); + + test('workflow event emitters expose bound event calls', () async { + final flow = Flow( + name: 'runtime.ref.event.bound.flow', + build: (builder) { + builder.step('wait', (ctx) async { + final payload = ctx.waitForEventRef(_userUpdatedEvent); + if (payload == null) { + return null; + } + return 'hello ${payload.name}'; + }); + }, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + await workflowApp.start(); + + final runId = await flow.ref0().startWith(workflowApp); + await workflowApp.runtime.executeRun(runId); + + final call = workflowApp.emitEventBuilder( + event: _userUpdatedEvent, + value: const _GreetingParams(name: 'bound'), + ); + expect(call.build().topic, 'runtime.ref.event'); + + await call.emit(); + await workflowApp.runtime.executeRun(runId); + + final result = await workflowApp.waitForCompletion( + runId, + timeout: const Duration(seconds: 2), + ); + + expect(result?.value, 'hello bound'); + } finally { + await workflowApp.shutdown(); + } + }); }); } From c84be25a382349994f6ac3f1a087544d44eeae6c Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 23:32:55 -0500 Subject: [PATCH 118/302] Add caller-bound task enqueue builders --- .site/docs/core-concepts/producer.md | 4 +- .site/docs/core-concepts/tasks.md | 6 +- packages/stem/CHANGELOG.md | 4 + packages/stem/README.md | 19 +-- .../stem/example/docs_snippets/lib/tasks.dart | 6 +- .../task_context_mixed/lib/shared.dart | 6 +- .../stem/example/task_usage_patterns.dart | 6 +- packages/stem/lib/src/core/contracts.dart | 135 ++++++++++++++++++ packages/stem/lib/src/core/stem.dart | 21 +++ .../stem/lib/src/core/task_invocation.dart | 11 +- .../unit/core/task_context_enqueue_test.dart | 2 +- .../unit/core/task_enqueue_builder_test.dart | 62 ++++++++ ...task_context_enqueue_integration_test.dart | 2 +- 13 files changed, 252 insertions(+), 32 deletions(-) diff --git a/.site/docs/core-concepts/producer.md b/.site/docs/core-concepts/producer.md index 1df0d33b..bbb63682 100644 --- a/.site/docs/core-concepts/producer.md +++ b/.site/docs/core-concepts/producer.md @@ -52,8 +52,8 @@ metadata, while exposing direct helpers and a fluent builder for overrides Typed helpers are also available on `Canvas` (`definition.toSignature`) so group/chain/chord APIs produce strongly typed `TaskResult` streams. Need to tweak headers/meta/queue at call sites? Wrap the definition in a -`TaskEnqueueBuilder` and invoke `await builder.enqueue(enqueuer);` or -`await builder.enqueueAndWait(caller);`. +caller-bound `enqueueBuilder(...)` and invoke `await builder.enqueue();` or +`await builder.enqueueAndWait();`. Raw task-name strings still work, but they are the lower-level interop path. Reach for them when the task name is truly dynamic or you are crossing a diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index cee79ab5..20606d37 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -57,8 +57,10 @@ If your manual task args are DTOs, prefer codec still needs to encode to `Map` because task args are published as a map. -`TaskEnqueueBuilder` also supports `enqueueAndWait(...)`, so fluent per-call -overrides no longer force a separate manual wait step. +`TaskEnqueueBuilder` also supports `enqueueAndWait(...)`, and any +`TaskEnqueuer` can now create a caller-bound builder through +`enqueueBuilder(...)`, so fluent per-call overrides no longer force a separate +manual wait step or an extra `builder.enqueue(enqueuer)` hop. For tasks with no producer inputs, use `TaskDefinition.noArgs(...)` instead. That gives you direct `enqueue(...)` / diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 11915444..b4f69e5d 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -9,6 +9,10 @@ - Added `WorkflowEventEmitter.emitEventBuilder(...)` plus a caller-bound typed event call so apps, runtimes, and task/workflow contexts can emit typed workflow events without bouncing between emitter-first and ref-first styles. +- Added `TaskEnqueuer.enqueueBuilder(...)` / `enqueueNoArgsBuilder(...)` plus a + caller-bound fluent task builder so producers and contexts can enqueue typed + task calls directly from the enqueuer surface, with `enqueueAndWait()` + available whenever that enqueuer also supports typed result waits. - Made `TaskContext` and `TaskInvocationContext` implement `WorkflowEventEmitter` when a workflow runtime is attached, so inline handlers and isolate entrypoints can resume waiting workflows with diff --git a/packages/stem/README.md b/packages/stem/README.md index 246a2985..8421fdc8 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -252,17 +252,18 @@ await healthcheckDefinition.enqueue(stem); If a no-arg task returns a DTO, pass `resultCodec:` so waiting helpers decode the result and the task metadata advertises the right result encoder. -You can also build requests fluently with the `TaskEnqueueBuilder`: +You can also build requests fluently from the enqueuer itself: ```dart -final result = await TaskEnqueueBuilder( - definition: HelloTask.definition, - args: const HelloArgs(name: 'Tenant A'), -) - ..header('x-tenant', 'tenant-a') - ..priority(5) - ..delay(const Duration(seconds: 30)) - .enqueueAndWait(stem); +final result = await stem + .enqueueBuilder( + definition: HelloTask.definition, + args: const HelloArgs(name: 'Tenant A'), + ) + .header('x-tenant', 'tenant-a') + .priority(5) + .delay(const Duration(seconds: 30)) + .enqueueAndWait(); print(result?.value); ``` diff --git a/packages/stem/example/docs_snippets/lib/tasks.dart b/packages/stem/example/docs_snippets/lib/tasks.dart index d97f5940..bab5f813 100644 --- a/packages/stem/example/docs_snippets/lib/tasks.dart +++ b/packages/stem/example/docs_snippets/lib/tasks.dart @@ -143,7 +143,7 @@ final childDefinition = TaskDefinition( // #region tasks-invocation-builder Future enqueueWithBuilder(TaskInvocationContext invocation) async { - final call = invocation + await invocation .enqueueBuilder( definition: childDefinition, args: const ChildArgs('value'), @@ -160,9 +160,7 @@ Future enqueueWithBuilder(TaskInvocationContext invocation) async { ), ), ) - .build(); - - await invocation.enqueueCall(call); + .enqueue(); } // #endregion tasks-invocation-builder diff --git a/packages/stem/example/task_context_mixed/lib/shared.dart b/packages/stem/example/task_context_mixed/lib/shared.dart index 961c436a..b2ad7e97 100644 --- a/packages/stem/example/task_context_mixed/lib/shared.dart +++ b/packages/stem/example/task_context_mixed/lib/shared.dart @@ -306,7 +306,7 @@ FutureOr isolateChildEntrypoint( '[isolate_child] id=${context.id} attempt=${context.attempt} runId=$runId', ); - final call = context + await context .enqueueBuilder( definition: auditDefinition, args: AuditArgs( @@ -318,9 +318,7 @@ FutureOr isolateChildEntrypoint( .meta('origin', 'isolate-child') .delay(const Duration(milliseconds: 200)) .enqueueOptions(const TaskEnqueueOptions(shadow: 'audit-shadow')) - .build(); - - await context.enqueueCall(call); + .enqueue(); return 'isolate-ok'; } diff --git a/packages/stem/example/task_usage_patterns.dart b/packages/stem/example/task_usage_patterns.dart index 3c8fe12a..87e4dc67 100644 --- a/packages/stem/example/task_usage_patterns.dart +++ b/packages/stem/example/task_usage_patterns.dart @@ -56,16 +56,14 @@ FutureOr invocationParentEntrypoint( TaskInvocationContext context, Map args, ) async { - final call = context + await context .enqueueBuilder( definition: childDefinition, args: const ChildArgs('from-invocation-builder'), ) .priority(5) .delay(const Duration(milliseconds: 100)) - .build(); - - await context.enqueueCall(call); + .enqueue(); return null; } diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 7341cb0d..226a0fc4 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2463,6 +2463,141 @@ class TaskEnqueueBuilder { } } +/// Caller-bound fluent task enqueue builder. +/// +/// This keeps the enqueue target attached to the builder so producers and +/// contexts can stay on the enqueuer surface instead of bouncing back through +/// `builder.enqueue(enqueuer)`. +class BoundTaskEnqueueBuilder { + /// Creates a caller-bound task enqueue builder. + BoundTaskEnqueueBuilder({ + required this.enqueuer, + required TaskEnqueueBuilder builder, + }) : _builder = builder; + + /// Bound enqueuer used for dispatch. + final TaskEnqueuer enqueuer; + final TaskEnqueueBuilder _builder; + + /// Replaces headers entirely. + BoundTaskEnqueueBuilder headers(Map headers) { + _builder.headers(headers); + return this; + } + + /// Adds or overrides a single header entry. + BoundTaskEnqueueBuilder header(String key, String value) { + _builder.header(key, value); + return this; + } + + /// Replaces metadata entirely. + BoundTaskEnqueueBuilder metadata(Map meta) { + _builder.metadata(meta); + return this; + } + + /// Adds or overrides a metadata entry. + BoundTaskEnqueueBuilder meta(String key, Object? value) { + _builder.meta(key, value); + return this; + } + + /// Replaces the options for this call. + BoundTaskEnqueueBuilder options(TaskOptions options) { + _builder.options(options); + return this; + } + + /// Sets the queue for this enqueue. + BoundTaskEnqueueBuilder queue(String queue) { + _builder.queue(queue); + return this; + } + + /// Sets the priority for this enqueue. + BoundTaskEnqueueBuilder priority(int priority) { + _builder.priority(priority); + return this; + } + + /// Sets the earliest execution time. + BoundTaskEnqueueBuilder notBefore(DateTime instant) { + _builder.notBefore(instant); + return this; + } + + /// Sets a relative delay before execution. + BoundTaskEnqueueBuilder delay(Duration duration) { + _builder.delay(duration); + return this; + } + + /// Replaces the enqueue options for this call. + BoundTaskEnqueueBuilder enqueueOptions( + TaskEnqueueOptions options, + ) { + _builder.enqueueOptions(options); + return this; + } + + /// Builds the [TaskCall] with accumulated overrides. + TaskCall build() => _builder.build(); + + Future _enqueueBuiltCall( + TaskCall call, { + TaskEnqueueOptions? enqueueOptions, + }) { + final resolvedEnqueueOptions = enqueueOptions ?? call.enqueueOptions; + final scopeMeta = TaskEnqueueScope.currentMeta(); + if (scopeMeta == null || scopeMeta.isEmpty) { + return enqueuer.enqueueCall( + call, + enqueueOptions: resolvedEnqueueOptions, + ); + } + final mergedMeta = Map.from(scopeMeta)..addAll(call.meta); + return enqueuer.enqueueCall( + call.copyWith(meta: Map.unmodifiable(mergedMeta)), + enqueueOptions: resolvedEnqueueOptions, + ); + } + + /// Builds the call and enqueues it with the bound enqueuer. + Future enqueue({TaskEnqueueOptions? enqueueOptions}) { + final call = build(); + return _enqueueBuiltCall(call, enqueueOptions: enqueueOptions); + } +} + +/// Convenience helpers for building typed enqueue requests directly from a task +/// enqueuer. +extension TaskEnqueuerBuilderExtension on TaskEnqueuer { + /// Creates a caller-bound fluent builder for a typed task definition. + BoundTaskEnqueueBuilder enqueueBuilder({ + required TaskDefinition definition, + required TArgs args, + }) { + return BoundTaskEnqueueBuilder( + enqueuer: this, + builder: TaskEnqueueBuilder(definition: definition, args: args), + ); + } + + /// Creates a caller-bound fluent builder for a no-args task definition. + BoundTaskEnqueueBuilder<(), TResult> enqueueNoArgsBuilder({ + required NoArgsTaskDefinition definition, + }) { + return BoundTaskEnqueueBuilder( + enqueuer: this, + builder: TaskEnqueueBuilder( + definition: definition.asDefinition, + args: (), + ), + ); + } +} + /// Retry strategy used to compute the next backoff delay. /// Since: 0.1.0 // Intentionally an interface for DI and test doubles. diff --git a/packages/stem/lib/src/core/stem.dart b/packages/stem/lib/src/core/stem.dart index 4f2334b0..2626abfa 100644 --- a/packages/stem/lib/src/core/stem.dart +++ b/packages/stem/lib/src/core/stem.dart @@ -1120,6 +1120,27 @@ extension TaskEnqueueBuilderExtension } } +/// Convenience helpers for waiting on caller-bound task enqueue builders. +extension BoundTaskEnqueueBuilderExtension + on BoundTaskEnqueueBuilder { + /// Enqueues this bound request and waits for the typed task result. + Future?> enqueueAndWait({ + Duration? timeout, + TaskEnqueueOptions? enqueueOptions, + }) async { + final boundEnqueuer = enqueuer; + if (boundEnqueuer is! TaskResultCaller) { + throw StateError( + 'BoundTaskEnqueueBuilder requires a TaskResultCaller to wait for ' + 'results', + ); + } + final call = build(); + final taskId = await enqueue(enqueueOptions: enqueueOptions); + return call.definition.waitFor(boundEnqueuer, taskId, timeout: timeout); + } +} + /// Convenience helpers for dispatching prebuilt [TaskCall] instances. extension TaskCallExtension on TaskCall { diff --git a/packages/stem/lib/src/core/task_invocation.dart b/packages/stem/lib/src/core/task_invocation.dart index bbfe7fa3..e7c51319 100644 --- a/packages/stem/lib/src/core/task_invocation.dart +++ b/packages/stem/lib/src/core/task_invocation.dart @@ -559,14 +559,15 @@ class TaskInvocationContext return delegate.emitEvent(event, value); } - /// Build a fluent enqueue request for this invocation. - /// - /// Use [TaskEnqueueBuilder.build] + [enqueueCall] to dispatch. - TaskEnqueueBuilder enqueueBuilder({ + /// Build a caller-bound fluent enqueue request for this invocation. + BoundTaskEnqueueBuilder enqueueBuilder({ required TaskDefinition definition, required TArgs args, }) { - return TaskEnqueueBuilder(definition: definition, args: args); + return BoundTaskEnqueueBuilder( + enqueuer: this, + builder: TaskEnqueueBuilder(definition: definition, args: args), + ); } /// Alias for enqueue. diff --git a/packages/stem/test/unit/core/task_context_enqueue_test.dart b/packages/stem/test/unit/core/task_context_enqueue_test.dart index 818a6cda..f8bc837a 100644 --- a/packages/stem/test/unit/core/task_context_enqueue_test.dart +++ b/packages/stem/test/unit/core/task_context_enqueue_test.dart @@ -202,7 +202,7 @@ void main() { args: const _ExampleArgs('hello'), ); - await builder.queue('priority').priority(7).enqueue(context); + await builder.queue('priority').priority(7).enqueue(); final record = enqueuer.last!; expect(record.name, equals('tasks.typed')); diff --git a/packages/stem/test/unit/core/task_enqueue_builder_test.dart b/packages/stem/test/unit/core/task_enqueue_builder_test.dart index 8b3bbd56..ff245883 100644 --- a/packages/stem/test/unit/core/task_enqueue_builder_test.dart +++ b/packages/stem/test/unit/core/task_enqueue_builder_test.dart @@ -86,6 +86,68 @@ void main() { }, ); + test('TaskEnqueuer.enqueueBuilder binds enqueue to the enqueuer', () async { + final enqueuer = _RecordingTaskEnqueuer(); + final definition = TaskDefinition, String>( + name: 'demo.task', + encodeArgs: (args) => args, + decodeResult: (payload) => 'decoded:$payload', + ); + + final taskId = await enqueuer + .enqueueBuilder(definition: definition, args: const {'a': 1}) + .header('h1', 'v1') + .queue('critical') + .enqueue(); + + expect(taskId, 'task-1'); + expect(enqueuer.lastCall, isNotNull); + expect(enqueuer.lastCall!.name, 'demo.task'); + expect(enqueuer.lastCall!.headers, containsPair('h1', 'v1')); + expect(enqueuer.lastCall!.resolveOptions().queue, 'critical'); + }); + + test( + 'BoundTaskEnqueueBuilder.enqueueAndWait reuses typed result decoding', + () async { + final caller = _RecordingTaskResultCaller(); + final definition = TaskDefinition, String>( + name: 'demo.task', + encodeArgs: (args) => args, + decodeResult: (payload) => 'decoded:$payload', + ); + + final result = await caller + .enqueueBuilder(definition: definition, args: const {'a': 1}) + .header('h1', 'v1') + .enqueueAndWait(); + + expect(caller.lastCall, isNotNull); + expect(caller.lastCall!.name, 'demo.task'); + expect(caller.lastCall!.headers, containsPair('h1', 'v1')); + expect(caller.waitedTaskId, 'task-1'); + expect(result?.value, 'decoded:stored'); + }, + ); + + test( + 'BoundTaskEnqueueBuilder.enqueueAndWait throws without a result caller', + () async { + final enqueuer = _RecordingTaskEnqueuer(); + final definition = TaskDefinition, String>( + name: 'demo.task', + encodeArgs: (args) => args, + ); + + expect( + () => enqueuer + .enqueueBuilder(definition: definition, args: const {'a': 1}) + .enqueueAndWait(), + throwsStateError, + ); + }, + ); + test('TaskCall.copyWith updates headers and meta', () { final definition = TaskDefinition, Object?>( name: 'demo.task', diff --git a/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart b/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart index 2a790b97..9784864f 100644 --- a/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart +++ b/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart @@ -539,7 +539,7 @@ FutureOr _isolateEnqueueEntrypoint( definition: _childDefinition, args: const _ChildArgs('from-isolate'), ); - await builder.enqueue(context); + await builder.enqueue(); return null; } From a3c62f9250ac227085effae7df68fae56da961f4 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 23:35:33 -0500 Subject: [PATCH 119/302] Prefer bound workflow event builder docs --- .site/docs/workflows/suspensions-and-events.md | 5 +++-- packages/stem/CHANGELOG.md | 4 ++++ packages/stem/README.md | 8 +++++--- packages/stem/example/workflows/sleep_and_event.dart | 7 ++++++- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index a2a696db..4a905e3d 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -67,8 +67,9 @@ transport shape. When the topic and codec travel together in your codebase, prefer a typed `WorkflowEventRef` and `emitter.emitEventBuilder(event: ref, value: dto) -.emit()`, `event.emitWith(...)`, or `event.call(value).emitWith(...)` together -with `waitForEventRef(...)` or `awaitEventRef(...)`. +.emit()` as the happy path. `event.emitWith(...)` and +`event.call(value).emitWith(...)` remain available as lower-level variants. +Pair that with `waitForEventRef(...)` or `awaitEventRef(...)`. ## Inspect waiting runs diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index b4f69e5d..54dc8bc5 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -13,6 +13,10 @@ caller-bound fluent task builder so producers and contexts can enqueue typed task calls directly from the enqueuer surface, with `enqueueAndWait()` available whenever that enqueuer also supports typed result waits. +- Updated the public workflow event examples and docs to prefer + `emitEventBuilder(...).emit()` as the primary typed event emission path, + while keeping the older `emitWith(...)` variants documented as lower-level + alternatives. - Made `TaskContext` and `TaskInvocationContext` implement `WorkflowEventEmitter` when a workflow runtime is attached, so inline handlers and isolate entrypoints can resume waiting workflows with diff --git a/packages/stem/README.md b/packages/stem/README.md index 8421fdc8..ac6d4d3c 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -1068,9 +1068,11 @@ backend metadata under `stem.unique.duplicates`. `runtime.emitValue(...)` when you are intentionally using the low-level runtime) with a `PayloadCodec`, or bundle the topic and codec once in a `WorkflowEventRef` and use `emitter.emitEventBuilder(event: ref, value: - dto).emit()`, `event.emitWith(...)`, or `event.call(value).emitWith(...)` - together with `waitForEventRef(...)` or `awaitEventRef(...)`. Event payloads - still serialize onto the existing `Map` wire format. + dto).emit()` as the happy path, with `event.emitWith(...)` and + `event.call(value).emitWith(...)` still available as lower-level variants. + Pair that with `waitForEventRef(...)` or `awaitEventRef(...)`. Event + payloads still serialize onto the existing `Map` wire + format. - Only return values you want persisted. If a handler returns `null`, the runtime treats it as "no result yet" and will run the step again on resume. - Derive outbound idempotency tokens with `ctx.idempotencyKey('charge')` so diff --git a/packages/stem/example/workflows/sleep_and_event.dart b/packages/stem/example/workflows/sleep_and_event.dart index 0ed1a7fb..1827f937 100644 --- a/packages/stem/example/workflows/sleep_and_event.dart +++ b/packages/stem/example/workflows/sleep_and_event.dart @@ -48,7 +48,12 @@ Future main() async { await Future.delayed(const Duration(milliseconds: 50)); } - await demoEvent.call({'message': 'event received'}).emitWith(app); + await app + .emitEventBuilder( + event: demoEvent, + value: const {'message': 'event received'}, + ) + .emit(); final result = await sleepAndEventRef.waitFor(app, runId); print('Workflow $runId resumed and completed with: ${result?.value}'); From 2c7870a9444970144a48339e262ec7ea1a5dc226 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 23:38:46 -0500 Subject: [PATCH 120/302] Prefer no-arg task definitions in examples --- packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 10 +++++-- packages/stem/example/stack_autowire.dart | 12 +++++--- .../stem/example/task_usage_patterns.dart | 30 ++++++++++++------- 4 files changed, 38 insertions(+), 17 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 54dc8bc5..44498b42 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -17,6 +17,9 @@ `emitEventBuilder(...).emit()` as the primary typed event emission path, while keeping the older `emitWith(...)` variants documented as lower-level alternatives. +- Updated the public no-input task examples to prefer + `TaskDefinition.noArgs(...)` plus typed `enqueue()` / `enqueueAndWait()` + helpers instead of reintroducing raw task-name strings in the happy path. - Made `TaskContext` and `TaskInvocationContext` implement `WorkflowEventEmitter` when a workflow runtime is attached, so inline handlers and isolate entrypoints can resume waiting workflows with diff --git a/packages/stem/README.md b/packages/stem/README.md index ac6d4d3c..b5cbcb3f 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -46,8 +46,10 @@ import 'dart:async'; import 'package:stem/stem.dart'; class HelloTask implements TaskHandler { + static final definition = TaskDefinition.noArgs(name: 'demo.hello'); + @override - String get name => 'demo.hello'; + String get name => definition.name; @override TaskOptions get options => const TaskOptions(queue: 'default'); @@ -64,8 +66,10 @@ Future main() async { final worker = await client.createWorker(); unawaited(worker.start()); - await client.enqueue('demo.hello'); - await Future.delayed(const Duration(seconds: 1)); + await HelloTask.definition.enqueueAndWait( + client, + timeout: const Duration(seconds: 1), + ); await worker.shutdown(); await client.close(); diff --git a/packages/stem/example/stack_autowire.dart b/packages/stem/example/stack_autowire.dart index cf8d3dda..489de415 100644 --- a/packages/stem/example/stack_autowire.dart +++ b/packages/stem/example/stack_autowire.dart @@ -4,11 +4,13 @@ import 'package:stem/stem.dart'; import 'package:stem_redis/stem_redis.dart'; class PingTask implements TaskHandler { + static final definition = TaskDefinition.noArgs(name: 'demo.ping'); + @override - String get name => 'demo.ping'; + String get name => definition.name; @override - TaskMetadata get metadata => const TaskMetadata(); + TaskMetadata get metadata => definition.metadata; @override TaskOptions get options => const TaskOptions(maxRetries: 0); @@ -64,8 +66,10 @@ Future main() async { await workflowApp.start(); await beat.start(); - final taskId = await app.enqueue('demo.ping'); - await app.waitForTask(taskId, timeout: const Duration(seconds: 1)); + await PingTask.definition.enqueueAndWait( + app, + timeout: const Duration(seconds: 1), + ); } finally { await beat.stop(); await workflowApp.shutdown(); diff --git a/packages/stem/example/task_usage_patterns.dart b/packages/stem/example/task_usage_patterns.dart index 87e4dc67..ca8dbcf5 100644 --- a/packages/stem/example/task_usage_patterns.dart +++ b/packages/stem/example/task_usage_patterns.dart @@ -14,23 +14,32 @@ final childDefinition = TaskDefinition( metadata: const TaskMetadata(description: 'Typed child task example'), ); +final invocationParentDefinition = TaskDefinition.noArgs( + name: 'tasks.invocation_parent', +); + class ParentTask extends TaskHandler { + static final definition = TaskDefinition.noArgs( + name: 'tasks.parent', + metadata: TaskMetadata( + description: 'Parent task that enqueues follow-up work.', + ), + ); + @override - String get name => 'tasks.parent'; + String get name => definition.name; @override TaskOptions get options => const TaskOptions(queue: 'default'); @override - TaskMetadata get metadata => const TaskMetadata( - description: 'Parent task that enqueues follow-up work.', - ); + TaskMetadata get metadata => definition.metadata; @override Future call(TaskContext context, Map args) async { - await context.enqueue( - 'tasks.child', - args: {'value': 'from-parent'}, + await childDefinition.enqueue( + context, + const ChildArgs('from-parent'), enqueueOptions: TaskEnqueueOptions( countdown: const Duration(milliseconds: 200), queue: 'default', @@ -77,9 +86,10 @@ Future main() async { metadata: childDefinition.metadata, ), FunctionTaskHandler.inline( - name: 'tasks.invocation_parent', + name: invocationParentDefinition.name, entrypoint: invocationParentEntrypoint, options: const TaskOptions(queue: 'default'), + metadata: invocationParentDefinition.metadata, ), ]; @@ -88,8 +98,8 @@ Future main() async { workerConfig: const StemWorkerConfig(consumerName: 'example-worker'), ); - await app.enqueue('tasks.parent', args: const {}); - await app.enqueue('tasks.invocation_parent', args: const {}); + await ParentTask.definition.enqueue(app); + await invocationParentDefinition.enqueue(app); final directTaskId = await childDefinition.enqueue( app, const ChildArgs('direct-call'), From 283ed39b02c9dc7b207ea88556b2324dba77dfd0 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 23:42:49 -0500 Subject: [PATCH 121/302] Add definition-first task enqueue builders --- .site/docs/core-concepts/producer.md | 6 ++-- .site/docs/core-concepts/tasks.md | 9 +++--- packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 11 +++----- .../stem/example/docs_snippets/lib/tasks.dart | 9 ++---- .../stem/example/task_usage_patterns.dart | 9 ++---- packages/stem/lib/src/core/contracts.dart | 10 +++++++ .../unit/core/task_enqueue_builder_test.dart | 28 +++++++++++++++++++ 8 files changed, 59 insertions(+), 26 deletions(-) diff --git a/.site/docs/core-concepts/producer.md b/.site/docs/core-concepts/producer.md index bbb63682..27c1aa6f 100644 --- a/.site/docs/core-concepts/producer.md +++ b/.site/docs/core-concepts/producer.md @@ -51,9 +51,9 @@ metadata, while exposing direct helpers and a fluent builder for overrides Typed helpers are also available on `Canvas` (`definition.toSignature`) so group/chain/chord APIs produce strongly typed `TaskResult` streams. Need to -tweak headers/meta/queue at call sites? Wrap the definition in a -caller-bound `enqueueBuilder(...)` and invoke `await builder.enqueue();` or -`await builder.enqueueAndWait();`. +tweak headers/meta/queue at call sites? Start from +`definition.enqueueBuilder(args)` for the neutral builder, or use the +caller-bound `enqueueBuilder(...)` when you want the enqueue target baked in. Raw task-name strings still work, but they are the lower-level interop path. Reach for them when the task name is truly dynamic or you are crossing a diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 20606d37..74e3a623 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -57,10 +57,11 @@ If your manual task args are DTOs, prefer codec still needs to encode to `Map` because task args are published as a map. -`TaskEnqueueBuilder` also supports `enqueueAndWait(...)`, and any -`TaskEnqueuer` can now create a caller-bound builder through -`enqueueBuilder(...)`, so fluent per-call overrides no longer force a separate -manual wait step or an extra `builder.enqueue(enqueuer)` hop. +`TaskEnqueueBuilder` also supports `enqueueAndWait(...)`, and typed task +definitions can now create a fluent builder directly through +`definition.enqueueBuilder(...)`. `TaskEnqueuer.enqueueBuilder(...)` remains +available when you want the caller-bound variant that keeps the enqueue target +attached to the builder. For tasks with no producer inputs, use `TaskDefinition.noArgs(...)` instead. That gives you direct `enqueue(...)` / diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 44498b42..782d8cc5 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -20,6 +20,9 @@ - Updated the public no-input task examples to prefer `TaskDefinition.noArgs(...)` plus typed `enqueue()` / `enqueueAndWait()` helpers instead of reintroducing raw task-name strings in the happy path. +- Added `TaskDefinition.enqueueBuilder(...)` / + `NoArgsTaskDefinition.enqueueBuilder()` so typed tasks now expose the same + definition-first fluent builder pattern as typed workflow refs. - Made `TaskContext` and `TaskInvocationContext` implement `WorkflowEventEmitter` when a workflow runtime is attached, so inline handlers and isolate entrypoints can resume waiting workflows with diff --git a/packages/stem/README.md b/packages/stem/README.md index b5cbcb3f..baae2e6b 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -256,18 +256,15 @@ await healthcheckDefinition.enqueue(stem); If a no-arg task returns a DTO, pass `resultCodec:` so waiting helpers decode the result and the task metadata advertises the right result encoder. -You can also build requests fluently from the enqueuer itself: +You can also build requests fluently from the task definition itself: ```dart -final result = await stem - .enqueueBuilder( - definition: HelloTask.definition, - args: const HelloArgs(name: 'Tenant A'), - ) +final result = await HelloTask.definition + .enqueueBuilder(const HelloArgs(name: 'Tenant A')) .header('x-tenant', 'tenant-a') .priority(5) .delay(const Duration(seconds: 30)) - .enqueueAndWait(); + .enqueueAndWait(stem); print(result?.value); ``` diff --git a/packages/stem/example/docs_snippets/lib/tasks.dart b/packages/stem/example/docs_snippets/lib/tasks.dart index bab5f813..5e502c49 100644 --- a/packages/stem/example/docs_snippets/lib/tasks.dart +++ b/packages/stem/example/docs_snippets/lib/tasks.dart @@ -143,11 +143,8 @@ final childDefinition = TaskDefinition( // #region tasks-invocation-builder Future enqueueWithBuilder(TaskInvocationContext invocation) async { - await invocation - .enqueueBuilder( - definition: childDefinition, - args: const ChildArgs('value'), - ) + await childDefinition + .enqueueBuilder(const ChildArgs('value')) .queue('critical') .priority(9) .delay(const Duration(seconds: 5)) @@ -160,7 +157,7 @@ Future enqueueWithBuilder(TaskInvocationContext invocation) async { ), ), ) - .enqueue(); + .enqueue(invocation); } // #endregion tasks-invocation-builder diff --git a/packages/stem/example/task_usage_patterns.dart b/packages/stem/example/task_usage_patterns.dart index ca8dbcf5..425108ec 100644 --- a/packages/stem/example/task_usage_patterns.dart +++ b/packages/stem/example/task_usage_patterns.dart @@ -65,14 +65,11 @@ FutureOr invocationParentEntrypoint( TaskInvocationContext context, Map args, ) async { - await context - .enqueueBuilder( - definition: childDefinition, - args: const ChildArgs('from-invocation-builder'), - ) + await childDefinition + .enqueueBuilder(const ChildArgs('from-invocation-builder')) .priority(5) .delay(const Duration(milliseconds: 100)) - .enqueue(); + .enqueue(context); return null; } diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 226a0fc4..3a475eba 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2220,6 +2220,11 @@ class TaskDefinition { ); } + /// Creates a fluent enqueue builder from this definition and [args]. + TaskEnqueueBuilder enqueueBuilder(TArgs args) { + return TaskEnqueueBuilder(definition: this, args: args); + } + /// Encodes arguments into a JSON-ready map. Map encodeArgs(TArgs args) => _encodeArgs(args); @@ -2289,6 +2294,11 @@ class NoArgsTaskDefinition { ); } + /// Creates a fluent enqueue builder for this no-args task definition. + TaskEnqueueBuilder<(), TResult> enqueueBuilder() { + return asDefinition.enqueueBuilder(()); + } + /// Decodes a persisted payload into a typed result. TResult? decode(Object? payload) => asDefinition.decode(payload); } diff --git a/packages/stem/test/unit/core/task_enqueue_builder_test.dart b/packages/stem/test/unit/core/task_enqueue_builder_test.dart index ff245883..4daf8db4 100644 --- a/packages/stem/test/unit/core/task_enqueue_builder_test.dart +++ b/packages/stem/test/unit/core/task_enqueue_builder_test.dart @@ -63,6 +63,24 @@ void main() { expect(call.options?.priority, 9); }); + test('TaskDefinition.enqueueBuilder creates a fluent builder', () { + final definition = TaskDefinition, Object?>( + name: 'demo.task', + encodeArgs: (args) => args, + ); + + final call = definition + .enqueueBuilder(const {'a': 1}) + .priority(7) + .header('h1', 'v1') + .build(); + + expect(call.name, 'demo.task'); + expect(call.resolveOptions().priority, 7); + expect(call.headers, containsPair('h1', 'v1')); + expect(call.encodeArgs(), containsPair('a', 1)); + }); + test( 'TaskEnqueueBuilder.enqueueAndWait reuses typed result decoding', () async { @@ -182,6 +200,16 @@ void main() { expect(call.meta, containsPair('m', 1)); }); + test('NoArgsTaskDefinition.enqueueBuilder creates a fluent builder', () { + final definition = TaskDefinition.noArgs(name: 'demo.no_args'); + + final call = definition.enqueueBuilder().priority(4).build(); + + expect(call.name, 'demo.no_args'); + expect(call.resolveOptions().priority, 4); + expect(call.encodeArgs(), isEmpty); + }); + test( 'NoArgsTaskDefinition.enqueue uses the TaskEnqueuer surface', () async { From da57ebded8823c90ccb1afc5f1590b38e8e79077 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 23:48:29 -0500 Subject: [PATCH 122/302] Add direct no-arg workflow helpers --- .site/docs/workflows/flows-and-scripts.md | 11 ++-- .site/docs/workflows/starting-and-waiting.md | 5 +- packages/stem/CHANGELOG.md | 4 ++ packages/stem/README.md | 16 +++--- .../example/workflows/basic_in_memory.dart | 5 +- .../workflows/cancellation_policy.dart | 8 +-- .../example/workflows/custom_factories.dart | 5 +- .../example/workflows/sleep_and_event.dart | 5 +- .../stem/example/workflows/sqlite_store.dart | 5 +- .../example/workflows/versioned_rewind.dart | 5 +- packages/stem/lib/src/workflow/core/flow.dart | 56 +++++++++++++++++++ .../src/workflow/core/workflow_script.dart | 56 +++++++++++++++++++ .../workflow/workflow_runtime_ref_test.dart | 15 ++--- 13 files changed, 154 insertions(+), 42 deletions(-) diff --git a/.site/docs/workflows/flows-and-scripts.md b/.site/docs/workflows/flows-and-scripts.md index cbaedbf1..becc30b7 100644 --- a/.site/docs/workflows/flows-and-scripts.md +++ b/.site/docs/workflows/flows-and-scripts.md @@ -35,8 +35,9 @@ final approvalsRef = approvalsFlow.ref>( ); ``` -When a flow has no start params, prefer `flow.ref0()` and start directly from -that no-args ref. +When a flow has no start params, start directly from the flow itself with +`flow.startWith(...)`, `flow.startAndWaitWith(...)`, or `flow.startBuilder()`. +Use `ref0()` only when another API specifically needs a `NoArgsWorkflowRef`. Use `Flow` when: @@ -58,8 +59,10 @@ final retryRef = retryScript.ref>( ); ``` -When a script has no start params, prefer `retryScript.ref0()` and start -directly from that no-args ref. +When a script has no start params, start directly from the script itself with +`retryScript.startWith(...)`, `retryScript.startAndWaitWith(...)`, or +`retryScript.startBuilder()`. Use `ref0()` only when another API specifically +needs a `NoArgsWorkflowRef`. Use `WorkflowScript` when: diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 01b8177b..c4619058 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -63,8 +63,9 @@ final runId = await approvalsRef `refWithCodec(...)` is the manual DTO path. The codec still needs to encode to `Map` because workflow params are stored as a map. -For workflows without start params, derive `ref0()` instead and start them -directly from the no-args ref. +For workflows without start params, start directly from the flow or script +itself with `startWith(...)`, `startAndWaitWith(...)`, or `startBuilder()`. +Use `ref0()` when another API specifically needs a `NoArgsWorkflowRef`. ## Wait for completion diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 782d8cc5..ffd984d1 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -23,6 +23,10 @@ - Added `TaskDefinition.enqueueBuilder(...)` / `NoArgsTaskDefinition.enqueueBuilder()` so typed tasks now expose the same definition-first fluent builder pattern as typed workflow refs. +- Added direct no-args `startWith(...)`, `startAndWaitWith(...)`, + `startBuilder()`, and `waitFor(...)` helpers on manual `Flow` and + `WorkflowScript` definitions so simple workflows no longer need an extra + `ref0()` hop just to start or wait. - Made `TaskContext` and `TaskInvocationContext` implement `WorkflowEventEmitter` when a workflow runtime is attached, so inline handlers and isolate entrypoints can resume waiting workflows with diff --git a/packages/stem/README.md b/packages/stem/README.md index baae2e6b..52d945da 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -354,14 +354,12 @@ final demoWorkflow = Flow( }, ); -final demoWorkflowRef = demoWorkflow.ref0(); - final app = await StemWorkflowApp.inMemory( flows: [demoWorkflow], ); -final runId = await demoWorkflowRef.startWith(app); -final result = await demoWorkflowRef.waitFor(app, runId); +final runId = await demoWorkflow.startWith(app); +final result = await demoWorkflow.waitFor(app, runId); print(result?.value); // 'hello world' print(result?.state.status); // WorkflowStatus.completed @@ -531,14 +529,16 @@ Use `refWithCodec(...)` when your manual workflow start params are DTOs that already have a `PayloadCodec`. The codec still needs to encode to `Map` because workflow params are persisted as a map. -For workflows without start parameters, use `ref0()` and start directly from -the no-args ref: +For workflows without start parameters, start directly from the flow or script +itself: ```dart -final healthcheckRef = healthcheckFlow.ref0(); -final runId = await healthcheckRef.startWith(app); +final runId = await healthcheckFlow.startWith(app); ``` +If you need to pass a no-args workflow through another API, `ref0()` still +builds the explicit `NoArgsWorkflowRef`. + #### Manual `WorkflowScript` Use `WorkflowScript` when you want your workflow to read like a normal async diff --git a/packages/stem/example/workflows/basic_in_memory.dart b/packages/stem/example/workflows/basic_in_memory.dart index e47d0337..d197eb4c 100644 --- a/packages/stem/example/workflows/basic_in_memory.dart +++ b/packages/stem/example/workflows/basic_in_memory.dart @@ -10,14 +10,13 @@ Future main() async { flow.step('greet', (ctx) async => 'Hello Stem'); }, ); - final basicHelloRef = basicHello.ref0(); final app = await StemWorkflowApp.inMemory( flows: [basicHello], ); - final runId = await basicHelloRef.startWith(app); - final result = await basicHelloRef.waitFor(app, runId); + final runId = await basicHello.startWith(app); + final result = await basicHello.waitFor(app, runId); print('Workflow $runId finished with result: ${result?.value}'); await app.close(); diff --git a/packages/stem/example/workflows/cancellation_policy.dart b/packages/stem/example/workflows/cancellation_policy.dart index f8507ea5..8d5aa8cf 100644 --- a/packages/stem/example/workflows/cancellation_policy.dart +++ b/packages/stem/example/workflows/cancellation_policy.dart @@ -24,15 +24,15 @@ Future main() async { }); }, ); - final reportsGenerateRef = reportsGenerate.ref0(); final app = await StemWorkflowApp.inMemory( flows: [reportsGenerate], ); - final runId = await reportsGenerateRef - .call( - cancellationPolicy: const WorkflowCancellationPolicy( + final runId = await reportsGenerate + .startBuilder() + .cancellationPolicy( + const WorkflowCancellationPolicy( maxRunDuration: Duration(minutes: 10), maxSuspendDuration: Duration(seconds: 2), ), diff --git a/packages/stem/example/workflows/custom_factories.dart b/packages/stem/example/workflows/custom_factories.dart index 0602aba9..7938a249 100644 --- a/packages/stem/example/workflows/custom_factories.dart +++ b/packages/stem/example/workflows/custom_factories.dart @@ -11,7 +11,6 @@ Future main() async { flow.step('greet', (ctx) async => 'Redis-backed workflow'); }, ); - final redisWorkflowRef = redisWorkflow.ref0(); final app = await StemWorkflowApp.fromUrl( 'redis://localhost:6379', adapters: const [StemRedisAdapter()], @@ -23,8 +22,8 @@ Future main() async { ); try { - final runId = await redisWorkflowRef.startWith(app); - final result = await redisWorkflowRef.waitFor(app, runId); + final runId = await redisWorkflow.startWith(app); + final result = await redisWorkflow.waitFor(app, runId); print('Workflow $runId finished with result: ${result?.value}'); } finally { await app.close(); diff --git a/packages/stem/example/workflows/sleep_and_event.dart b/packages/stem/example/workflows/sleep_and_event.dart index 1827f937..c145627b 100644 --- a/packages/stem/example/workflows/sleep_and_event.dart +++ b/packages/stem/example/workflows/sleep_and_event.dart @@ -30,13 +30,12 @@ Future main() async { }); }, ); - final sleepAndEventRef = sleepAndEvent.ref0(); final app = await StemWorkflowApp.inMemory( flows: [sleepAndEvent], ); - final runId = await sleepAndEventRef.startWith(app); + final runId = await sleepAndEvent.startWith(app); // Wait until the workflow is suspended before emitting the event to avoid // losing the signal. @@ -55,7 +54,7 @@ Future main() async { ) .emit(); - final result = await sleepAndEventRef.waitFor(app, runId); + final result = await sleepAndEvent.waitFor(app, runId); print('Workflow $runId resumed and completed with: ${result?.value}'); await app.close(); diff --git a/packages/stem/example/workflows/sqlite_store.dart b/packages/stem/example/workflows/sqlite_store.dart index 9091ba9f..6120ac2e 100644 --- a/packages/stem/example/workflows/sqlite_store.dart +++ b/packages/stem/example/workflows/sqlite_store.dart @@ -14,7 +14,6 @@ Future main() async { flow.step('greet', (ctx) async => 'Persisted to SQLite'); }, ); - final sqliteExampleRef = sqliteExample.ref0(); final app = await StemWorkflowApp.fromUrl( 'sqlite://${databaseFile.path}', adapters: const [StemSqliteAdapter()], @@ -22,8 +21,8 @@ Future main() async { ); try { - final runId = await sqliteExampleRef.startWith(app); - final result = await sqliteExampleRef.waitFor(app, runId); + final runId = await sqliteExample.startWith(app); + final result = await sqliteExample.waitFor(app, runId); print('Workflow $runId finished with result: ${result?.value}'); } finally { await app.close(); diff --git a/packages/stem/example/workflows/versioned_rewind.dart b/packages/stem/example/workflows/versioned_rewind.dart index 5f5b3c03..bc1c0fdd 100644 --- a/packages/stem/example/workflows/versioned_rewind.dart +++ b/packages/stem/example/workflows/versioned_rewind.dart @@ -13,13 +13,12 @@ Future main() async { flow.step('tail', (ctx) async => ctx.previousResult); }, ); - final versionedWorkflowRef = versionedWorkflow.ref0(); final app = await StemWorkflowApp.inMemory( flows: [versionedWorkflow], ); - final runId = await versionedWorkflowRef.startWith(app); + final runId = await versionedWorkflow.startWith(app); await app.executeRun(runId); // Rewind and execute again to append a new iteration checkpoint. @@ -31,7 +30,7 @@ Future main() async { print('${checkpoint.checkpointName}: ${checkpoint.value}'); } print('Iterations executed: $iterations'); - final completed = await versionedWorkflowRef.waitFor(app, runId); + final completed = await versionedWorkflow.waitFor(app, runId); print('Final result: ${completed?.value}'); await app.close(); diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index 5a2043b6..db67b0d5 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -1,6 +1,8 @@ import 'package:stem/src/core/payload_codec.dart'; +import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; import 'package:stem/src/workflow/core/workflow_definition.dart'; import 'package:stem/src/workflow/core/workflow_ref.dart'; +import 'package:stem/src/workflow/core/workflow_result.dart'; /// Convenience wrapper that builds a [WorkflowDefinition] using the declarative /// [FlowBuilder] DSL. @@ -49,4 +51,58 @@ class Flow { NoArgsWorkflowRef ref0() { return definition.ref0(); } + + /// Creates a fluent start builder for flows without start params. + WorkflowStartBuilder<(), T> startBuilder() { + return ref0().startBuilder(); + } + + /// Starts this flow directly when it does not accept start params. + Future startWith( + WorkflowCaller caller, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return ref0().startWith( + caller, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + + /// Starts this flow directly and waits for completion. + Future?> startAndWaitWith( + WorkflowCaller caller, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return ref0().startAndWaitWith( + caller, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + pollInterval: pollInterval, + timeout: timeout, + ); + } + + /// Waits for [runId] using this flow's result decoding rules. + Future?> waitFor( + WorkflowCaller caller, + String runId, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return ref0().waitFor( + caller, + runId, + pollInterval: pollInterval, + timeout: timeout, + ); + } } diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index f408c1e8..33759365 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -1,7 +1,9 @@ import 'package:stem/src/core/payload_codec.dart'; +import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; import 'package:stem/src/workflow/core/workflow_checkpoint.dart'; import 'package:stem/src/workflow/core/workflow_definition.dart'; import 'package:stem/src/workflow/core/workflow_ref.dart'; +import 'package:stem/src/workflow/core/workflow_result.dart'; /// High-level workflow facade that allows scripts to be authored as a single /// async function using `step`, `sleep`, and `awaitEvent` helpers. @@ -51,4 +53,58 @@ class WorkflowScript { NoArgsWorkflowRef ref0() { return definition.ref0(); } + + /// Creates a fluent start builder for scripts without start params. + WorkflowStartBuilder<(), T> startBuilder() { + return ref0().startBuilder(); + } + + /// Starts this script directly when it does not accept start params. + Future startWith( + WorkflowCaller caller, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return ref0().startWith( + caller, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + + /// Starts this script directly and waits for completion. + Future?> startAndWaitWith( + WorkflowCaller caller, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return ref0().startAndWaitWith( + caller, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + pollInterval: pollInterval, + timeout: timeout, + ); + } + + /// Waits for [runId] using this script's result decoding rules. + Future?> waitFor( + WorkflowCaller caller, + String runId, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return ref0().waitFor( + caller, + runId, + pollInterval: pollInterval, + timeout: timeout, + ); + } } diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index c7fb7698..31bb57f8 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -195,7 +195,7 @@ void main() { } }); - test('manual workflows can derive no-args refs', () async { + test('manual workflows expose direct no-args helpers', () async { final flow = Flow( name: 'runtime.ref.no-args.flow', build: (builder) { @@ -207,9 +207,6 @@ void main() { run: (context) async => 'hello script', ); - final flowRef = flow.ref0(); - final scriptRef = script.ref0(); - final workflowApp = await StemWorkflowApp.inMemory( flows: [flow], scripts: [script], @@ -217,12 +214,14 @@ void main() { try { await workflowApp.start(); - final flowResult = await flowRef.startAndWaitWith( + final flowResult = await flow.startAndWaitWith( workflowApp, timeout: const Duration(seconds: 2), ); - final scriptResult = await scriptRef.startAndWaitWith( + final scriptRunId = await script.startWith(workflowApp.runtime); + final scriptResult = await script.waitFor( workflowApp.runtime, + scriptRunId, timeout: const Duration(seconds: 2), ); @@ -251,8 +250,6 @@ void main() { final workflowRef = flow.ref>( encodeParams: (params) => params, ); - final scriptRef = script.ref0(); - final workflowApp = await StemWorkflowApp.inMemory( flows: [flow], scripts: [script], @@ -278,7 +275,7 @@ void main() { expect(result?.value, 'hello builder'); expect(state?.parentRunId, 'parent-builder'); - final scriptBuilder = scriptRef.startBuilder().cancellationPolicy( + final scriptBuilder = script.startBuilder().cancellationPolicy( const WorkflowCancellationPolicy( maxRunDuration: Duration(seconds: 5), ), From 6b6716a4548376fec0c6270b4e4b7d048849b298 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 23:50:23 -0500 Subject: [PATCH 123/302] Prefer enqueue-and-wait in typed task docs --- packages/stem/CHANGELOG.md | 3 +++ packages/stem/README.md | 25 ++++++++++++------------- packages/stem/example/stem_example.dart | 6 +----- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index ffd984d1..37d31c5d 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -27,6 +27,9 @@ `startBuilder()`, and `waitFor(...)` helpers on manual `Flow` and `WorkflowScript` definitions so simple workflows no longer need an extra `ref0()` hop just to start or wait. +- Updated the public typed task examples to prefer `enqueueAndWait(...)` as the + happy path when callers only need the final typed result, while keeping + `waitFor(...)` documented for task-id-driven inspection flows. - Made `TaskContext` and `TaskInvocationContext` implement `WorkflowEventEmitter` when a workflow runtime is attached, so inline handlers and isolate entrypoints can resume waiting workflows with diff --git a/packages/stem/README.md b/packages/stem/README.md index 52d945da..aad21e49 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -231,15 +231,14 @@ that already have a `PayloadCodec`. The codec still needs to encode to `Map` because task args are published as a map. For typed task calls, the definition and call objects now expose the common -producer operations directly: +producer operations directly. Prefer `enqueueAndWait(...)` when you only need +the final typed result: ```dart -final taskId = await HelloTask.definition.enqueue( +final result = await HelloTask.definition.enqueueAndWait( stem, const HelloArgs(name: 'Stem'), ); - -final result = await HelloTask.definition.waitFor(stem, taskId); ``` For tasks without producer inputs, use `TaskDefinition.noArgs(...)` so callers @@ -817,18 +816,15 @@ lowering. ### Typed task completion Producers can now wait for individual task results using either -`TaskDefinition.waitFor(...)` or `Stem.waitForTask` with optional decoders. -These helpers return a `TaskResult` containing the underlying `TaskStatus`, -decoded payload, and a timeout flag: +`TaskDefinition.enqueueAndWait(...)`, `TaskDefinition.waitFor(...)`, or +`Stem.waitForTask` with optional decoders. These helpers return a +`TaskResult` containing the underlying `TaskStatus`, decoded payload, and a +timeout flag: ```dart -final taskId = await ChargeCustomer.definition - .call(ChargeArgs(orderId: '123')) - .enqueue(stem); - -final charge = await ChargeCustomer.definition.waitFor( +final charge = await ChargeCustomer.definition.enqueueAndWait( stem, - taskId, + ChargeArgs(orderId: '123'), ); if (charge?.isSucceeded == true) { print('Captured ${charge!.value!.total}'); @@ -837,6 +833,9 @@ if (charge?.isSucceeded == true) { } ``` +Use `waitFor(...)` when you need to keep the task id for inspection or pass it +through another boundary before waiting. + Generated annotated tasks use the same surface: ```dart diff --git a/packages/stem/example/stem_example.dart b/packages/stem/example/stem_example.dart index d3d80e93..a5d464e8 100644 --- a/packages/stem/example/stem_example.dart +++ b/packages/stem/example/stem_example.dart @@ -60,13 +60,9 @@ Future main() async { await app.waitForTask(taskId, timeout: const Duration(seconds: 2)); // Typed helper with TaskDefinition for compile-time safety. - final typedTaskId = await HelloTask.definition.enqueue( + await HelloTask.definition.enqueueAndWait( app, const HelloArgs(name: 'Stem'), - ); - await HelloTask.definition.waitFor( - app, - typedTaskId, timeout: const Duration(seconds: 2), ); await app.close(); From 3bce6f0896e8778c96f419fb1ec965d479d6c673 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 23:51:26 -0500 Subject: [PATCH 124/302] Use direct no-arg workflow helpers in examples --- packages/stem/CHANGELOG.md | 3 +++ packages/stem/example/docs_snippets/lib/workflows.dart | 5 ++--- packages/stem/example/persistent_sleep.dart | 5 ++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 37d31c5d..22f5bfbd 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -30,6 +30,9 @@ - Updated the public typed task examples to prefer `enqueueAndWait(...)` as the happy path when callers only need the final typed result, while keeping `waitFor(...)` documented for task-id-driven inspection flows. +- Updated the remaining no-args workflow examples and docs snippets to use the + new direct `Flow.startWith(...)` / `Flow.waitFor(...)` helpers instead of + creating a temporary `ref0()` only to start and wait. - Made `TaskContext` and `TaskInvocationContext` implement `WorkflowEventEmitter` when a workflow runtime is attached, so inline handlers and isolate entrypoints can resume waiting workflows with diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index 0b1c76e9..a859ad0c 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -274,12 +274,11 @@ Future main() async { flow.step('hello', (ctx) async => 'done'); }, ); - final demoFlowRef = demoFlow.ref0(); final app = await StemWorkflowApp.inMemory(flows: [demoFlow]); - final runId = await demoFlowRef.startWith(app); - final result = await demoFlowRef.waitFor( + final runId = await demoFlow.startWith(app); + final result = await demoFlow.waitFor( app, runId, timeout: const Duration(seconds: 5), diff --git a/packages/stem/example/persistent_sleep.dart b/packages/stem/example/persistent_sleep.dart index 8177c280..815bc548 100644 --- a/packages/stem/example/persistent_sleep.dart +++ b/packages/stem/example/persistent_sleep.dart @@ -20,13 +20,12 @@ Future main() async { }); }, ); - final sleepLoopRef = sleepLoop.ref0(); final app = await StemWorkflowApp.inMemory( flows: [sleepLoop], ); - final runId = await sleepLoopRef.startWith(app); + final runId = await sleepLoop.startWith(app); await app.executeRun(runId); // After the delay elapses, the runtime should resume without the step @@ -37,7 +36,7 @@ Future main() async { await app.executeRun(id); } - final completed = await sleepLoopRef.waitFor(app, runId); + final completed = await sleepLoop.waitFor(app, runId); print('Workflow completed with result: ${completed?.value}'); await app.close(); } From 9469a0b4bd6869ddf6badef38457e2866ac92c00 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 23:53:17 -0500 Subject: [PATCH 125/302] Use no-arg task definitions in no-input producers --- packages/stem/CHANGELOG.md | 3 +++ .../stem/example/docs_snippets/lib/observability.dart | 7 ++++--- .../stem/example/docs_snippets/lib/retry_backoff.dart | 6 ++++-- packages/stem/example/otel_metrics/bin/worker.dart | 6 ++++-- packages/stem/example/signals_demo/bin/producer.dart | 4 ++-- packages/stem/example/signals_demo/lib/shared.dart | 9 +++++++-- 6 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 22f5bfbd..09e37d35 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -33,6 +33,9 @@ - Updated the remaining no-args workflow examples and docs snippets to use the new direct `Flow.startWith(...)` / `Flow.waitFor(...)` helpers instead of creating a temporary `ref0()` only to start and wait. +- Updated the remaining no-input producer examples to prefer + `TaskDefinition.noArgs(...)` over raw empty-map publishes where the task + already has a stable typed definition. - Made `TaskContext` and `TaskInvocationContext` implement `WorkflowEventEmitter` when a workflow runtime is attached, so inline handlers and isolate entrypoints can resume waiting workflows with diff --git a/packages/stem/example/docs_snippets/lib/observability.dart b/packages/stem/example/docs_snippets/lib/observability.dart index 788b482a..802598b7 100644 --- a/packages/stem/example/docs_snippets/lib/observability.dart +++ b/packages/stem/example/docs_snippets/lib/observability.dart @@ -52,6 +52,7 @@ void logTaskStart(Envelope envelope) { final metrics = MetricsCollector(); final heartbeatGauge = GaugeMetric(); +final traceTaskDefinition = TaskDefinition.noArgs(name: 'demo.trace'); class MetricsCollector { void recordRetry({required Duration delay}) {} @@ -67,7 +68,7 @@ Future main() async { final tasks = [ FunctionTaskHandler( - name: 'demo.trace', + name: traceTaskDefinition.name, entrypoint: (context, args) async { print('Tracing demo task'); return null; @@ -79,10 +80,10 @@ Future main() async { logTaskStart( Envelope( - name: 'demo.trace', + name: traceTaskDefinition.name, args: const {}, ), ); - await client.enqueue('demo.trace', args: const {}); + await traceTaskDefinition.enqueue(client); await client.close(); } diff --git a/packages/stem/example/docs_snippets/lib/retry_backoff.dart b/packages/stem/example/docs_snippets/lib/retry_backoff.dart index 55628342..d72e6783 100644 --- a/packages/stem/example/docs_snippets/lib/retry_backoff.dart +++ b/packages/stem/example/docs_snippets/lib/retry_backoff.dart @@ -6,8 +6,10 @@ import 'dart:async'; import 'package:stem/stem.dart'; class FlakyTask extends TaskHandler { + static final definition = TaskDefinition.noArgs(name: 'demo.flaky'); + @override - String get name => 'demo.flaky'; + String get name => definition.name; // #region retry-backoff-task-options @override @@ -65,7 +67,7 @@ Future main() async { workerConfig: workerConfig, ); - final taskId = await app.enqueue('demo.flaky'); + final taskId = await FlakyTask.definition.enqueue(app); await app.waitForTask(taskId, timeout: const Duration(seconds: 5)); await app.close(); diff --git a/packages/stem/example/otel_metrics/bin/worker.dart b/packages/stem/example/otel_metrics/bin/worker.dart index 327b5d3c..e351c9b5 100644 --- a/packages/stem/example/otel_metrics/bin/worker.dart +++ b/packages/stem/example/otel_metrics/bin/worker.dart @@ -3,10 +3,12 @@ import 'dart:io'; import 'package:stem/stem.dart'; +final pingDefinition = TaskDefinition.noArgs(name: 'metrics.ping'); + Future main() async { final tasks = >[ FunctionTaskHandler( - name: 'metrics.ping', + name: pingDefinition.name, entrypoint: (context, _) async { // Simulate a bit of work. await Future.delayed(const Duration(milliseconds: 150)); @@ -43,6 +45,6 @@ Future main() async { ); Timer.periodic(const Duration(seconds: 1), (_) async { - await client.enqueue('metrics.ping'); + await pingDefinition.enqueue(client); }); } diff --git a/packages/stem/example/signals_demo/bin/producer.dart b/packages/stem/example/signals_demo/bin/producer.dart index 3a4350a0..e3c108a8 100644 --- a/packages/stem/example/signals_demo/bin/producer.dart +++ b/packages/stem/example/signals_demo/bin/producer.dart @@ -23,8 +23,8 @@ Future main() async { 'tasks.hello', args: {'name': 'from-producer'}, ); - await client.enqueue('tasks.flaky'); - await client.enqueue('tasks.always_fail'); + await flakyTaskDefinition.enqueue(client); + await alwaysFailTaskDefinition.enqueue(client); }); void scheduleShutdown(ProcessSignal signal) async { diff --git a/packages/stem/example/signals_demo/lib/shared.dart b/packages/stem/example/signals_demo/lib/shared.dart index 39b3c4db..333d5f7b 100644 --- a/packages/stem/example/signals_demo/lib/shared.dart +++ b/packages/stem/example/signals_demo/lib/shared.dart @@ -3,6 +3,11 @@ import 'dart:convert'; import 'package:stem/stem.dart'; +final flakyTaskDefinition = TaskDefinition.noArgs(name: 'tasks.flaky'); +final alwaysFailTaskDefinition = TaskDefinition.noArgs( + name: 'tasks.always_fail', +); + List> buildTasks() => [ FunctionTaskHandler( name: 'tasks.hello', @@ -10,12 +15,12 @@ List> buildTasks() => [ options: const TaskOptions(maxRetries: 0), ), FunctionTaskHandler( - name: 'tasks.flaky', + name: flakyTaskDefinition.name, entrypoint: _flakyEntrypoint, options: const TaskOptions(maxRetries: 2), ), FunctionTaskHandler( - name: 'tasks.always_fail', + name: alwaysFailTaskDefinition.name, entrypoint: _alwaysFailEntrypoint, options: const TaskOptions(maxRetries: 1), ), From 29d91efbd0696431f457f8f7e2948260ae25e200 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 23:54:34 -0500 Subject: [PATCH 126/302] Use no-arg task definitions in persistence snippets --- packages/stem/CHANGELOG.md | 2 ++ .../stem/example/docs_snippets/lib/persistence.dart | 12 +++++++----- packages/stem/example/docs_snippets/lib/signals.dart | 10 +++++++--- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 09e37d35..116bb5ee 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -36,6 +36,8 @@ - Updated the remaining no-input producer examples to prefer `TaskDefinition.noArgs(...)` over raw empty-map publishes where the task already has a stable typed definition. +- Updated the persistence and signals docs snippets to use the same no-input + task-definition helpers instead of raw empty-map enqueue calls. - Made `TaskContext` and `TaskInvocationContext` implement `WorkflowEventEmitter` when a workflow runtime is attached, so inline handlers and isolate entrypoints can resume waiting workflows with diff --git a/packages/stem/example/docs_snippets/lib/persistence.dart b/packages/stem/example/docs_snippets/lib/persistence.dart index 0eb660cd..9a6d7ab1 100644 --- a/packages/stem/example/docs_snippets/lib/persistence.dart +++ b/packages/stem/example/docs_snippets/lib/persistence.dart @@ -9,9 +9,11 @@ import 'package:stem_postgres/stem_postgres.dart'; import 'package:stem_redis/stem_redis.dart'; import 'package:stem_sqlite/stem_sqlite.dart'; +final demoTaskDefinition = TaskDefinition.noArgs(name: 'demo'); + final demoTasks = [ FunctionTaskHandler( - name: 'demo', + name: demoTaskDefinition.name, entrypoint: (context, args) async { print('Handled demo task'); return null; @@ -26,7 +28,7 @@ Future connectInMemoryBackend() async { backend: StemBackendFactory.inMemory(), tasks: demoTasks, ); - await client.enqueue('demo', args: {}); + await demoTaskDefinition.enqueue(client); await client.close(); } // #endregion persistence-backend-in-memory @@ -44,7 +46,7 @@ Future connectRedisBackend() async { ), tasks: demoTasks, ); - await client.enqueue('demo', args: {}); + await demoTaskDefinition.enqueue(client); await client.close(); } // #endregion persistence-backend-redis @@ -64,7 +66,7 @@ Future connectPostgresBackend() async { ), tasks: demoTasks, ); - await client.enqueue('demo', args: {}); + await demoTaskDefinition.enqueue(client); await client.close(); } // #endregion persistence-backend-postgres @@ -82,7 +84,7 @@ Future connectSqliteBackend() async { ), tasks: demoTasks, ); - await client.enqueue('demo', args: {}); + await demoTaskDefinition.enqueue(client); await client.close(); } // #endregion persistence-backend-sqlite diff --git a/packages/stem/example/docs_snippets/lib/signals.dart b/packages/stem/example/docs_snippets/lib/signals.dart index 69c0ff23..e0b50328 100644 --- a/packages/stem/example/docs_snippets/lib/signals.dart +++ b/packages/stem/example/docs_snippets/lib/signals.dart @@ -5,6 +5,10 @@ import 'dart:async'; import 'package:stem/stem.dart'; +final signalsDemoTaskDefinition = TaskDefinition.noArgs( + name: 'signals.demo', +); + // #region signals-configure void configureSignals() { StemSignals.configure( @@ -59,7 +63,7 @@ List registerWorkerScopedSignals() { 'Task failed on worker ${payload.worker.id}: ${payload.taskName}', ); }, - taskName: 'signals.demo', + taskName: signalsDemoTaskDefinition.name, workerId: 'signals-worker', ), StemSignals.onControlCommandCompleted( @@ -141,7 +145,7 @@ Future main() async { 'memory://', tasks: [ FunctionTaskHandler( - name: 'signals.demo', + name: signalsDemoTaskDefinition.name, entrypoint: (context, args) async { print('Signals demo task'); return null; @@ -154,7 +158,7 @@ Future main() async { ), ); - await app.enqueue('signals.demo', args: const {}); + await signalsDemoTaskDefinition.enqueue(app); await Future.delayed(const Duration(milliseconds: 200)); await app.close(); From d692b3b605876236ee63644e5594814d271ae6af Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 23:55:30 -0500 Subject: [PATCH 127/302] Prefer direct child workflow builders in docs --- .site/docs/core-concepts/tasks.md | 4 +++- packages/stem/CHANGELOG.md | 3 +++ packages/stem/README.md | 6 +----- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 74e3a623..f72d6c7e 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -126,7 +126,9 @@ Inside isolate entrypoints: When a task runs inside a workflow-enabled runtime like `StemWorkflowApp`, both `TaskContext` and `TaskInvocationContext` also implement `WorkflowCaller`, so handlers and isolate entrypoints can start or wait for -typed child workflows without dropping to raw workflow-name APIs. +typed child workflows without dropping to raw workflow-name APIs. For manual +no-args flows and scripts, prefer `childFlow.startBuilder().startAndWaitWith( +context)` over manufacturing a temporary `ref0()` just to start the child. Those same contexts also implement `WorkflowEventEmitter`, so tasks can resume waiting workflows through `emitValue(...)` or typed `WorkflowEventRef` diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 116bb5ee..d1e0951f 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -38,6 +38,9 @@ already has a stable typed definition. - Updated the persistence and signals docs snippets to use the same no-input task-definition helpers instead of raw empty-map enqueue calls. +- Updated the manual child-workflow docs to prefer + `childFlow.startBuilder().startAndWaitWith(context)` over creating a + temporary `ref0()` only to start a no-args child run. - Made `TaskContext` and `TaskInvocationContext` implement `WorkflowEventEmitter` when a workflow runtime is attached, so inline handlers and isolate entrypoints can resume waiting workflows with diff --git a/packages/stem/README.md b/packages/stem/README.md index aad21e49..53d3ce67 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -312,17 +312,13 @@ final childWorkflow = Flow( }, ); -final childWorkflowRef = childWorkflow.ref0(); - class ParentTask implements TaskHandler { @override String get name => 'demo.parent'; @override Future call(TaskContext context, Map args) async { - final result = await context - .startNoArgsWorkflowBuilder(definition: childWorkflowRef) - .startAndWait(); + final result = await childWorkflow.startBuilder().startAndWaitWith(context); return result?.value ?? 'missing'; } } From f8bac39ce9818ca3b39d9af56f5f56939e3b94a3 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 23:58:29 -0500 Subject: [PATCH 128/302] Clarify name-based workflow APIs in docs --- .site/docs/workflows/getting-started.md | 5 ++++- .site/docs/workflows/starting-and-waiting.md | 8 ++++++-- packages/stem/CHANGELOG.md | 3 +++ packages/stem/README.md | 19 ++++++++++++++----- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/.site/docs/workflows/getting-started.md b/.site/docs/workflows/getting-started.md index bb2380ec..18d3f29b 100644 --- a/.site/docs/workflows/getting-started.md +++ b/.site/docs/workflows/getting-started.md @@ -31,7 +31,10 @@ not need to manually register the internal `stem.workflow.run` task. If you prefer a minimal example, `startWorkflow(...)` also lazy-starts the runtime and managed worker on first use. Explicit `start()` is still the better -choice when you want deterministic application lifecycle control. +choice when you want deterministic application lifecycle control. Use that +name-based API when workflow names come from config or external input. For +workflows you define in code, prefer direct workflow helpers or generated +workflow refs. ## 3. Start a run and wait for the result diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index c4619058..2ad2d2da 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -69,8 +69,12 @@ Use `ref0()` when another API specifically needs a `NoArgsWorkflowRef`. ## Wait for completion -`waitForCompletion` polls the store until the run finishes or the caller -times out. +For workflows defined in code, prefer direct workflow helpers or typed refs +like `ordersFlow.startAndWaitWith(...)` and +`StemWorkflowDefinitions.orders.startAndWaitWith(...)`. + +`waitForCompletion` is the low-level completion API for name-based runs. It +polls the store until the run finishes or the caller times out. Use the returned `WorkflowResult` when you need: diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index d1e0951f..2fe63015 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Clarified the workflow docs so direct workflow helpers and generated refs are + the default path, while `startWorkflow(...)` / `waitForCompletion(...)` are + explicitly documented as the low-level name-driven APIs. - Added `WorkflowCaller.startWorkflowBuilder(...)` / `startNoArgsWorkflowBuilder(...)` plus a caller-bound fluent builder so workflow-capable contexts, apps, and runtimes can start child workflows with diff --git a/packages/stem/README.md b/packages/stem/README.md index 53d3ce67..6b1538ab 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -443,8 +443,9 @@ The runtime shape is the same in every case: - bootstrap a `StemWorkflowApp` - pass `flows:`, `scripts:`, and `tasks:` directly -- start runs with `startWorkflow(...)` or generated workflow refs -- wait with `waitForCompletion(...)` +- start runs with direct workflow helpers or generated workflow refs +- use `startWorkflow(...)` / `waitForCompletion(...)` when names come from + config, CLI input, or other dynamic sources You do not need to build task registries manually for normal workflow usage. @@ -779,9 +780,17 @@ That split is the intended model: ### Typed workflow completion All workflow definitions (flows and scripts) accept an optional type argument -representing the value they produce. `StemWorkflowApp.waitForCompletion` -exposes the decoded value along with the raw `RunState`, letting you work with -domain models without manual casts: +representing the value they produce. For workflows you define in code, prefer +their direct helpers or typed refs: + +```dart +final result = await ordersWorkflow.startAndWaitWith(app); +print(result.value?.total); +``` + +`StemWorkflowApp.waitForCompletion` is the low-level completion API for +name-based runs. It exposes the decoded value along with the raw `RunState`, +letting you work with domain models without manual casts: ```dart final runId = await app.startWorkflow('orders.workflow'); From 99d8868a32e765cafa0c5b539f1dcdd846987236 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 00:01:25 -0500 Subject: [PATCH 129/302] Prefer direct child workflow helpers in docs --- .site/docs/core-concepts/tasks.md | 5 +++-- .site/docs/workflows/annotated-workflows.md | 7 +++--- .../workflows/context-and-serialization.md | 13 +++++------ packages/stem/CHANGELOG.md | 4 ++++ packages/stem/README.md | 7 +++--- .../example/annotated_workflows/README.md | 6 ++--- .../annotated_workflows/lib/definitions.dart | 22 +++++++++---------- packages/stem_builder/CHANGELOG.md | 3 +++ packages/stem_builder/README.md | 8 +++---- 9 files changed, 40 insertions(+), 35 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index f72d6c7e..295faad8 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -127,8 +127,9 @@ When a task runs inside a workflow-enabled runtime like `StemWorkflowApp`, both `TaskContext` and `TaskInvocationContext` also implement `WorkflowCaller`, so handlers and isolate entrypoints can start or wait for typed child workflows without dropping to raw workflow-name APIs. For manual -no-args flows and scripts, prefer `childFlow.startBuilder().startAndWaitWith( -context)` over manufacturing a temporary `ref0()` just to start the child. +flows and scripts, prefer `childFlow.startAndWaitWith(context)` or +`childWorkflowRef.startAndWaitWith(context, value)` for the simple case. Use a +builder only when you need advanced overrides. Those same contexts also implement `WorkflowEventEmitter`, so tasks can resume waiting workflows through `emitValue(...)` or typed `WorkflowEventRef` diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index fe4473f0..9c84614f 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -132,9 +132,10 @@ This keeps one authoring model: When a workflow needs to start another workflow, do it from a durable boundary: - `FlowContext` and `WorkflowScriptStepContext` both implement - `WorkflowCaller`, so prefer - `context.startWorkflowBuilder(definition: ref, params: value).startAndWait()` - inside flow steps and checkpoint methods + `WorkflowCaller`, so prefer `ref.startAndWaitWith(context, value)` inside + flow steps and checkpoint methods +- use `context.startWorkflowBuilder(...)` only when you need advanced start + overrides like `ttl(...)` or `cancellationPolicy(...)` Avoid starting child workflows from the raw `WorkflowScriptContext` body. diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index 269ccd11..553b495f 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -38,9 +38,8 @@ Depending on the context type, you can access: - `takeResumeData()` for event-driven resumes - `takeResumeValue(codec: ...)` for typed event-driven resumes - `idempotencyKey(...)` -- direct child-workflow start helpers such as - `context.startWorkflowBuilder(definition: ref, params: value).start()` and - `.startAndWait()` +- direct child-workflow start helpers such as `ref.startWith(context, value)` + and `ref.startAndWaitWith(context, value)` - direct task enqueue APIs because `FlowContext`, `WorkflowScriptStepContext`, and `TaskInvocationContext` all implement `TaskEnqueuer` @@ -48,10 +47,10 @@ Depending on the context type, you can access: Child workflow starts belong in durable boundaries: -- `context.startWorkflowBuilder(definition: ref, params: value).start()` - inside flow steps -- `context.startWorkflowBuilder(definition: ref, params: value).startAndWait()` - inside script checkpoints +- `ref.startWith(context, value)` inside flow steps +- `ref.startAndWaitWith(context, value)` inside script checkpoints +- `context.startWorkflowBuilder(...)` when you need advanced overrides like + `ttl(...)` or `cancellationPolicy(...)` Do not treat the raw `WorkflowScriptContext` body as a safe place for child starts or other replay-sensitive side effects. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 2fe63015..28a3596b 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.1 +- Updated the public workflow docs and annotated workflow example to prefer + direct child-workflow helpers like `ref.startAndWaitWith(context, value)` in + durable boundaries, keeping `startWorkflowBuilder(...)` for advanced + override cases. - Clarified the workflow docs so direct workflow helpers and generated refs are the default path, while `startWorkflow(...)` / `waitForCompletion(...)` are explicitly documented as the low-level name-driven APIs. diff --git a/packages/stem/README.md b/packages/stem/README.md index 6b1538ab..17c3b981 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -639,9 +639,10 @@ Durable workflow contexts enqueue tasks directly: Child workflows belong in durable execution boundaries: - `FlowContext` and `WorkflowScriptStepContext` both implement - `WorkflowCaller`, so prefer - `context.startWorkflowBuilder(definition: ref, params: value).startAndWait()` - inside flow steps and script checkpoints + `WorkflowCaller`, so prefer `ref.startAndWaitWith(context, value)` inside + flow steps and script checkpoints +- use `context.startWorkflowBuilder(...)` when you need advanced overrides like + `ttl(...)` or `cancellationPolicy(...)` - do not start child workflows from the raw `WorkflowScriptContext` body unless you are deliberately managing replay/idempotency yourself diff --git a/packages/stem/example/annotated_workflows/README.md b/packages/stem/example/annotated_workflows/README.md index 0ed8e0a4..8791ffeb 100644 --- a/packages/stem/example/annotated_workflows/README.md +++ b/packages/stem/example/annotated_workflows/README.md @@ -6,8 +6,7 @@ with the `stem_builder` bundle generator. It now demonstrates the generated script-proxy behavior explicitly: - a flow step using `FlowContext` - a flow step starting and waiting on a child workflow through - `context.startWorkflowBuilder(definition: StemWorkflowDefinitions.*, params: - value).startAndWait()` + `StemWorkflowDefinitions.*.startAndWaitWith(context, value)` - `run(WelcomeRequest request)` calls annotated checkpoint methods directly - `prepareWelcome(...)` calls other annotated checkpoints - `deliverWelcome(...)` calls another annotated checkpoint from inside an @@ -17,8 +16,7 @@ It now demonstrates the generated script-proxy behavior explicitly: expose `runId`, `workflow`, `stepName`, `stepIndex`, and idempotency keys while still calling its annotated checkpoint directly from `run(...)` - a script checkpoint starting and waiting on a child workflow through - `context.startWorkflowBuilder(definition: StemWorkflowDefinitions.*, params: - value).startAndWait()` + `StemWorkflowDefinitions.*.startAndWaitWith(context, value)` - a plain script workflow that returns a codec-backed DTO result and persists a codec-backed DTO checkpoint value - a typed `@TaskDefn` using optional named `TaskInvocationContext? context` diff --git a/packages/stem/example/annotated_workflows/lib/definitions.dart b/packages/stem/example/annotated_workflows/lib/definitions.dart index 0c524bd1..be9c4127 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.dart @@ -194,12 +194,11 @@ class AnnotatedFlowWorkflow { if (!ctx.sleepUntilResumed(const Duration(milliseconds: 50))) { return null; } - final childResult = await ctx - .startWorkflowBuilder( - definition: StemWorkflowDefinitions.script, - params: const WelcomeRequest(email: 'flow-child@example.com'), - ) - .startAndWait(timeout: const Duration(seconds: 2)); + final childResult = await StemWorkflowDefinitions.script.startAndWaitWith( + ctx, + const WelcomeRequest(email: 'flow-child@example.com'), + timeout: const Duration(seconds: 2), + ); return { 'workflow': ctx.workflow, 'runId': ctx.runId, @@ -275,12 +274,11 @@ class AnnotatedContextScriptWorkflow { final ctx = context!; final normalizedEmail = await normalizeEmail(request.email); final subject = await buildWelcomeSubject(normalizedEmail); - final childResult = await ctx - .startWorkflowBuilder( - definition: StemWorkflowDefinitions.script, - params: WelcomeRequest(email: normalizedEmail), - ) - .startAndWait(timeout: const Duration(seconds: 2)); + final childResult = await StemWorkflowDefinitions.script.startAndWaitWith( + ctx, + WelcomeRequest(email: normalizedEmail), + timeout: const Duration(seconds: 2), + ); return ContextCaptureResult( workflow: ctx.workflow, runId: ctx.runId, diff --git a/packages/stem_builder/CHANGELOG.md b/packages/stem_builder/CHANGELOG.md index 74f184a6..185a9323 100644 --- a/packages/stem_builder/CHANGELOG.md +++ b/packages/stem_builder/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.0 +- Updated the builder docs and annotated workflow example to prefer direct + child-workflow helpers like `ref.startAndWaitWith(context, value)` in + durable boundaries, leaving caller-bound builders for advanced overrides. - Warned when a manual `script.step(...)` wrapper redundantly encloses an annotated checkpoint, including context-aware checkpoints that can now use direct annotated method calls with injected workflow-step context. diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index 7e80df3c..4ee05c13 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -111,10 +111,10 @@ Durable workflow contexts enqueue tasks directly: Child workflows should be started from durable boundaries: -- `context.startWorkflowBuilder(definition: ref, params: value).start()` - inside flow steps -- `context.startWorkflowBuilder(definition: ref, params: value).startAndWait()` - inside script checkpoints +- `ref.startWith(context, value)` inside flow steps +- `ref.startAndWaitWith(context, value)` inside script checkpoints +- use `context.startWorkflowBuilder(...)` when you need advanced overrides like + `ttl(...)` or `cancellationPolicy(...)` Avoid starting child workflows directly from the raw `WorkflowScriptContext` body unless you are explicitly handling replay From 3068585753f0b097f8a540bab1998450ecfe369d Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 00:03:38 -0500 Subject: [PATCH 130/302] Prefer direct no-arg child workflow helper --- packages/stem/CHANGELOG.md | 3 +++ packages/stem/README.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 28a3596b..17dac6c3 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Updated the remaining README child-workflow snippet to use the direct + no-args helper `childWorkflow.startAndWaitWith(context)` instead of an + unnecessary `startBuilder()` hop. - Updated the public workflow docs and annotated workflow example to prefer direct child-workflow helpers like `ref.startAndWaitWith(context, value)` in durable boundaries, keeping `startWorkflowBuilder(...)` for advanced diff --git a/packages/stem/README.md b/packages/stem/README.md index 17c3b981..40be594c 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -318,7 +318,7 @@ class ParentTask implements TaskHandler { @override Future call(TaskContext context, Map args) async { - final result = await childWorkflow.startBuilder().startAndWaitWith(context); + final result = await childWorkflow.startAndWaitWith(context); return result?.value ?? 'missing'; } } From 6409250ea01654350b2e49aba947b01213f00ec7 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 00:06:04 -0500 Subject: [PATCH 131/302] Prefer direct workflow event ref emission --- .site/docs/workflows/suspensions-and-events.md | 4 ++-- packages/stem/CHANGELOG.md | 3 +++ packages/stem/README.md | 12 ++++++------ packages/stem/example/durable_watchers.dart | 10 ++++------ packages/stem/example/workflows/sleep_and_event.dart | 7 +------ 5 files changed, 16 insertions(+), 20 deletions(-) diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index 4a905e3d..46d8c8bf 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -66,8 +66,8 @@ wire format. `emitValue(...)` is a DTO/codec convenience layer, not a new transport shape. When the topic and codec travel together in your codebase, prefer a typed -`WorkflowEventRef` and `emitter.emitEventBuilder(event: ref, value: dto) -.emit()` as the happy path. `event.emitWith(...)` and +`WorkflowEventRef` and `event.emitWith(emitter, dto)` as the happy path. +`emitter.emitEventBuilder(event: ref, value: dto).emit()` and `event.call(value).emitWith(...)` remain available as lower-level variants. Pair that with `waitForEventRef(...)` or `awaitEventRef(...)`. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 17dac6c3..30f279e7 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Updated the public workflow event examples and docs to prefer the direct + typed ref helper `event.emitWith(emitter, value)` for simple event emission, + leaving bound event builders and prebuilt calls as lower-level variants. - Updated the remaining README child-workflow snippet to use the direct no-args helper `childWorkflow.startAndWaitWith(context)` instead of an unnecessary `startBuilder()` hop. diff --git a/packages/stem/README.md b/packages/stem/README.md index 40be594c..601ada1e 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -311,6 +311,9 @@ final childWorkflow = Flow( flow.step('complete', (ctx) async => 'done'); }, ); +const childReadyEvent = WorkflowEventRef>( + topic: 'demo.child.workflow.ready', +); class ParentTask implements TaskHandler { @override @@ -329,10 +332,7 @@ class NotifyTask implements TaskHandler { @override Future call(TaskContext context, Map args) async { - await context.emitValue( - 'demo.child.workflow.ready', - {'status': 'ready'}, - ); + await childReadyEvent.emitWith(context, {'status': 'ready'}); } } ``` @@ -1073,8 +1073,8 @@ backend metadata under `stem.unique.duplicates`. - When you have a DTO event, emit it through `workflowApp.emitValue(...)` (or `runtime.emitValue(...)` when you are intentionally using the low-level runtime) with a `PayloadCodec`, or bundle the topic and codec once in a - `WorkflowEventRef` and use `emitter.emitEventBuilder(event: ref, value: - dto).emit()` as the happy path, with `event.emitWith(...)` and + `WorkflowEventRef` and use `event.emitWith(emitter, dto)` as the happy + path, with `emitter.emitEventBuilder(event: ref, value: dto).emit()` and `event.call(value).emitWith(...)` still available as lower-level variants. Pair that with `waitForEventRef(...)` or `awaitEventRef(...)`. Event payloads still serialize onto the existing `Map` wire diff --git a/packages/stem/example/durable_watchers.dart b/packages/stem/example/durable_watchers.dart index 7b1a2e89..6fb47442 100644 --- a/packages/stem/example/durable_watchers.dart +++ b/packages/stem/example/durable_watchers.dart @@ -57,12 +57,10 @@ Future main() async { print('Watcher metadata: ${watcher.data}'); } - await app - .emitEventBuilder( - event: shipmentReadyEvent, - value: const _ShipmentReadyEvent(trackingId: 'ZX-42'), - ) - .emit(); + await shipmentReadyEvent.emitWith( + app, + const _ShipmentReadyEvent(trackingId: 'ZX-42'), + ); await app.executeRun(runId); diff --git a/packages/stem/example/workflows/sleep_and_event.dart b/packages/stem/example/workflows/sleep_and_event.dart index c145627b..17015b11 100644 --- a/packages/stem/example/workflows/sleep_and_event.dart +++ b/packages/stem/example/workflows/sleep_and_event.dart @@ -47,12 +47,7 @@ Future main() async { await Future.delayed(const Duration(milliseconds: 50)); } - await app - .emitEventBuilder( - event: demoEvent, - value: const {'message': 'event received'}, - ) - .emit(); + await demoEvent.emitWith(app, const {'message': 'event received'}); final result = await sleepAndEvent.waitFor(app, runId); print('Workflow $runId resumed and completed with: ${result?.value}'); From 715c08616dcd200b3c44cbc382fee8a3c8835b89 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 04:16:54 -0500 Subject: [PATCH 132/302] Add StemModule merge helper --- .site/docs/core-concepts/stem-builder.md | 11 ++ packages/stem/CHANGELOG.md | 2 + packages/stem/README.md | 11 ++ .../stem/lib/src/bootstrap/stem_module.dart | 104 ++++++++++++++ .../test/bootstrap/module_bootstrap_test.dart | 127 ++++++++++++++++++ 5 files changed, 255 insertions(+) diff --git a/.site/docs/core-concepts/stem-builder.md b/.site/docs/core-concepts/stem-builder.md index c78cdcf0..b52bb804 100644 --- a/.site/docs/core-concepts/stem-builder.md +++ b/.site/docs/core-concepts/stem-builder.md @@ -98,6 +98,17 @@ subscription from the workflow queue plus the default queues declared on the bundled task handlers. Explicit subscriptions are still available for advanced routing. +If your service needs more than one generated or hand-written bundle, merge +them before bootstrap: + +```dart +final module = StemModule.merge([authModule, billingModule, stemModule]); +final workflowApp = await StemWorkflowApp.inMemory(module: module); +``` + +`StemModule.merge(...)` fails fast when modules declare conflicting task or +workflow names. + If you already manage a `StemApp` for a larger service, reuse it instead of bootstrapping a second app: diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 30f279e7..9ddb2269 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.1.1 +- Added `StemModule.merge(...)` so generated and hand-written bundles can be + composed with fail-fast conflict checks instead of manual list stitching. - Updated the public workflow event examples and docs to prefer the direct typed ref helper `event.emitWith(emitter, value)` for simple event emission, leaving bound event builders and prebuilt calls as lower-level variants. diff --git a/packages/stem/README.md b/packages/stem/README.md index 601ada1e..0192b8e0 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -732,6 +732,17 @@ When you bootstrap a plain `StemApp`, the worker infers task queue subscriptions from the bundled or explicitly supplied task handlers. Set `workerConfig.subscription` explicitly only when you need broader routing. +If you need to compose multiple generated or hand-written bundles, merge them +once and pass the combined module through bootstrap: + +```dart +final module = StemModule.merge([authModule, billingModule, stemModule]); +final app = await StemWorkflowApp.inMemory(module: module); +``` + +`StemModule.merge(...)` fails fast when modules declare the same task or +workflow name with different underlying definitions. + If your service already owns a `StemApp`, reuse it: ```dart diff --git a/packages/stem/lib/src/bootstrap/stem_module.dart b/packages/stem/lib/src/bootstrap/stem_module.dart index 6a57df47..7487ea8c 100644 --- a/packages/stem/lib/src/bootstrap/stem_module.dart +++ b/packages/stem/lib/src/bootstrap/stem_module.dart @@ -1,4 +1,5 @@ import 'dart:collection'; +import 'dart:convert'; import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/workflow/core/flow.dart'; @@ -47,6 +48,105 @@ class StemModule { ), ); + /// Merges [modules] into one bundled module. + /// + /// Duplicate task or workflow names must resolve to the same underlying + /// object instance. Distinct definitions with the same public name fail fast + /// so module composition never silently overrides behavior. + factory StemModule.merge(Iterable modules) { + final mergedWorkflows = []; + final mergedFlows = []; + final mergedScripts = []; + final mergedTasks = >[]; + final mergedManifest = []; + final workflowDefinitionsByName = {}; + final taskHandlersByName = >{}; + final manifestEntriesByName = {}; + + void addWorkflowDefinition( + WorkflowDefinition definition, { + required String source, + required void Function() onFirstSeen, + }) { + final existing = workflowDefinitionsByName[definition.name]; + if (existing == null) { + workflowDefinitionsByName[definition.name] = definition; + onFirstSeen(); + return; + } + if (!identical(existing, definition)) { + throw ArgumentError( + 'Workflow "${definition.name}" is declared by multiple modules ' + 'with different definitions ($source).', + ); + } + } + + void addTaskHandler(TaskHandler handler) { + final existing = taskHandlersByName[handler.name]; + if (existing == null) { + taskHandlersByName[handler.name] = handler; + mergedTasks.add(handler); + return; + } + if (!identical(existing, handler)) { + throw ArgumentError( + 'Task handler "${handler.name}" is declared by multiple modules ' + 'with different handlers.', + ); + } + } + + void addManifestEntry(WorkflowManifestEntry entry) { + final existing = manifestEntriesByName[entry.name]; + if (existing == null) { + manifestEntriesByName[entry.name] = entry; + mergedManifest.add(entry); + return; + } + if (!_sameManifestEntry(existing, entry)) { + throw ArgumentError( + 'Workflow manifest entry "${entry.name}" conflicts across merged ' + 'modules.', + ); + } + } + + for (final module in modules) { + for (final workflow in module.workflows) { + addWorkflowDefinition( + workflow, + source: 'workflow definition', + onFirstSeen: () => mergedWorkflows.add(workflow), + ); + } + for (final flow in module.flows) { + addWorkflowDefinition( + flow.definition, + source: 'flow', + onFirstSeen: () => mergedFlows.add(flow), + ); + } + for (final script in module.scripts) { + addWorkflowDefinition( + script.definition, + source: 'script', + onFirstSeen: () => mergedScripts.add(script), + ); + } + module.tasks.forEach(addTaskHandler); + module.workflowManifest.forEach(addManifestEntry); + } + + return StemModule( + workflows: mergedWorkflows, + flows: mergedFlows, + scripts: mergedScripts, + tasks: mergedTasks, + workflowManifest: mergedManifest, + ); + } + /// Raw workflow definitions that are not represented as [Flow] or /// [WorkflowScript] instances. final List workflows; @@ -197,3 +297,7 @@ class StemModule { } } } + +bool _sameManifestEntry(WorkflowManifestEntry a, WorkflowManifestEntry b) { + return jsonEncode(a.toJson()) == jsonEncode(b.toJson()); +} diff --git a/packages/stem/test/bootstrap/module_bootstrap_test.dart b/packages/stem/test/bootstrap/module_bootstrap_test.dart index 6c14c9df..8b28ec06 100644 --- a/packages/stem/test/bootstrap/module_bootstrap_test.dart +++ b/packages/stem/test/bootstrap/module_bootstrap_test.dart @@ -2,6 +2,133 @@ import 'package:stem/stem.dart'; import 'package:test/test.dart'; void main() { + group('StemModule.merge', () { + test('combines distinct task and workflow definitions', () async { + final taskA = FunctionTaskHandler( + name: 'module.merge.task.a', + entrypoint: (context, args) async => 'a', + runInIsolate: false, + ); + final taskB = FunctionTaskHandler( + name: 'module.merge.task.b', + options: const TaskOptions(queue: 'priority'), + entrypoint: (context, args) async => 'b', + runInIsolate: false, + ); + final flow = Flow( + name: 'module.merge.flow', + build: (builder) { + builder.step('hello', (ctx) async => 'ok'); + }, + ); + final merged = StemModule.merge([ + StemModule(tasks: [taskA]), + StemModule(flows: [flow], tasks: [taskB]), + ]); + + expect(merged.tasks, [taskA, taskB]); + expect( + merged.workflowDefinitions.map((definition) => definition.name), + ['module.merge.flow'], + ); + expect(merged.workflowManifest.map((entry) => entry.name), [ + 'module.merge.flow', + ]); + + final app = await StemWorkflowApp.inMemory(module: merged); + try { + await app.start(); + + final runId = await app.startWorkflow('module.merge.flow'); + final flowResult = await app.waitForCompletion( + runId, + timeout: const Duration(seconds: 2), + ); + expect(flowResult?.value, 'ok'); + } finally { + await app.close(); + } + }); + + test('deduplicates identical modules and manifest entries', () { + final flow = Flow( + name: 'module.merge.duplicate.flow', + build: (builder) { + builder.step('hello', (ctx) async => 'ok'); + }, + ); + final task = FunctionTaskHandler( + name: 'module.merge.duplicate.task', + entrypoint: (context, args) async => 'ok', + runInIsolate: false, + ); + final module = StemModule(flows: [flow], tasks: [task]); + + final merged = StemModule.merge([module, module]); + + expect(merged.tasks, [task]); + expect( + merged.workflowDefinitions.map((definition) => definition.name), + ['module.merge.duplicate.flow'], + ); + expect(merged.workflowManifest.map((entry) => entry.name), [ + 'module.merge.duplicate.flow', + ]); + }); + + test('fails fast on conflicting task or workflow names', () { + final taskA = FunctionTaskHandler( + name: 'module.merge.conflict.task', + entrypoint: (context, args) async => 'a', + runInIsolate: false, + ); + final taskB = FunctionTaskHandler( + name: 'module.merge.conflict.task', + entrypoint: (context, args) async => 'b', + runInIsolate: false, + ); + final flowA = Flow( + name: 'module.merge.conflict.workflow', + build: (builder) { + builder.step('hello', (ctx) async => 'a'); + }, + ); + final flowB = Flow( + name: 'module.merge.conflict.workflow', + build: (builder) { + builder.step('hello', (ctx) async => 'b'); + }, + ); + + expect( + () => StemModule.merge([ + StemModule(tasks: [taskA]), + StemModule(tasks: [taskB]), + ]), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('module.merge.conflict.task'), + ), + ), + ); + expect( + () => StemModule.merge([ + StemModule(flows: [flowA]), + StemModule(flows: [flowB]), + ]), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('module.merge.conflict.workflow'), + ), + ), + ); + }); + }); + group('module bootstrap', () { test('StemApp.inMemory registers module tasks and infers queues', () async { final moduleTask = FunctionTaskHandler( From cde8c530174bf286e0ef93cd7f9896f02a674605 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 04:24:09 -0500 Subject: [PATCH 133/302] Add plural module bootstrap helpers --- .site/docs/core-concepts/stem-builder.md | 9 ++ .site/docs/workflows/getting-started.md | 2 +- packages/stem/CHANGELOG.md | 6 ++ packages/stem/README.md | 9 ++ packages/stem/lib/src/bootstrap/stem_app.dart | 20 +++- .../stem/lib/src/bootstrap/stem_client.dart | 26 ++++- .../stem/lib/src/bootstrap/stem_module.dart | 22 +++++ .../stem/lib/src/bootstrap/workflow_app.dart | 28 +++++- .../test/bootstrap/module_bootstrap_test.dart | 98 +++++++++++++++++++ .../stem/test/bootstrap/stem_client_test.dart | 41 ++++++++ .../workflow_module_bootstrap_test.dart | 81 +++++++++++++++ 11 files changed, 328 insertions(+), 14 deletions(-) diff --git a/.site/docs/core-concepts/stem-builder.md b/.site/docs/core-concepts/stem-builder.md index b52bb804..951091af 100644 --- a/.site/docs/core-concepts/stem-builder.md +++ b/.site/docs/core-concepts/stem-builder.md @@ -109,6 +109,15 @@ final workflowApp = await StemWorkflowApp.inMemory(module: module); `StemModule.merge(...)` fails fast when modules declare conflicting task or workflow names. +If you do not want to pre-merge them yourself, bootstrap helpers also accept +`modules:` directly: + +```dart +final workflowApp = await StemWorkflowApp.inMemory( + modules: [authModule, billingModule, stemModule], +); +``` + If you already manage a `StemApp` for a larger service, reuse it instead of bootstrapping a second app: diff --git a/.site/docs/workflows/getting-started.md b/.site/docs/workflows/getting-started.md index 18d3f29b..ed495369 100644 --- a/.site/docs/workflows/getting-started.md +++ b/.site/docs/workflows/getting-started.md @@ -68,7 +68,7 @@ runtime registry: - `registerWorkflow(...)` / `registerWorkflows(...)` - `registerFlow(...)` / `registerFlows(...)` - `registerScript(...)` / `registerScripts(...)` -- `registerModule(...)` +- `registerModule(...)` / `registerModules(...)` ## 5. Move to the right next page diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 9ddb2269..79d5b527 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,12 @@ ## 0.1.1 +- Added `StemModule.combine(...)` plus `modules:` support across `StemApp`, + `StemWorkflowApp`, and `StemClient` bootstrap helpers so multi-module apps + no longer need to pre-merge bundles manually at every call site. +- Added `registerModules(...)` on `StemWorkflowApp` so late registration can + attach multiple generated or hand-written bundles with the same conflict + rules as `StemModule.merge(...)`. - Added `StemModule.merge(...)` so generated and hand-written bundles can be composed with fail-fast conflict checks instead of manual list stitching. - Updated the public workflow event examples and docs to prefer the direct diff --git a/packages/stem/README.md b/packages/stem/README.md index 0192b8e0..b4e823a7 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -743,6 +743,15 @@ final app = await StemWorkflowApp.inMemory(module: module); `StemModule.merge(...)` fails fast when modules declare the same task or workflow name with different underlying definitions. +Bootstrap helpers also accept `modules:` directly when you would rather let +the app/client merge them for you: + +```dart +final app = await StemWorkflowApp.inMemory( + modules: [authModule, billingModule, stemModule], +); +``` + If your service already owns a `StemApp`, reuse it: ```dart diff --git a/packages/stem/lib/src/bootstrap/stem_app.dart b/packages/stem/lib/src/bootstrap/stem_app.dart index 1d8f8b05..0603bfea 100644 --- a/packages/stem/lib/src/bootstrap/stem_app.dart +++ b/packages/stem/lib/src/bootstrap/stem_app.dart @@ -184,6 +184,7 @@ class StemApp implements StemTaskApp { /// Creates a new Stem application with the provided configuration. static Future create({ StemModule? module, + Iterable modules = const [], Iterable> tasks = const [], TaskRegistry? registry, StemBrokerFactory? broker, @@ -200,7 +201,12 @@ class StemApp implements StemTaskApp { TaskPayloadEncoder argsEncoder = const JsonTaskPayloadEncoder(), Iterable additionalEncoders = const [], }) async { - final bundledTasks = module?.tasks ?? const >[]; + final effectiveModule = StemModule.combine( + module: module, + modules: modules, + ); + final bundledTasks = + effectiveModule?.tasks ?? const >[]; final allTasks = [...bundledTasks, ...tasks]; final taskRegistry = registry ?? InMemoryTaskRegistry(); registerModuleTaskHandlers(taskRegistry, allTasks); @@ -244,7 +250,7 @@ class StemApp implements StemTaskApp { final workerSigner = workerConfig.signer ?? signer; final inferredSubscription = workerConfig.subscription ?? - module?.inferTaskWorkerSubscription( + effectiveModule?.inferTaskWorkerSubscription( defaultQueue: workerConfig.queue, additionalTasks: tasks, ) ?? @@ -294,7 +300,7 @@ class StemApp implements StemTaskApp { ]; return StemApp._( - module: module, + module: effectiveModule, registry: taskRegistry, broker: brokerInstance, backend: encodedBackend, @@ -307,6 +313,7 @@ class StemApp implements StemTaskApp { /// Creates an in-memory Stem application (broker + result backend). static Future inMemory({ StemModule? module, + Iterable modules = const [], Iterable> tasks = const [], StemWorkerConfig workerConfig = const StemWorkerConfig(), TaskPayloadEncoderRegistry? encoderRegistry, @@ -316,6 +323,7 @@ class StemApp implements StemTaskApp { }) { return StemApp.create( module: module, + modules: modules, tasks: tasks, broker: StemBrokerFactory.inMemory(), backend: StemBackendFactory.inMemory(), @@ -334,6 +342,7 @@ class StemApp implements StemTaskApp { static Future fromUrl( String url, { StemModule? module, + Iterable modules = const [], Iterable> tasks = const [], TaskRegistry? registry, Iterable adapters = const [], @@ -408,6 +417,7 @@ class StemApp implements StemTaskApp { try { final app = await create( module: module, + modules: modules, tasks: tasks, registry: registry, broker: resolvedStack.broker, @@ -448,10 +458,12 @@ class StemApp implements StemTaskApp { static Future fromClient( StemClient client, { StemModule? module, + Iterable modules = const [], Iterable> tasks = const [], StemWorkerConfig workerConfig = const StemWorkerConfig(), }) async { - final effectiveModule = module ?? client.module; + final effectiveModule = + StemModule.combine(module: module, modules: modules) ?? client.module; final bundledTasks = effectiveModule?.tasks ?? const >[]; final allTasks = [...bundledTasks, ...tasks]; diff --git a/packages/stem/lib/src/bootstrap/stem_client.dart b/packages/stem/lib/src/bootstrap/stem_client.dart index 0c308c08..5ae328c4 100644 --- a/packages/stem/lib/src/bootstrap/stem_client.dart +++ b/packages/stem/lib/src/bootstrap/stem_client.dart @@ -24,6 +24,7 @@ abstract class StemClient implements TaskResultCaller { /// Creates a client using the provided factories and defaults. static Future create({ StemModule? module, + Iterable modules = const [], Iterable> tasks = const [], TaskRegistry? taskRegistry, WorkflowRegistry? workflowRegistry, @@ -43,6 +44,7 @@ abstract class StemClient implements TaskResultCaller { return _DefaultStemClient.create( tasks: tasks, module: module, + modules: modules, taskRegistry: taskRegistry, workflowRegistry: workflowRegistry, broker: broker, @@ -63,6 +65,7 @@ abstract class StemClient implements TaskResultCaller { /// Creates an in-memory client using in-memory broker/backend. static Future inMemory({ StemModule? module, + Iterable modules = const [], Iterable> tasks = const [], StemWorkerConfig defaultWorkerConfig = const StemWorkerConfig(), TaskPayloadEncoderRegistry? encoderRegistry, @@ -72,6 +75,7 @@ abstract class StemClient implements TaskResultCaller { }) { return create( module: module, + modules: modules, tasks: tasks, broker: StemBrokerFactory.inMemory(), backend: StemBackendFactory.inMemory(), @@ -90,6 +94,7 @@ abstract class StemClient implements TaskResultCaller { static Future fromUrl( String url, { StemModule? module, + Iterable modules = const [], Iterable> tasks = const [], TaskRegistry? taskRegistry, WorkflowRegistry? workflowRegistry, @@ -113,6 +118,7 @@ abstract class StemClient implements TaskResultCaller { ); return create( module: module, + modules: modules, tasks: tasks, taskRegistry: taskRegistry, workflowRegistry: workflowRegistry, @@ -298,6 +304,7 @@ abstract class StemClient implements TaskResultCaller { /// Creates a workflow app using the shared client configuration. Future createWorkflowApp({ StemModule? module, + Iterable modules = const [], Iterable workflows = const [], Iterable flows = const [], Iterable scripts = const [], @@ -310,7 +317,8 @@ abstract class StemClient implements TaskResultCaller { Duration leaseExtension = const Duration(seconds: 30), WorkflowIntrospectionSink? introspectionSink, }) { - final effectiveModule = module ?? this.module; + final effectiveModule = + StemModule.combine(module: module, modules: modules) ?? this.module; return StemWorkflowApp.fromClient( client: this, module: effectiveModule, @@ -331,10 +339,12 @@ abstract class StemClient implements TaskResultCaller { /// Creates a StemApp wrapper using the shared client configuration. Future createApp({ StemModule? module, + Iterable modules = const [], Iterable> tasks = const [], StemWorkerConfig? workerConfig, }) { - final effectiveModule = module ?? this.module; + final effectiveModule = + StemModule.combine(module: module, modules: modules) ?? this.module; return StemApp.fromClient( this, module: effectiveModule, @@ -368,6 +378,7 @@ class _DefaultStemClient extends StemClient { static Future create({ StemModule? module, + Iterable modules = const [], Iterable> tasks = const [], TaskRegistry? taskRegistry, WorkflowRegistry? workflowRegistry, @@ -384,12 +395,17 @@ class _DefaultStemClient extends StemClient { TaskPayloadEncoder argsEncoder = const JsonTaskPayloadEncoder(), Iterable additionalEncoders = const [], }) async { - final bundledTasks = module?.tasks ?? const >[]; + final effectiveModule = StemModule.combine( + module: module, + modules: modules, + ); + final bundledTasks = + effectiveModule?.tasks ?? const >[]; final allTasks = [...bundledTasks, ...tasks]; final registry = taskRegistry ?? InMemoryTaskRegistry(); registerModuleTaskHandlers(registry, allTasks); final workflows = workflowRegistry ?? InMemoryWorkflowRegistry(); - module?.registerInto(workflows: workflows); + effectiveModule?.registerInto(workflows: workflows); final brokerFactory = broker ?? StemBrokerFactory.inMemory(); final backendFactory = backend ?? StemBackendFactory.inMemory(); @@ -418,7 +434,7 @@ class _DefaultStemClient extends StemClient { backend: backendInstance, taskRegistry: registry, workflowRegistry: workflows, - module: module, + module: effectiveModule, stem: stem, encoderRegistry: stem.payloadEncoders, routing: stem.routing, diff --git a/packages/stem/lib/src/bootstrap/stem_module.dart b/packages/stem/lib/src/bootstrap/stem_module.dart index 7487ea8c..561307c4 100644 --- a/packages/stem/lib/src/bootstrap/stem_module.dart +++ b/packages/stem/lib/src/bootstrap/stem_module.dart @@ -147,6 +147,28 @@ class StemModule { ); } + /// Combines an optional singular [module] and plural [modules] input. + /// + /// Returns `null` when no modules are supplied. When exactly one module is + /// present it is returned unchanged. Otherwise the modules are merged with + /// the same conflict detection as [StemModule.merge]. + static StemModule? combine({ + StemModule? module, + Iterable modules = const [], + }) { + final combined = [ + ?module, + ...modules, + ]; + if (combined.isEmpty) { + return null; + } + if (combined.length == 1) { + return combined.single; + } + return StemModule.merge(combined); + } + /// Raw workflow definitions that are not represented as [Flow] or /// [WorkflowScript] instances. final List workflows; diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index 048aba1b..c8c7b4a1 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -271,6 +271,15 @@ class StemWorkflowApp module.registerInto(workflows: runtime.registry); } + /// Registers all tasks and workflows from [modules] into this app. + void registerModules(Iterable modules) { + final merged = StemModule.combine(modules: modules); + if (merged == null) { + return; + } + registerModule(merged); + } + /// Registers [definition] into this app's workflow registry. void registerWorkflow(WorkflowDefinition definition) { runtime.registerWorkflow(definition); @@ -495,6 +504,7 @@ class StemWorkflowApp /// ``` static Future create({ StemModule? module, + Iterable modules = const [], Iterable workflows = const [], Iterable flows = const [], Iterable scripts = const [], @@ -516,7 +526,9 @@ class StemWorkflowApp TaskPayloadEncoder argsEncoder = const JsonTaskPayloadEncoder(), Iterable additionalEncoders = const [], }) async { - final effectiveModule = module ?? stemApp?.module; + final effectiveModule = + StemModule.combine(module: module, modules: modules) ?? + stemApp?.module; final moduleTasks = effectiveModule?.tasks ?? const >[]; final moduleWorkflowDefinitions = @@ -606,6 +618,7 @@ class StemWorkflowApp /// ``` static Future inMemory({ StemModule? module, + Iterable modules = const [], Iterable workflows = const [], Iterable flows = const [], Iterable scripts = const [], @@ -624,6 +637,7 @@ class StemWorkflowApp }) { return StemWorkflowApp.create( module: module, + modules: modules, workflows: workflows, flows: flows, scripts: scripts, @@ -656,6 +670,7 @@ class StemWorkflowApp static Future fromUrl( String url, { StemModule? module, + Iterable modules = const [], Iterable workflows = const [], Iterable flows = const [], Iterable scripts = const [], @@ -683,7 +698,7 @@ class StemWorkflowApp }) async { final resolvedWorkerConfig = _resolveWorkflowWorkerConfig( workerConfig, - module: module, + module: StemModule.combine(module: module, modules: modules), tasks: tasks, continuationQueue: continuationQueue, executionQueue: executionQueue, @@ -716,6 +731,7 @@ class StemWorkflowApp try { return await create( module: module, + modules: modules, workflows: workflows, flows: flows, scripts: scripts, @@ -752,6 +768,7 @@ class StemWorkflowApp static Future fromClient({ required StemClient client, StemModule? module, + Iterable modules = const [], Iterable workflows = const [], Iterable flows = const [], Iterable scripts = const [], @@ -767,7 +784,7 @@ class StemWorkflowApp }) async { final resolvedWorkerConfig = _resolveWorkflowWorkerConfig( workerConfig, - module: module, + module: StemModule.combine(module: module, modules: modules), tasks: tasks, continuationQueue: continuationQueue, executionQueue: executionQueue, @@ -778,6 +795,7 @@ class StemWorkflowApp ); return StemWorkflowApp.create( module: module, + modules: modules, workflows: workflows, flows: flows, scripts: scripts, @@ -804,6 +822,7 @@ extension StemAppWorkflowExtension on StemApp { /// required by the supplied module or tasks. Future createWorkflowApp({ StemModule? module, + Iterable modules = const [], Iterable workflows = const [], Iterable flows = const [], Iterable scripts = const [], @@ -819,7 +838,8 @@ extension StemAppWorkflowExtension on StemApp { WorkflowIntrospectionSink? introspectionSink, }) { return StemWorkflowApp.create( - module: module ?? this.module, + module: + StemModule.combine(module: module, modules: modules) ?? this.module, workflows: workflows, flows: flows, scripts: scripts, diff --git a/packages/stem/test/bootstrap/module_bootstrap_test.dart b/packages/stem/test/bootstrap/module_bootstrap_test.dart index 8b28ec06..9f05ec58 100644 --- a/packages/stem/test/bootstrap/module_bootstrap_test.dart +++ b/packages/stem/test/bootstrap/module_bootstrap_test.dart @@ -3,6 +3,28 @@ import 'package:test/test.dart'; void main() { group('StemModule.merge', () { + test('combine returns null, a single module, or a merged module', () { + final taskA = FunctionTaskHandler( + name: 'module.combine.task.a', + entrypoint: (context, args) async => 'a', + runInIsolate: false, + ); + final taskB = FunctionTaskHandler( + name: 'module.combine.task.b', + entrypoint: (context, args) async => 'b', + runInIsolate: false, + ); + final moduleA = StemModule(tasks: [taskA]); + final moduleB = StemModule(tasks: [taskB]); + + expect(StemModule.combine(), isNull); + expect(StemModule.combine(module: moduleA), same(moduleA)); + expect( + StemModule.combine(modules: [moduleA, moduleB])?.tasks, + [taskA, taskB], + ); + }); + test('combines distinct task and workflow definitions', () async { final taskA = FunctionTaskHandler( name: 'module.merge.task.a', @@ -183,6 +205,38 @@ void main() { } }); + test('StemApp.inMemory merges plural modules during bootstrap', () async { + final taskA = FunctionTaskHandler( + name: 'module.bootstrap.modules.task.a', + entrypoint: (context, args) async => 'a', + runInIsolate: false, + ); + final taskB = FunctionTaskHandler( + name: 'module.bootstrap.modules.task.b', + options: const TaskOptions(queue: 'priority'), + entrypoint: (context, args) async => 'b', + runInIsolate: false, + ); + + final app = await StemApp.inMemory( + modules: [ + StemModule(tasks: [taskA]), + StemModule(tasks: [taskB]), + ], + ); + await app.start(); + try { + expect(app.registry.resolve(taskA.name), same(taskA)); + expect(app.registry.resolve(taskB.name), same(taskB)); + expect( + app.worker.subscription.queues, + unorderedEquals(['default', 'priority']), + ); + } finally { + await app.close(); + } + }); + test('StemClient.createWorkflowApp reuses its default module', () async { final moduleTask = FunctionTaskHandler( name: 'module.client.workflow-task', @@ -263,6 +317,50 @@ void main() { } }); + test('StemApp.createWorkflowApp registers plural modules', () async { + final flow = Flow( + name: 'module.app.modules.workflow', + build: (builder) { + builder.step('hello', (ctx) async => 'module-ok'); + }, + ); + final task = FunctionTaskHandler( + name: 'module.app.modules.task', + options: const TaskOptions(queue: 'priority'), + entrypoint: (context, args) async => 'task-ok', + runInIsolate: false, + ); + final stemApp = await StemApp.inMemory( + workerConfig: StemWorkerConfig( + queue: 'workflow', + subscription: RoutingSubscription( + queues: ['workflow', 'priority'], + ), + ), + ); + + final workflowApp = await stemApp.createWorkflowApp( + modules: [ + StemModule(flows: [flow]), + StemModule(tasks: [task]), + ], + ); + await workflowApp.start(); + try { + expect(workflowApp.app.registry.resolve(task.name), same(task)); + + final runId = await workflowApp.startWorkflow(flow.definition.name); + final result = await workflowApp.waitForCompletion( + runId, + timeout: const Duration(seconds: 2), + ); + + expect(result?.value, 'module-ok'); + } finally { + await workflowApp.close(); + } + }); + test( 'StemWorkflowApp.create rejects reused StemApp without workflow queue ' 'coverage', diff --git a/packages/stem/test/bootstrap/stem_client_test.dart b/packages/stem/test/bootstrap/stem_client_test.dart index e699bda3..89d59130 100644 --- a/packages/stem/test/bootstrap/stem_client_test.dart +++ b/packages/stem/test/bootstrap/stem_client_test.dart @@ -383,6 +383,47 @@ void main() { await client.close(); }); + test('StemClient.inMemory merges plural default modules', () async { + final flow = Flow( + name: 'client.modules.workflow', + build: (builder) { + builder.step('hello', (ctx) async => 'module-ok'); + }, + ); + final task = FunctionTaskHandler( + name: 'client.modules.task', + options: const TaskOptions(queue: 'priority'), + entrypoint: (context, args) async => 'task-ok', + runInIsolate: false, + ); + final client = await StemClient.inMemory( + modules: [ + StemModule(flows: [flow]), + StemModule(tasks: [task]), + ], + ); + + final app = await client.createWorkflowApp(); + await app.start(); + + expect(app.app.registry.resolve(task.name), same(task)); + expect( + app.app.worker.subscription.queues, + unorderedEquals(['workflow', 'priority']), + ); + + final runId = await app.startWorkflow(flow.definition.name); + final result = await app.waitForCompletion( + runId, + timeout: const Duration(seconds: 2), + ); + + expect(result?.value, 'module-ok'); + + await app.close(); + await client.close(); + }); + test('StemClient workflow app supports startAndWaitWith', () async { final client = await StemClient.inMemory(); final flow = Flow( diff --git a/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart b/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart index ea47422a..40543fcf 100644 --- a/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart +++ b/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart @@ -3,6 +3,42 @@ import 'package:test/test.dart'; void main() { group('workflow module bootstrap', () { + test('StemWorkflowApp.inMemory merges plural modules', () async { + final flow = Flow( + name: 'workflow.module.modules.flow', + build: (builder) { + builder.step('hello', (ctx) async => 'flow-ok'); + }, + ); + final task = FunctionTaskHandler( + name: 'workflow.module.modules.task', + entrypoint: (context, args) async => 'task-ok', + runInIsolate: false, + ); + final workflowApp = await StemWorkflowApp.inMemory( + modules: [ + StemModule(flows: [flow]), + StemModule(tasks: [task]), + ], + ); + try { + expect( + workflowApp.app.worker.subscription.queues, + unorderedEquals(['workflow', 'default']), + ); + + await workflowApp.start(); + final runId = await workflowApp.startWorkflow(flow.definition.name); + final result = await workflowApp.waitForCompletion( + runId, + timeout: const Duration(seconds: 2), + ); + expect(result?.value, 'flow-ok'); + } finally { + await workflowApp.shutdown(); + } + }); + test('StemWorkflowApp.inMemory infers workflow and task queues', () async { final helperTask = FunctionTaskHandler( name: 'workflow.module.queue-helper', @@ -159,5 +195,50 @@ void main() { } }, ); + + test('registerModules registers flows and tasks together', () async { + final flow = Flow( + name: 'workflow.module.register-modules.flow', + build: (builder) { + builder.step('hello', (ctx) async => 'flow-ok'); + }, + ); + final task = FunctionTaskHandler( + name: 'workflow.module.register-modules.task', + entrypoint: (context, args) async => 'task-ok', + runInIsolate: false, + ); + final taskDefinition = TaskDefinition.noArgs(name: task.name); + final workflowApp = await StemWorkflowApp.inMemory( + workerConfig: StemWorkerConfig( + queue: 'workflow', + subscription: RoutingSubscription( + queues: ['workflow', 'default'], + ), + ), + ); + try { + workflowApp.registerModules([ + StemModule(flows: [flow]), + StemModule(tasks: [task]), + ]); + + await workflowApp.start(); + final runId = await workflowApp.startWorkflow(flow.definition.name); + final workflowResult = await workflowApp.waitForCompletion( + runId, + timeout: const Duration(seconds: 2), + ); + final taskResult = await taskDefinition.enqueueAndWait( + workflowApp, + timeout: const Duration(seconds: 2), + ); + + expect(workflowResult?.value, 'flow-ok'); + expect(taskResult?.value, 'task-ok'); + } finally { + await workflowApp.shutdown(); + } + }); }); } From d25cfa61d5ab548bcc2545ce4a100ee6937adfbd Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 04:34:55 -0500 Subject: [PATCH 134/302] Add named-arg workflow suspension helpers --- .../errors-retries-and-idempotency.md | 2 + .../docs/workflows/suspensions-and-events.md | 16 +- packages/stem/CHANGELOG.md | 5 + packages/stem/README.md | 24 +-- packages/stem/example/durable_watchers.dart | 7 +- .../example/workflows/sleep_and_event.dart | 11 +- .../src/workflow/core/workflow_resume.dart | 100 +++++++++ .../workflow/runtime/workflow_runtime.dart | 21 +- .../unit/workflow/workflow_resume_test.dart | 201 ++++++++++++++++++ .../test/workflow/workflow_runtime_test.dart | 112 ++++++++++ 10 files changed, 463 insertions(+), 36 deletions(-) diff --git a/.site/docs/workflows/errors-retries-and-idempotency.md b/.site/docs/workflows/errors-retries-and-idempotency.md index 199731c1..b150caab 100644 --- a/.site/docs/workflows/errors-retries-and-idempotency.md +++ b/.site/docs/workflows/errors-retries-and-idempotency.md @@ -12,6 +12,8 @@ the runtime after resume, and the step body must tolerate replay. Use: +- `await ctx.sleepFor(duration: ...)` for the expression-style sleep path +- `await ctx.waitForEvent(topic: ...)` for the expression-style event path - `sleepUntilResumed(...)` for simple sleep/replay loops - `waitForEventValue(...)` for one-event suspension points - `takeResumeData()` to branch on fresh resume payloads diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index 46d8c8bf..416c3c48 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -16,9 +16,7 @@ For the common "sleep once, continue on resume" case, prefer the higher-level helper: ```dart -if (!ctx.sleepUntilResumed(const Duration(milliseconds: 200))) { - return null; -} +await ctx.sleepFor(duration: const Duration(milliseconds: 200)); ``` ## Await external events @@ -34,18 +32,15 @@ Typical flow: `WorkflowRuntime.emitValue(...)` (or an app/service wrapper around it) with a payload 4. the runtime resumes the run and exposes the payload through - `waitForEventValue(...)`, `waitForEventRef(...)`, or the lower-level + `waitForEvent(...)`, `waitForEventRefValue(...)`, or the lower-level `takeResumeData()` / `takeResumeValue(codec: ...)` For the common "wait for one event and continue" case, prefer: ```dart -final payload = ctx.waitForEventValue>( - 'orders.payment.confirmed', +final payload = await ctx.waitForEvent>( + topic: 'orders.payment.confirmed', ); -if (payload == null) { - return null; -} ``` ## Emit resume events @@ -69,7 +64,8 @@ When the topic and codec travel together in your codebase, prefer a typed `WorkflowEventRef` and `event.emitWith(emitter, dto)` as the happy path. `emitter.emitEventBuilder(event: ref, value: dto).emit()` and `event.call(value).emitWith(...)` remain available as lower-level variants. -Pair that with `waitForEventRef(...)` or `awaitEventRef(...)`. +Pair that with `await ctx.waitForEventRefValue(event: ref)` or +`awaitEventRef(...)`. ## Inspect waiting runs diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 79d5b527..f284b330 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,11 @@ ## 0.1.1 +- Added expression-style workflow suspension helpers with named arguments: + `await ctx.sleepFor(duration: ...)`, + `await ctx.waitForEvent(topic: ...)`, and + `await ctx.waitForEventRefValue(event: ...)` for both flow steps and script + checkpoints. - Added `StemModule.combine(...)` plus `modules:` support across `StemApp`, `StemWorkflowApp`, and `StemClient` bootstrap helpers so multi-module apps no longer need to pre-merge bundles manually at every call site. diff --git a/packages/stem/README.md b/packages/stem/README.md index b4e823a7..6b8e7872 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -392,9 +392,7 @@ final app = await StemWorkflowApp.inMemory( }); await script.step('poll-shipment', (step) async { - if (!step.sleepUntilResumed(const Duration(seconds: 30))) { - return 'waiting'; - } + await step.sleepFor(duration: const Duration(seconds: 30)); final status = await fetchShipment(checkout.id); if (!status.isComplete) { await step.sleep(const Duration(seconds: 30)); @@ -421,6 +419,9 @@ Inside a script checkpoint you can access the same metadata as `FlowContext`: - `step.iteration` tracks the current auto-version suffix when `autoVersion: true` is set. - `step.idempotencyKey('scope')` builds stable outbound identifiers. +- `await step.sleepFor(duration: ...)` is the expression-style sleep path. +- `await step.waitForEvent(topic: ..., codec: ...)` is the expression-style + event wait path. - `step.sleepUntilResumed(...)` handles the common sleep-once, continue-on- resume path. - `step.waitForEventValue(...)` handles the common wait-for-one-event path. @@ -1064,16 +1065,13 @@ backend metadata under `stem.unique.duplicates`. - Prefer the higher-level helpers for common cases: ```dart - if (!ctx.sleepUntilResumed(const Duration(milliseconds: 200))) { - return null; - } + await ctx.sleepFor(duration: const Duration(milliseconds: 200)); ``` ```dart - final payload = ctx.waitForEventValue>('demo.event'); - if (payload == null) { - return null; - } + final payload = await ctx.waitForEvent>( + topic: 'demo.event', + ); ``` - Use `ctx.takeResumeData()` or `ctx.takeResumeValue(codec: ...)` when you @@ -1096,9 +1094,9 @@ backend metadata under `stem.unique.duplicates`. `WorkflowEventRef` and use `event.emitWith(emitter, dto)` as the happy path, with `emitter.emitEventBuilder(event: ref, value: dto).emit()` and `event.call(value).emitWith(...)` still available as lower-level variants. - Pair that with `waitForEventRef(...)` or `awaitEventRef(...)`. Event - payloads still serialize onto the existing `Map` wire - format. + Pair that with `await ctx.waitForEventRefValue(event: ref)` or + `awaitEventRef(...)`. Event payloads still serialize onto the existing + `Map` wire format. - Only return values you want persisted. If a handler returns `null`, the runtime treats it as "no result yet" and will run the step again on resume. - Derive outbound idempotency tokens with `ctx.idempotencyKey('charge')` so diff --git a/packages/stem/example/durable_watchers.dart b/packages/stem/example/durable_watchers.dart index 6fb47442..bb2be731 100644 --- a/packages/stem/example/durable_watchers.dart +++ b/packages/stem/example/durable_watchers.dart @@ -21,14 +21,11 @@ Future main() async { }); final trackingId = await script.step('wait-for-shipment', (step) async { - final payload = step.waitForEventRef( - shipmentReadyEvent, + final payload = await step.waitForEventRefValue( + event: shipmentReadyEvent, deadline: DateTime.now().add(const Duration(minutes: 5)), data: const {'reason': 'waiting-for-carrier'}, ); - if (payload == null) { - return 'waiting'; - } return payload.trackingId; }); diff --git a/packages/stem/example/workflows/sleep_and_event.dart b/packages/stem/example/workflows/sleep_and_event.dart index 17015b11..5efdd3fe 100644 --- a/packages/stem/example/workflows/sleep_and_event.dart +++ b/packages/stem/example/workflows/sleep_and_event.dart @@ -16,16 +16,13 @@ Future main() async { build: (flow) { flow ..step('initial', (ctx) async { - if (!ctx.sleepUntilResumed(const Duration(milliseconds: 200))) { - return null; - } + await ctx.sleepFor(duration: const Duration(milliseconds: 200)); return 'awake'; }) ..step('await-event', (ctx) async { - final payload = ctx.waitForEventRef(demoEvent); - if (payload == null) { - return null; - } + final payload = await ctx.waitForEvent>( + topic: demoEvent.topic, + ); return payload['message']; }); }, diff --git a/packages/stem/lib/src/workflow/core/workflow_resume.dart b/packages/stem/lib/src/workflow/core/workflow_resume.dart index 80b5cc20..6c7f7ccc 100644 --- a/packages/stem/lib/src/workflow/core/workflow_resume.dart +++ b/packages/stem/lib/src/workflow/core/workflow_resume.dart @@ -6,6 +6,15 @@ import 'package:stem/src/workflow/core/flow_step.dart'; import 'package:stem/src/workflow/core/workflow_event_ref.dart'; import 'package:stem/src/workflow/core/workflow_script_context.dart'; +/// Internal control-flow signal used by expression-style suspension helpers. +/// +/// The workflow runtime catches this after a helper has already scheduled the +/// corresponding sleep or event wait directive. User code should not catch it. +class WorkflowSuspensionSignal implements Exception { + /// Creates a durable suspension control-flow signal. + const WorkflowSuspensionSignal(); +} + /// Typed resume helpers for durable workflow suspensions. extension FlowContextResumeValues on FlowContext { /// Returns the next resume payload as [T] and consumes it. @@ -44,6 +53,25 @@ extension FlowContextResumeValues on FlowContext { return false; } + /// Suspends once for [duration] and resumes by replaying the same step. + /// + /// This enables expression-style flow logic: + /// + /// ```dart + /// await ctx.sleepFor(duration: const Duration(seconds: 1)); + /// ``` + Future sleepFor({ + required Duration duration, + Map? data, + }) async { + final resume = takeResumeData(); + if (resume != null) { + return; + } + sleep(duration, data: data); + throw const WorkflowSuspensionSignal(); + } + /// Returns the next event payload as [T] when the step has resumed, or /// registers an event wait and returns `null` on the first invocation. /// @@ -72,6 +100,21 @@ extension FlowContextResumeValues on FlowContext { return null; } + /// Suspends until [topic] is emitted, then returns the resumed payload. + Future waitForEvent({ + required String topic, + DateTime? deadline, + Map? data, + PayloadCodec? codec, + }) async { + final payload = takeResumeValue(codec: codec); + if (payload != null) { + return payload; + } + awaitEvent(topic, deadline: deadline, data: data); + throw const WorkflowSuspensionSignal(); + } + /// Returns the next event payload from [event] when the step has resumed, or /// registers an event wait and returns `null` on the first invocation. T? waitForEventRef( @@ -87,6 +130,20 @@ extension FlowContextResumeValues on FlowContext { ); } + /// Suspends until [event] is emitted, then returns the decoded payload. + Future waitForEventRefValue({ + required WorkflowEventRef event, + DateTime? deadline, + Map? data, + }) { + return waitForEvent( + topic: event.topic, + deadline: deadline, + data: data, + codec: event.codec, + ); + } + /// Registers an event wait using a typed [event] reference. FlowStepControl awaitEventRef( WorkflowEventRef event, { @@ -128,6 +185,20 @@ extension WorkflowScriptStepResumeValues on WorkflowScriptStepContext { return false; } + /// Suspends once for [duration] and resumes by replaying the same + /// checkpoint. + Future sleepFor({ + required Duration duration, + Map? data, + }) async { + final resume = takeResumeData(); + if (resume != null) { + return; + } + await sleep(duration, data: data); + throw const WorkflowSuspensionSignal(); + } + /// Returns the next event payload as [T] when the checkpoint has resumed, or /// registers an event wait and returns `null` on the first invocation. T? waitForEventValue( @@ -144,6 +215,21 @@ extension WorkflowScriptStepResumeValues on WorkflowScriptStepContext { return null; } + /// Suspends until [topic] is emitted, then returns the resumed payload. + Future waitForEvent({ + required String topic, + DateTime? deadline, + Map? data, + PayloadCodec? codec, + }) async { + final payload = takeResumeValue(codec: codec); + if (payload != null) { + return payload; + } + await awaitEvent(topic, deadline: deadline, data: data); + throw const WorkflowSuspensionSignal(); + } + /// Returns the next event payload from [event] when the checkpoint has /// resumed, or registers an event wait and returns `null` on the first /// invocation. @@ -160,6 +246,20 @@ extension WorkflowScriptStepResumeValues on WorkflowScriptStepContext { ); } + /// Suspends until [event] is emitted, then returns the decoded payload. + Future waitForEventRefValue({ + required WorkflowEventRef event, + DateTime? deadline, + Map? data, + }) { + return waitForEvent( + topic: event.topic, + deadline: deadline, + data: data, + codec: event.codec, + ); + } + /// Registers an event wait using a typed [event] reference. Future awaitEventRef( WorkflowEventRef event, { diff --git a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart index d53e09da..ea3b69dc 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart @@ -46,6 +46,7 @@ import 'package:stem/src/workflow/core/workflow_clock.dart'; import 'package:stem/src/workflow/core/workflow_definition.dart'; import 'package:stem/src/workflow/core/workflow_ref.dart'; import 'package:stem/src/workflow/core/workflow_result.dart'; +import 'package:stem/src/workflow/core/workflow_resume.dart'; import 'package:stem/src/workflow/core/workflow_runtime_metadata.dart'; import 'package:stem/src/workflow/core/workflow_script_context.dart'; import 'package:stem/src/workflow/core/workflow_status.dart'; @@ -664,11 +665,14 @@ class WorkflowRuntime implements WorkflowCaller, WorkflowEventEmitter { ); resumeData = null; dynamic result; + var suspendedBySignal = false; try { result = await TaskEnqueueScope.run( stepMeta, () async => await step.handler(context), ); + } on WorkflowSuspensionSignal { + suspendedBySignal = true; } on _WorkflowLeaseLost { return; } catch (error, stack) { @@ -821,6 +825,12 @@ class WorkflowRuntime implements WorkflowCaller, WorkflowEventEmitter { } return; } + if (suspendedBySignal) { + throw StateError( + 'Flow step "${step.name}" threw WorkflowSuspensionSignal without ' + 'scheduling a suspension control.', + ); + } final storedResult = step.encodeValue(result); await _store.saveStep(runId, checkpointName, storedResult); @@ -1551,12 +1561,15 @@ class _WorkflowScriptExecution implements WorkflowScriptContext { ), workflows: _ChildWorkflowCaller(runtime: runtime, parentRunId: runId), ); - T result; + late final T result; + var suspendedBySignal = false; try { result = await TaskEnqueueScope.run( stepMeta, () async => await handler(stepContext), ); + } on WorkflowSuspensionSignal { + suspendedBySignal = true; } catch (error, stack) { await runtime._recordStepEvent( WorkflowStepEventType.failed, @@ -1575,6 +1588,12 @@ class _WorkflowScriptExecution implements WorkflowScriptContext { throw const _WorkflowScriptSuspended(); } } + if (suspendedBySignal) { + throw StateError( + 'Script checkpoint "$name" threw WorkflowSuspensionSignal without ' + 'scheduling a suspension control.', + ); + } final storedResult = declaredCheckpoint?.encodeValue(result) ?? result; await runtime._store.saveStep(runId, checkpointName, storedResult); diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart index 1aa68c70..b3cf092c 100644 --- a/packages/stem/test/unit/workflow/workflow_resume_test.dart +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -84,6 +84,39 @@ void main() { expect(resumedContext.takeControl(), isNull); }); + test('FlowContext.sleepFor uses named args and throws suspension signal', () { + final firstContext = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + ); + + expect( + () => firstContext.sleepFor(duration: const Duration(seconds: 1)), + throwsA(isA()), + ); + expect(firstContext.takeControl()?.type, FlowControlType.sleep); + + final resumedContext = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + resumeData: true, + ); + + expect( + resumedContext.sleepFor(duration: const Duration(seconds: 1)), + completes, + ); + expect(resumedContext.takeControl(), isNull); + }); + test( 'FlowContext.waitForEventValue registers watcher then decodes payload', () { @@ -163,6 +196,95 @@ void main() { expect(resumed?.message, 'approved'); }); + test( + 'FlowContext.waitForEventRefValue uses named args and resumes with payload', + () { + const event = WorkflowEventRef<_ResumePayload>( + topic: 'demo.event', + codec: _resumePayloadCodec, + ); + final waiting = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + ); + + expect( + () => waiting.waitForEventRefValue(event: event), + throwsA(isA()), + ); + expect(waiting.takeControl()?.topic, 'demo.event'); + + final resumed = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + resumeData: const {'message': 'approved'}, + ); + + expect( + resumed.waitForEventRefValue(event: event), + completion( + isA<_ResumePayload>().having( + (value) => value.message, + 'message', + 'approved', + ), + ), + ); + }, + ); + + test('FlowContext.waitForEvent uses named args and resumes with payload', () { + final waiting = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + ); + + expect( + () => waiting.waitForEvent<_ResumePayload>( + topic: 'demo.event', + codec: _resumePayloadCodec, + ), + throwsA(isA()), + ); + expect(waiting.takeControl()?.topic, 'demo.event'); + + final resumed = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + resumeData: const {'message': 'approved'}, + ); + + expect( + resumed.waitForEvent<_ResumePayload>( + topic: 'demo.event', + codec: _resumePayloadCodec, + ), + completion( + isA<_ResumePayload>().having( + (value) => value.message, + 'message', + 'approved', + ), + ), + ); + }); + test('FlowContext.awaitEventRef reuses the event topic', () { const event = WorkflowEventRef<_ResumePayload>( topic: 'demo.event', @@ -230,6 +352,53 @@ void main() { }, ); + test( + 'WorkflowScriptStepContext expression helpers use named args and ' + 'throw suspension signal', + () { + final sleeping = _FakeWorkflowScriptStepContext(); + expect( + sleeping.sleepFor(duration: const Duration(milliseconds: 10)), + throwsA(isA()), + ); + expect(sleeping.sleepCalls, [const Duration(milliseconds: 10)]); + + final resumedSleep = _FakeWorkflowScriptStepContext(resumeData: true); + expect( + resumedSleep.sleepFor(duration: const Duration(milliseconds: 10)), + completes, + ); + expect(resumedSleep.sleepCalls, isEmpty); + + final waiting = _FakeWorkflowScriptStepContext(); + expect( + waiting.waitForEvent<_ResumePayload>( + topic: 'demo.event', + codec: _resumePayloadCodec, + ), + throwsA(isA()), + ); + expect(waiting.awaitedTopics, ['demo.event']); + + final resumedEvent = _FakeWorkflowScriptStepContext( + resumeData: const {'message': 'approved'}, + ); + expect( + resumedEvent.waitForEvent<_ResumePayload>( + topic: 'demo.event', + codec: _resumePayloadCodec, + ), + completion( + isA<_ResumePayload>().having( + (value) => value.message, + 'message', + 'approved', + ), + ), + ); + }, + ); + test('WorkflowScriptStepContext.waitForEventRef reuses topic and codec', () { const event = WorkflowEventRef<_ResumePayload>( topic: 'demo.event', @@ -247,6 +416,38 @@ void main() { expect(resumedValue?.message, 'approved'); }); + test( + 'WorkflowScriptStepContext.waitForEventRefValue uses named args and ' + 'resumes with payload', + () { + const event = WorkflowEventRef<_ResumePayload>( + topic: 'demo.event', + codec: _resumePayloadCodec, + ); + final waiting = _FakeWorkflowScriptStepContext(); + + expect( + waiting.waitForEventRefValue(event: event), + throwsA(isA()), + ); + expect(waiting.awaitedTopics, ['demo.event']); + + final resumed = _FakeWorkflowScriptStepContext( + resumeData: const {'message': 'approved'}, + ); + expect( + resumed.waitForEventRefValue(event: event), + completion( + isA<_ResumePayload>().having( + (value) => value.message, + 'message', + 'approved', + ), + ), + ); + }, + ); + test( 'WorkflowScriptStepContext.awaitEventRef reuses the event topic', () async { diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index 6667fea8..0c1186f0 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -518,6 +518,46 @@ void main() { expect(completed?.result, 'resumed'); }); + test('sleepFor suspends and resumes without manual guards', () async { + runtime.registerWorkflow( + Flow( + name: 'sleep.expression.workflow', + build: (flow) { + flow + ..step('wait', (context) async { + await context.sleepFor( + duration: const Duration(milliseconds: 20), + ); + return 'slept'; + }) + ..step( + 'complete', + (context) async => '${context.previousResult}-done', + ); + }, + ).definition, + ); + + final runId = await runtime.startWorkflow('sleep.expression.workflow'); + await runtime.executeRun(runId); + + final suspended = await store.get(runId); + expect(suspended?.status, WorkflowStatus.suspended); + expect(suspended?.resumeAt, isNotNull); + + clock.advance(const Duration(milliseconds: 30)); + final due = await store.dueRuns(clock.now()); + for (final id in due) { + final state = await store.get(id); + await store.markResumed(id, data: state?.suspensionData); + await runtime.executeRun(id); + } + + final completed = await store.get(runId); + expect(completed?.status, WorkflowStatus.completed); + expect(completed?.result, 'slept-done'); + }); + test('awaitEvent suspends and resumes with payload', () async { String? observedPayload; @@ -554,6 +594,40 @@ void main() { expect(observedPayload, 'user-123'); }); + test('waitForEvent suspends and resumes with payload', () async { + String? observedPayload; + + runtime.registerWorkflow( + Flow( + name: 'event.expression.workflow', + build: (flow) { + flow.step('wait', (context) async { + final payload = await context.waitForEvent>( + topic: 'user.updated.expression', + ); + observedPayload = payload['id'] as String?; + return payload['id']; + }); + }, + ).definition, + ); + + final runId = await runtime.startWorkflow('event.expression.workflow'); + await runtime.executeRun(runId); + + final suspended = await store.get(runId); + expect(suspended?.status, WorkflowStatus.suspended); + expect(suspended?.waitTopic, 'user.updated.expression'); + + await runtime.emit('user.updated.expression', const {'id': 'user-789'}); + await runtime.executeRun(runId); + + final completed = await store.get(runId); + expect(completed?.status, WorkflowStatus.completed); + expect(observedPayload, 'user-789'); + expect(completed?.result, 'user-789'); + }); + test('emitValue resumes flows with codec-backed DTO payloads', () async { _UserUpdatedEvent? observedPayload; @@ -1018,6 +1092,44 @@ void main() { expect(completed?.result, 'user-42'); }); + test('script waitForEvent uses named args and resumes with payload', () async { + Map? resumePayload; + + runtime.registerWorkflow( + WorkflowScript( + name: 'script.event.expression', + run: (script) async { + final result = await script.step('wait', (step) async { + final payload = await step.waitForEvent>( + topic: 'user.updated.expression.script', + ); + resumePayload = payload; + return payload['id']; + }); + return result; + }, + ).definition, + ); + + final runId = await runtime.startWorkflow('script.event.expression'); + await runtime.executeRun(runId); + + final suspended = await store.get(runId); + expect(suspended?.status, WorkflowStatus.suspended); + expect(suspended?.waitTopic, 'user.updated.expression.script'); + + await runtime.emit( + 'user.updated.expression.script', + const {'id': 'user-43'}, + ); + await runtime.executeRun(runId); + + final completed = await store.get(runId); + expect(completed?.status, WorkflowStatus.completed); + expect(resumePayload?['id'], 'user-43'); + expect(completed?.result, 'user-43'); + }); + test('script autoVersion step persists sequential checkpoints', () async { final iterations = []; From 88b43f7896f4e4cc5ada9496efd96a438ab54aca Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 04:40:54 -0500 Subject: [PATCH 135/302] Add direct workflow event wait helpers --- .../docs/workflows/suspensions-and-events.md | 5 +- packages/stem/CHANGELOG.md | 3 + packages/stem/README.md | 8 +- packages/stem/example/durable_watchers.dart | 4 +- .../example/workflows/sleep_and_event.dart | 4 +- .../src/workflow/core/workflow_resume.dart | 59 ++++++++++++ .../unit/workflow/workflow_resume_test.dart | 91 +++++++++++++++++++ .../workflow/workflow_runtime_ref_test.dart | 9 +- 8 files changed, 166 insertions(+), 17 deletions(-) diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index 416c3c48..0182d622 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -32,7 +32,7 @@ Typical flow: `WorkflowRuntime.emitValue(...)` (or an app/service wrapper around it) with a payload 4. the runtime resumes the run and exposes the payload through - `waitForEvent(...)`, `waitForEventRefValue(...)`, or the lower-level + `waitForEvent(...)`, `event.waitWith(ctx)`, or the lower-level `takeResumeData()` / `takeResumeValue(codec: ...)` For the common "wait for one event and continue" case, prefer: @@ -64,8 +64,7 @@ When the topic and codec travel together in your codebase, prefer a typed `WorkflowEventRef` and `event.emitWith(emitter, dto)` as the happy path. `emitter.emitEventBuilder(event: ref, value: dto).emit()` and `event.call(value).emitWith(...)` remain available as lower-level variants. -Pair that with `await ctx.waitForEventRefValue(event: ref)` or -`awaitEventRef(...)`. +Pair that with `await event.waitWith(ctx)` or `awaitEventRef(...)`. ## Inspect waiting runs diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index f284b330..59287b5d 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -7,6 +7,9 @@ `await ctx.waitForEvent(topic: ...)`, and `await ctx.waitForEventRefValue(event: ...)` for both flow steps and script checkpoints. +- Added direct typed workflow event wait helpers: + `await event.waitWith(ctx)` and `event.waitValueWith(ctx)` so typed workflow + events now stay on the ref surface for both emit and wait paths. - Added `StemModule.combine(...)` plus `modules:` support across `StemApp`, `StemWorkflowApp`, and `StemClient` bootstrap helpers so multi-module apps no longer need to pre-merge bundles manually at every call site. diff --git a/packages/stem/README.md b/packages/stem/README.md index 6b8e7872..8c3df210 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -422,6 +422,8 @@ Inside a script checkpoint you can access the same metadata as `FlowContext`: - `await step.sleepFor(duration: ...)` is the expression-style sleep path. - `await step.waitForEvent(topic: ..., codec: ...)` is the expression-style event wait path. +- `await event.waitWith(step)` keeps typed event waits on the + `WorkflowEventRef` surface. - `step.sleepUntilResumed(...)` handles the common sleep-once, continue-on- resume path. - `step.waitForEventValue(...)` handles the common wait-for-one-event path. @@ -1094,9 +1096,9 @@ backend metadata under `stem.unique.duplicates`. `WorkflowEventRef` and use `event.emitWith(emitter, dto)` as the happy path, with `emitter.emitEventBuilder(event: ref, value: dto).emit()` and `event.call(value).emitWith(...)` still available as lower-level variants. - Pair that with `await ctx.waitForEventRefValue(event: ref)` or - `awaitEventRef(...)`. Event payloads still serialize onto the existing - `Map` wire format. + Pair that with `await event.waitWith(ctx)` or `awaitEventRef(...)`. Event + payloads still serialize onto the existing `Map` wire + format. - Only return values you want persisted. If a handler returns `null`, the runtime treats it as "no result yet" and will run the step again on resume. - Derive outbound idempotency tokens with `ctx.idempotencyKey('charge')` so diff --git a/packages/stem/example/durable_watchers.dart b/packages/stem/example/durable_watchers.dart index bb2be731..0fcc8383 100644 --- a/packages/stem/example/durable_watchers.dart +++ b/packages/stem/example/durable_watchers.dart @@ -21,8 +21,8 @@ Future main() async { }); final trackingId = await script.step('wait-for-shipment', (step) async { - final payload = await step.waitForEventRefValue( - event: shipmentReadyEvent, + final payload = await shipmentReadyEvent.waitWith( + step, deadline: DateTime.now().add(const Duration(minutes: 5)), data: const {'reason': 'waiting-for-carrier'}, ); diff --git a/packages/stem/example/workflows/sleep_and_event.dart b/packages/stem/example/workflows/sleep_and_event.dart index 5efdd3fe..86f860f4 100644 --- a/packages/stem/example/workflows/sleep_and_event.dart +++ b/packages/stem/example/workflows/sleep_and_event.dart @@ -20,9 +20,7 @@ Future main() async { return 'awake'; }) ..step('await-event', (ctx) async { - final payload = await ctx.waitForEvent>( - topic: demoEvent.topic, - ); + final payload = await demoEvent.waitWith(ctx); return payload['message']; }); }, diff --git a/packages/stem/lib/src/workflow/core/workflow_resume.dart b/packages/stem/lib/src/workflow/core/workflow_resume.dart index 6c7f7ccc..24b085c9 100644 --- a/packages/stem/lib/src/workflow/core/workflow_resume.dart +++ b/packages/stem/lib/src/workflow/core/workflow_resume.dart @@ -273,3 +273,62 @@ extension WorkflowScriptStepResumeValues on WorkflowScriptStepContext { ); } } + +/// Direct typed wait helpers on [WorkflowEventRef]. +/// +/// These mirror `event.emitWith(...)` so typed workflow events can stay on the +/// event-ref surface for both emit and wait paths. +extension WorkflowEventRefWaitExtension on WorkflowEventRef { + /// Registers an event wait and returns the resumed payload on the legacy + /// null-then-resume path. + /// + /// [waiter] must be a [FlowContext] or [WorkflowScriptStepContext]. + T? waitValueWith( + Object waiter, { + DateTime? deadline, + Map? data, + }) { + if (waiter case final FlowContext context) { + return context.waitForEventRef(this, deadline: deadline, data: data); + } + if (waiter case final WorkflowScriptStepContext context) { + return context.waitForEventRef(this, deadline: deadline, data: data); + } + throw ArgumentError.value( + waiter, + 'waiter', + 'WorkflowEventRef.waitValueWith requires a FlowContext or ' + 'WorkflowScriptStepContext.', + ); + } + + /// Suspends until this event is emitted, then returns the decoded payload. + /// + /// [waiter] must be a [FlowContext] or [WorkflowScriptStepContext]. + Future waitWith( + Object waiter, { + DateTime? deadline, + Map? data, + }) { + if (waiter case final FlowContext context) { + return context.waitForEventRefValue( + event: this, + deadline: deadline, + data: data, + ); + } + if (waiter case final WorkflowScriptStepContext context) { + return context.waitForEventRefValue( + event: this, + deadline: deadline, + data: data, + ); + } + throw ArgumentError.value( + waiter, + 'waiter', + 'WorkflowEventRef.waitWith requires a FlowContext or ' + 'WorkflowScriptStepContext.', + ); + } +} diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart index b3cf092c..dc55c160 100644 --- a/packages/stem/test/unit/workflow/workflow_resume_test.dart +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -448,6 +448,97 @@ void main() { }, ); + test( + 'WorkflowEventRef.waitValueWith delegates to both flow and script ' + 'contexts', + () { + const event = WorkflowEventRef<_ResumePayload>( + topic: 'demo.event', + codec: _resumePayloadCodec, + ); + + final flowWaiting = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + ); + expect(event.waitValueWith(flowWaiting), isNull); + expect(flowWaiting.takeControl()?.topic, 'demo.event'); + + final flowResumed = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + resumeData: const {'message': 'approved'}, + ); + expect(event.waitValueWith(flowResumed)?.message, 'approved'); + + final scriptWaiting = _FakeWorkflowScriptStepContext(); + expect(event.waitValueWith(scriptWaiting), isNull); + expect(scriptWaiting.awaitedTopics, ['demo.event']); + }, + ); + + test( + 'WorkflowEventRef.waitWith delegates to both flow and script contexts', + () { + const event = WorkflowEventRef<_ResumePayload>( + topic: 'demo.event', + codec: _resumePayloadCodec, + ); + + final flowWaiting = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + ); + expect( + () => event.waitWith(flowWaiting), + throwsA(isA()), + ); + expect(flowWaiting.takeControl()?.topic, 'demo.event'); + + final scriptResumed = _FakeWorkflowScriptStepContext( + resumeData: const {'message': 'approved'}, + ); + expect( + event.waitWith(scriptResumed), + completion( + isA<_ResumePayload>().having( + (value) => value.message, + 'message', + 'approved', + ), + ), + ); + }, + ); + + test('WorkflowEventRef wait helpers reject unsupported waiter types', () { + const event = WorkflowEventRef<_ResumePayload>( + topic: 'demo.event', + codec: _resumePayloadCodec, + ); + + expect( + () => event.waitValueWith('invalid'), + throwsA(isA()), + ); + expect( + () => event.waitWith('invalid'), + throwsA(isA()), + ); + }); + test( 'WorkflowScriptStepContext.awaitEventRef reuses the event topic', () async { diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 31bb57f8..324cb05a 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -371,7 +371,7 @@ void main() { name: 'runtime.ref.event.flow', build: (builder) { builder.step('wait', (ctx) async { - final payload = ctx.waitForEventRef(_userUpdatedEvent); + final payload = _userUpdatedEvent.waitValueWith(ctx); if (payload == null) { return null; } @@ -411,10 +411,7 @@ void main() { name: 'runtime.ref.event.call.flow', build: (builder) { builder.step('wait', (ctx) async { - final payload = ctx.waitForEventRef(_userUpdatedEvent); - if (payload == null) { - return null; - } + final payload = await _userUpdatedEvent.waitWith(ctx); return 'hello ${payload.name}'; }); }, @@ -449,7 +446,7 @@ void main() { name: 'runtime.ref.event.bound.flow', build: (builder) { builder.step('wait', (ctx) async { - final payload = ctx.waitForEventRef(_userUpdatedEvent); + final payload = _userUpdatedEvent.waitValueWith(ctx); if (payload == null) { return null; } From 7e51ea4d7a8c6f4eaa7dbaba94337dc1a7bd01b2 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 04:49:22 -0500 Subject: [PATCH 136/302] Add map payload codec helpers --- .../workflows/context-and-serialization.md | 3 + .site/docs/workflows/starting-and-waiting.md | 7 +- packages/stem/CHANGELOG.md | 3 + packages/stem/README.md | 14 ++- .../lib/definitions.stem.g.dart | 59 ++++------- .../stem/example/docs_snippets/lib/tasks.dart | 9 +- .../example/docs_snippets/lib/workflows.dart | 9 +- packages/stem/example/durable_watchers.dart | 3 +- packages/stem/lib/src/core/payload_codec.dart | 64 +++++++++++- .../test/unit/core/payload_codec_test.dart | 97 +++++++++++++++++++ .../stem/test/unit/core/stem_core_test.dart | 12 +-- .../workflow/workflow_runtime_ref_test.dart | 20 ++-- packages/stem_builder/CHANGELOG.md | 3 + .../lib/src/stem_registry_builder.dart | 34 +------ .../test/stem_registry_builder_test.dart | 15 ++- 15 files changed, 231 insertions(+), 121 deletions(-) create mode 100644 packages/stem/test/unit/core/payload_codec_test.dart diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index 553b495f..cd35eeb1 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -106,6 +106,9 @@ typed DTO plus a `PayloadCodec`, but the codec must still encode to a `Map` because watcher persistence and event delivery are map-based today. +For map-shaped DTOs, prefer `PayloadCodec.map(...)` over hand-written +`Object?` decode wrappers. + ## Practical rule When you need context metadata, add the appropriate optional named context diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 2ad2d2da..2e90ba1a 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -24,11 +24,10 @@ Manual `Flow(...)` and `WorkflowScript(...)` definitions can derive a typed ref without repeating the workflow-name string: ```dart -const approvalDraftCodec = PayloadCodec( +const approvalDraftCodec = PayloadCodec.map( encode: (value) => value.toJson(), - decode: (payload) => ApprovalDraft.fromJson( - Map.from(payload as Map), - ), + decode: ApprovalDraft.fromJson, + typeName: 'ApprovalDraft', ); final approvalsRef = approvalsFlow.refWithCodec( diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 59287b5d..a44ba165 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Added `PayloadCodec.map(...)` so map-shaped workflow/task DTO codecs no + longer need handwritten `Object?` decode wrappers, and refreshed the public + typed payload docs/examples around the new helper. - Added expression-style workflow suspension helpers with named arguments: `await ctx.sleepFor(duration: ...)`, `await ctx.waitForEvent(topic: ...)`, and diff --git a/packages/stem/README.md b/packages/stem/README.md index 8c3df210..ab96aafb 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -193,11 +193,10 @@ class HelloArgs { } } -const helloArgsCodec = PayloadCodec( +const helloArgsCodec = PayloadCodec.map( encode: (value) => value.toJson(), - decode: (payload) => HelloArgs.fromJson( - Map.from(payload! as Map), - ), + decode: HelloArgs.fromJson, + typeName: 'HelloArgs', ); Future main() async { @@ -483,11 +482,10 @@ final approvalsFlow = Flow( }, ); -const approvalDraftCodec = PayloadCodec( +const approvalDraftCodec = PayloadCodec.map( encode: (value) => value.toJson(), - decode: (payload) => ApprovalDraft.fromJson( - Map.from(payload as Map), - ), + decode: ApprovalDraft.fromJson, + typeName: 'ApprovalDraft', ); final approvalsRef = approvalsFlow.refWithCodec( diff --git a/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart b/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart index a3a4d7be..fe15adb5 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart @@ -3,65 +3,42 @@ part of 'definitions.dart'; -Map _stemPayloadMap(Object? value, String typeName) { - if (value is Map) { - return Map.from(value); - } - if (value is Map) { - final result = {}; - value.forEach((key, entry) { - if (key is! String) { - throw StateError('$typeName payload must use string keys.'); - } - result[key] = entry; - }); - return result; - } - throw StateError( - '$typeName payload must decode to Map, got ${value.runtimeType}.', - ); -} - abstract final class StemPayloadCodecs { static final PayloadCodec welcomeWorkflowResult = - PayloadCodec( + PayloadCodec.map( encode: (value) => value.toJson(), - decode: (payload) => WelcomeWorkflowResult.fromJson( - _stemPayloadMap(payload, "WelcomeWorkflowResult"), - ), + decode: WelcomeWorkflowResult.fromJson, + typeName: "WelcomeWorkflowResult", ); static final PayloadCodec welcomeRequest = - PayloadCodec( + PayloadCodec.map( encode: (value) => value.toJson(), - decode: (payload) => - WelcomeRequest.fromJson(_stemPayloadMap(payload, "WelcomeRequest")), + decode: WelcomeRequest.fromJson, + typeName: "WelcomeRequest", ); static final PayloadCodec welcomePreparation = - PayloadCodec( + PayloadCodec.map( encode: (value) => value.toJson(), - decode: (payload) => WelcomePreparation.fromJson( - _stemPayloadMap(payload, "WelcomePreparation"), - ), + decode: WelcomePreparation.fromJson, + typeName: "WelcomePreparation", ); static final PayloadCodec contextCaptureResult = - PayloadCodec( + PayloadCodec.map( encode: (value) => value.toJson(), - decode: (payload) => ContextCaptureResult.fromJson( - _stemPayloadMap(payload, "ContextCaptureResult"), - ), + decode: ContextCaptureResult.fromJson, + typeName: "ContextCaptureResult", ); static final PayloadCodec emailDispatch = - PayloadCodec( + PayloadCodec.map( encode: (value) => value.toJson(), - decode: (payload) => - EmailDispatch.fromJson(_stemPayloadMap(payload, "EmailDispatch")), + decode: EmailDispatch.fromJson, + typeName: "EmailDispatch", ); static final PayloadCodec emailDeliveryReceipt = - PayloadCodec( + PayloadCodec.map( encode: (value) => value.toJson(), - decode: (payload) => EmailDeliveryReceipt.fromJson( - _stemPayloadMap(payload, "EmailDeliveryReceipt"), - ), + decode: EmailDeliveryReceipt.fromJson, + typeName: "EmailDeliveryReceipt", ); } diff --git a/packages/stem/example/docs_snippets/lib/tasks.dart b/packages/stem/example/docs_snippets/lib/tasks.dart index 5e502c49..fc4c04e2 100644 --- a/packages/stem/example/docs_snippets/lib/tasks.dart +++ b/packages/stem/example/docs_snippets/lib/tasks.dart @@ -58,17 +58,14 @@ class InvoicePayload { } } -const invoicePayloadCodec = PayloadCodec( +const invoicePayloadCodec = PayloadCodec.map( encode: _encodeInvoicePayload, - decode: _decodeInvoicePayload, + decode: InvoicePayload.fromJson, + typeName: 'InvoicePayload', ); Object? _encodeInvoicePayload(InvoicePayload value) => value.toJson(); -InvoicePayload _decodeInvoicePayload(Object? payload) { - return InvoicePayload.fromJson(Map.from(payload! as Map)); -} - class PublishInvoiceTask extends TaskHandler { static final definition = TaskDefinition.withPayloadCodec( diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index a859ad0c..49f04edd 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -17,17 +17,14 @@ class ApprovalDraft { } } -const approvalDraftCodec = PayloadCodec( +const approvalDraftCodec = PayloadCodec.map( encode: _encodeApprovalDraft, - decode: _decodeApprovalDraft, + decode: ApprovalDraft.fromJson, + typeName: 'ApprovalDraft', ); Object? _encodeApprovalDraft(ApprovalDraft value) => value.toJson(); -ApprovalDraft _decodeApprovalDraft(Object? payload) { - return ApprovalDraft.fromJson(Map.from(payload as Map)); -} - // #region workflows-runtime Future bootstrapWorkflowApp() async { // #region workflows-app-create diff --git a/packages/stem/example/durable_watchers.dart b/packages/stem/example/durable_watchers.dart index 0fcc8383..e94d63e9 100644 --- a/packages/stem/example/durable_watchers.dart +++ b/packages/stem/example/durable_watchers.dart @@ -1,8 +1,9 @@ import 'package:stem/stem.dart'; -final shipmentReadyEventCodec = PayloadCodec<_ShipmentReadyEvent>( +final shipmentReadyEventCodec = PayloadCodec<_ShipmentReadyEvent>.map( encode: (value) => value.toJson(), decode: _ShipmentReadyEvent.fromJson, + typeName: '_ShipmentReadyEvent', ); final shipmentReadyEvent = WorkflowEventRef<_ShipmentReadyEvent>( topic: 'shipment.ready', diff --git a/packages/stem/lib/src/core/payload_codec.dart b/packages/stem/lib/src/core/payload_codec.dart index 813f6d64..4c63bcdd 100644 --- a/packages/stem/lib/src/core/payload_codec.dart +++ b/packages/stem/lib/src/core/payload_codec.dart @@ -6,13 +6,50 @@ import 'package:stem/src/core/task_payload_encoder.dart'; /// lower richer Dart DTOs into the existing durable wire format. class PayloadCodec { /// Creates a payload codec from explicit encode/decode callbacks. - const PayloadCodec({required this.encode, required this.decode}); + const PayloadCodec({ + required Object? Function(T value) encode, + required T Function(Object? payload) decode, + }) : _encode = encode, + _decode = decode, + _decodeMap = null, + _typeName = null; + + /// Creates a payload codec for DTOs that serialize to a durable map payload. + /// + /// This is the common author-facing case for workflow/task DTOs: + /// + /// ```dart + /// const approvalCodec = PayloadCodec.map( + /// encode: (value) => value.toJson(), + /// decode: Approval.fromJson, + /// ); + /// ``` + const PayloadCodec.map({ + required Object? Function(T value) encode, + required T Function(Map payload) decode, + String? typeName, + }) : _encode = encode, + _decode = null, + _decodeMap = decode, + _typeName = typeName; + + final Object? Function(T value) _encode; + final T Function(Object? payload)? _decode; + final T Function(Map payload)? _decodeMap; + final String? _typeName; /// Converts a typed value into a durable payload representation. - final Object? Function(T value) encode; + Object? encode(T value) => _encode(value); /// Reconstructs a typed value from a durable payload representation. - final T Function(Object? payload) decode; + T decode(Object? payload) { + final decode = _decode; + if (decode != null) { + return decode(payload); + } + final decodeMap = _decodeMap!; + return decodeMap(_payloadMap(payload, _typeName ?? '$T')); + } /// Converts an erased author-facing value into a durable payload. Object? encodeDynamic(Object? value) { @@ -27,6 +64,27 @@ class PayloadCodec { } } +Map _payloadMap(Object? value, String typeName) { + if (value is Map) { + return Map.from(value); + } + if (value is Map) { + final result = {}; + for (final entry in value.entries) { + final key = entry.key; + if (key is! String) { + throw StateError('$typeName payload must use string keys.'); + } + result[key] = entry.value; + } + return result; + } + throw StateError( + '$typeName payload must decode to Map, got ' + '${value.runtimeType}.', + ); +} + /// Bridges a [PayloadCodec] into the existing [TaskPayloadEncoder] contract. class CodecTaskPayloadEncoder extends TaskPayloadEncoder { /// Creates a task payload encoder backed by a typed [codec]. diff --git a/packages/stem/test/unit/core/payload_codec_test.dart b/packages/stem/test/unit/core/payload_codec_test.dart new file mode 100644 index 00000000..05f5c1a5 --- /dev/null +++ b/packages/stem/test/unit/core/payload_codec_test.dart @@ -0,0 +1,97 @@ +import 'package:stem/stem.dart'; +import 'package:test/test.dart'; + +void main() { + group('PayloadCodec.map', () { + test('decodes typed DTO payloads from durable maps', () { + const codec = PayloadCodec<_CodecPayload>.map( + encode: _encodeCodecPayload, + decode: _CodecPayload.fromJson, + typeName: '_CodecPayload', + ); + + final decoded = codec.decode({ + 'id': 'payload-1', + 'count': 3, + }); + + expect(decoded.id, 'payload-1'); + expect(decoded.count, 3); + }); + + test('normalizes generic map payloads before decoding', () { + const codec = PayloadCodec<_CodecPayload>.map( + encode: _encodeCodecPayload, + decode: _CodecPayload.fromJson, + typeName: '_CodecPayload', + ); + + final decoded = codec.decode({ + 'id': 'payload-2', + 'count': 7, + }); + + expect(decoded.id, 'payload-2'); + expect(decoded.count, 7); + }); + + test('rejects non-map payloads with a clear error', () { + const codec = PayloadCodec<_CodecPayload>.map( + encode: _encodeCodecPayload, + decode: _CodecPayload.fromJson, + typeName: '_CodecPayload', + ); + + expect( + () => codec.decode('not-a-map'), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('_CodecPayload payload must decode to Map'), + ), + ), + ); + }); + + test('rejects non-string map keys with a clear error', () { + const codec = PayloadCodec<_CodecPayload>.map( + encode: _encodeCodecPayload, + decode: _CodecPayload.fromJson, + typeName: '_CodecPayload', + ); + + expect( + () => codec.decode({1: 'bad'}), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('_CodecPayload payload must use string keys.'), + ), + ), + ); + }); + }); +} + +class _CodecPayload { + const _CodecPayload({required this.id, required this.count}); + + factory _CodecPayload.fromJson(Map json) { + return _CodecPayload( + id: json['id']! as String, + count: json['count']! as int, + ); + } + + final String id; + final int count; + + Map toJson() => { + 'id': id, + 'count': count, + }; +} + +Object? _encodeCodecPayload(_CodecPayload value) => value.toJson(); diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index a0899cbd..c489b73c 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -578,9 +578,10 @@ class _CodecReceipt { Map toJson() => {'id': id}; } -const _codecReceiptCodec = PayloadCodec<_CodecReceipt>( +const _codecReceiptCodec = PayloadCodec<_CodecReceipt>.map( encode: _encodeCodecReceipt, - decode: _decodeCodecReceipt, + decode: _CodecReceipt.fromJson, + typeName: '_CodecReceipt', ); const _codecReceiptEncoder = CodecTaskPayloadEncoder<_CodecReceipt>( @@ -599,10 +600,6 @@ final _codecReceiptDefinition = Object? _encodeCodecReceipt(_CodecReceipt value) => value.toJson(); -_CodecReceipt _decodeCodecReceipt(Object? payload) { - return _CodecReceipt.fromJson(Map.from(payload! as Map)); -} - class _CodecTaskArgs { const _CodecTaskArgs(this.value); @@ -611,9 +608,10 @@ class _CodecTaskArgs { Map toJson() => {'value': value}; } -const _codecTaskArgsCodec = PayloadCodec<_CodecTaskArgs>( +const _codecTaskArgsCodec = PayloadCodec<_CodecTaskArgs>.map( encode: _encodeCodecTaskArgs, decode: _decodeCodecTaskArgs, + typeName: '_CodecTaskArgs', ); Object? _encodeCodecTaskArgs(_CodecTaskArgs value) => value.toJson(); diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 324cb05a..6a62b94e 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -25,14 +25,16 @@ class _GreetingResult { Map toJson() => {'message': message}; } -const _greetingParamsCodec = PayloadCodec<_GreetingParams>( +const _greetingParamsCodec = PayloadCodec<_GreetingParams>.map( encode: _encodeGreetingParams, - decode: _decodeGreetingParams, + decode: _GreetingParams.fromJson, + typeName: '_GreetingParams', ); -const _greetingResultCodec = PayloadCodec<_GreetingResult>( +const _greetingResultCodec = PayloadCodec<_GreetingResult>.map( encode: _encodeGreetingResult, - decode: _decodeGreetingResult, + decode: _GreetingResult.fromJson, + typeName: '_GreetingResult', ); const _userUpdatedEvent = WorkflowEventRef<_GreetingParams>( @@ -42,18 +44,8 @@ const _userUpdatedEvent = WorkflowEventRef<_GreetingParams>( Object? _encodeGreetingParams(_GreetingParams value) => value.toJson(); -_GreetingParams _decodeGreetingParams(Object? payload) { - return _GreetingParams.fromJson( - Map.from(payload! as Map), - ); -} - Object? _encodeGreetingResult(_GreetingResult value) => value.toJson(); -_GreetingResult _decodeGreetingResult(Object? payload) { - return _GreetingResult.fromJson(Map.from(payload! as Map)); -} - void main() { group('runtime workflow refs', () { test('start and wait helpers work directly with WorkflowRuntime', () async { diff --git a/packages/stem_builder/CHANGELOG.md b/packages/stem_builder/CHANGELOG.md index 185a9323..1e8c364d 100644 --- a/packages/stem_builder/CHANGELOG.md +++ b/packages/stem_builder/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.0 +- Switched generated DTO payload codecs to `PayloadCodec.map(...)`, removing + the old emitted map-normalization helper and the need for handwritten + `Object?` decode wrappers in generated output. - Updated the builder docs and annotated workflow example to prefer direct child-workflow helpers like `ref.startAndWaitWith(context, value)` in durable boundaries, leaving caller-bound builders for advanced overrides. diff --git a/packages/stem_builder/lib/src/stem_registry_builder.dart b/packages/stem_builder/lib/src/stem_registry_builder.dart index 2bdac8a6..da727235 100644 --- a/packages/stem_builder/lib/src/stem_registry_builder.dart +++ b/packages/stem_builder/lib/src/stem_registry_builder.dart @@ -1351,42 +1351,18 @@ class _RegistryEmitter { if (payloadCodecSymbols.isEmpty) { return; } - buffer.writeln('Map _stemPayloadMap('); - buffer.writeln(' Object? value,'); - buffer.writeln(' String typeName,'); - buffer.writeln(') {'); - buffer.writeln(' if (value is Map) {'); - buffer.writeln(' return Map.from(value);'); - buffer.writeln(' }'); - buffer.writeln(' if (value is Map) {'); - buffer.writeln(' final result = {};'); - buffer.writeln(' value.forEach((key, entry) {'); - buffer.writeln(' if (key is! String) {'); - buffer.writeln( - r" throw StateError('$typeName payload must use string keys.');", - ); - buffer.writeln(' }'); - buffer.writeln(' result[key] = entry;'); - buffer.writeln(' });'); - buffer.writeln(' return result;'); - buffer.writeln(' }'); - buffer.writeln( - r" throw StateError('$typeName payload must decode to Map, got ${value.runtimeType}.');", - ); - buffer.writeln('}'); - buffer.writeln(); - buffer.writeln('abstract final class StemPayloadCodecs {'); for (final entry in payloadCodecSymbols.entries) { final typeCode = entry.key; final symbol = entry.value; buffer.writeln(' static final PayloadCodec<$typeCode> $symbol ='); - buffer.writeln(' PayloadCodec<$typeCode>('); + buffer.writeln(' PayloadCodec<$typeCode>.map('); buffer.writeln(' encode: (value) => value.toJson(),'); buffer.writeln( - ' decode: (payload) => $typeCode.fromJson(' - ' _stemPayloadMap(payload, ${_string(typeCode)}),' - ' ),', + ' decode: $typeCode.fromJson,', + ); + buffer.writeln( + ' typeName: ${_string(typeCode)},', ); buffer.writeln(' );'); } diff --git a/packages/stem_builder/test/stem_registry_builder_test.dart b/packages/stem_builder/test/stem_registry_builder_test.dart index 11e5e168..a48ddaaf 100644 --- a/packages/stem_builder/test/stem_registry_builder_test.dart +++ b/packages/stem_builder/test/stem_registry_builder_test.dart @@ -12,9 +12,18 @@ typedef _FlowStepHandler = Future Function(FlowContext context); enum WorkflowStepKind { task, choice, parallel, wait, custom } class PayloadCodec { - const PayloadCodec({required this.encode, required this.decode}); + const PayloadCodec({required this.encode, required this.decode}) + : typeName = null; + const PayloadCodec.map({ + required this.encode, + required T Function(Map payload) decode, + this.typeName, + }) : decode = _unsupportedDecode; final Object? Function(T value) encode; final T Function(Object? payload) decode; + final String? typeName; + + static T _unsupportedDecode(Object? payload) => throw UnimplementedError(); } class FlowStep { @@ -1201,11 +1210,13 @@ Future dtoTask( allOf([ contains('abstract final class StemPayloadCodecs'), contains('PayloadCodec emailRequest ='), + contains('PayloadCodec.map('), contains( 'WorkflowRef script =', ), contains('encode: (value) => value.toJson(),'), - contains('EmailRequest.fromJson('), + contains('decode: EmailRequest.fromJson,'), + contains('typeName: "EmailRequest",'), contains( 'StemPayloadCodecs.emailRequest.encode(params)', ), From 86f489b55f92cd19e9c64a0dac73288e3e473bc7 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 04:57:45 -0500 Subject: [PATCH 137/302] Add json payload codec shortcut --- .site/docs/core-concepts/tasks.md | 8 +-- .../workflows/context-and-serialization.md | 5 +- .site/docs/workflows/starting-and-waiting.md | 3 +- packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 14 +++--- .../lib/definitions.stem.g.dart | 18 +++---- .../stem/example/docs_snippets/lib/tasks.dart | 5 +- .../example/docs_snippets/lib/workflows.dart | 5 +- packages/stem/example/durable_watchers.dart | 8 ++- packages/stem/lib/src/core/payload_codec.dart | 34 ++++++++++++- .../test/unit/core/payload_codec_test.dart | 49 +++++++++++++++++++ .../stem/test/unit/core/stem_core_test.dart | 5 +- .../workflow/workflow_runtime_ref_test.dart | 10 +--- .../test/workflow/workflow_runtime_test.dart | 7 ++- packages/stem_builder/CHANGELOG.md | 3 ++ .../lib/src/stem_registry_builder.dart | 3 +- .../test/stem_registry_builder_test.dart | 9 +++- 17 files changed, 129 insertions(+), 60 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 295faad8..025ef785 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -53,9 +53,11 @@ Typed results flow through `TaskResult` when you call lets you deserialize complex objects before they reach application code. If your manual task args are DTOs, prefer -`TaskDefinition.withPayloadCodec(...)` over hand-written `encodeArgs` maps. The -codec still needs to encode to `Map` because task args are -published as a map. +`TaskDefinition.withPayloadCodec(...)` over hand-written `encodeArgs` maps. +Prefer `PayloadCodec.json(...)` when the type already has `toJson()` and +`Type.fromJson(...)`, and use `PayloadCodec.map(...)` when you need a +custom map encoder. Task args still need to encode to `Map` +because they are published as a map. `TaskEnqueueBuilder` also supports `enqueueAndWait(...)`, and typed task definitions can now create a fluent builder directly through diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index cd35eeb1..ed763d9f 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -106,8 +106,9 @@ typed DTO plus a `PayloadCodec`, but the codec must still encode to a `Map` because watcher persistence and event delivery are map-based today. -For map-shaped DTOs, prefer `PayloadCodec.map(...)` over hand-written -`Object?` decode wrappers. +For normal DTOs that expose `toJson()` and `Type.fromJson(...)`, prefer +`PayloadCodec.json(...)`. Drop down to `PayloadCodec.map(...)` when you +need a custom map encoder or a nonstandard decode function. ## Practical rule diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 2e90ba1a..e2d2f036 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -24,8 +24,7 @@ Manual `Flow(...)` and `WorkflowScript(...)` definitions can derive a typed ref without repeating the workflow-name string: ```dart -const approvalDraftCodec = PayloadCodec.map( - encode: (value) => value.toJson(), +const approvalDraftCodec = PayloadCodec.json( decode: ApprovalDraft.fromJson, typeName: 'ApprovalDraft', ); diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index a44ba165..ad15908c 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Added `PayloadCodec.json(...)` as the shortest DTO helper for types that + already expose `toJson()` and `Type.fromJson(...)`, while keeping + `PayloadCodec.map(...)` for custom map encoders. - Added `PayloadCodec.map(...)` so map-shaped workflow/task DTO codecs no longer need handwritten `Object?` decode wrappers, and refreshed the public typed payload docs/examples around the new helper. diff --git a/packages/stem/README.md b/packages/stem/README.md index ab96aafb..6c35647d 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -193,8 +193,7 @@ class HelloArgs { } } -const helloArgsCodec = PayloadCodec.map( - encode: (value) => value.toJson(), +const helloArgsCodec = PayloadCodec.json( decode: HelloArgs.fromJson, typeName: 'HelloArgs', ); @@ -225,9 +224,11 @@ Future main() async { producer-only processes do not need to register the worker handler locally just to enqueue typed calls. -Use `TaskDefinition.withPayloadCodec(...)` when your manual task args are DTOs -that already have a `PayloadCodec`. The codec still needs to encode to -`Map` because task args are published as a map. +Use `TaskDefinition.withPayloadCodec(...)` when your manual task args are DTOs. +Prefer `PayloadCodec.json(...)` when the type already exposes `toJson()` and +`Type.fromJson(...)`, and drop down to `PayloadCodec.map(...)` only when +you need a custom map encoder. Task args still need to encode to +`Map` because they are published as a map. For typed task calls, the definition and call objects now expose the common producer operations directly. Prefer `enqueueAndWait(...)` when you only need @@ -482,8 +483,7 @@ final approvalsFlow = Flow( }, ); -const approvalDraftCodec = PayloadCodec.map( - encode: (value) => value.toJson(), +const approvalDraftCodec = PayloadCodec.json( decode: ApprovalDraft.fromJson, typeName: 'ApprovalDraft', ); diff --git a/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart b/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart index fe15adb5..cb5e3206 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart @@ -5,38 +5,32 @@ part of 'definitions.dart'; abstract final class StemPayloadCodecs { static final PayloadCodec welcomeWorkflowResult = - PayloadCodec.map( - encode: (value) => value.toJson(), + PayloadCodec.json( decode: WelcomeWorkflowResult.fromJson, typeName: "WelcomeWorkflowResult", ); static final PayloadCodec welcomeRequest = - PayloadCodec.map( - encode: (value) => value.toJson(), + PayloadCodec.json( decode: WelcomeRequest.fromJson, typeName: "WelcomeRequest", ); static final PayloadCodec welcomePreparation = - PayloadCodec.map( - encode: (value) => value.toJson(), + PayloadCodec.json( decode: WelcomePreparation.fromJson, typeName: "WelcomePreparation", ); static final PayloadCodec contextCaptureResult = - PayloadCodec.map( - encode: (value) => value.toJson(), + PayloadCodec.json( decode: ContextCaptureResult.fromJson, typeName: "ContextCaptureResult", ); static final PayloadCodec emailDispatch = - PayloadCodec.map( - encode: (value) => value.toJson(), + PayloadCodec.json( decode: EmailDispatch.fromJson, typeName: "EmailDispatch", ); static final PayloadCodec emailDeliveryReceipt = - PayloadCodec.map( - encode: (value) => value.toJson(), + PayloadCodec.json( decode: EmailDeliveryReceipt.fromJson, typeName: "EmailDeliveryReceipt", ); diff --git a/packages/stem/example/docs_snippets/lib/tasks.dart b/packages/stem/example/docs_snippets/lib/tasks.dart index fc4c04e2..14ad1e92 100644 --- a/packages/stem/example/docs_snippets/lib/tasks.dart +++ b/packages/stem/example/docs_snippets/lib/tasks.dart @@ -58,14 +58,11 @@ class InvoicePayload { } } -const invoicePayloadCodec = PayloadCodec.map( - encode: _encodeInvoicePayload, +const invoicePayloadCodec = PayloadCodec.json( decode: InvoicePayload.fromJson, typeName: 'InvoicePayload', ); -Object? _encodeInvoicePayload(InvoicePayload value) => value.toJson(); - class PublishInvoiceTask extends TaskHandler { static final definition = TaskDefinition.withPayloadCodec( diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index 49f04edd..8e17bb42 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -17,14 +17,11 @@ class ApprovalDraft { } } -const approvalDraftCodec = PayloadCodec.map( - encode: _encodeApprovalDraft, +const approvalDraftCodec = PayloadCodec.json( decode: ApprovalDraft.fromJson, typeName: 'ApprovalDraft', ); -Object? _encodeApprovalDraft(ApprovalDraft value) => value.toJson(); - // #region workflows-runtime Future bootstrapWorkflowApp() async { // #region workflows-app-create diff --git a/packages/stem/example/durable_watchers.dart b/packages/stem/example/durable_watchers.dart index e94d63e9..8f9a0976 100644 --- a/packages/stem/example/durable_watchers.dart +++ b/packages/stem/example/durable_watchers.dart @@ -1,11 +1,10 @@ import 'package:stem/stem.dart'; -final shipmentReadyEventCodec = PayloadCodec<_ShipmentReadyEvent>.map( - encode: (value) => value.toJson(), +const shipmentReadyEventCodec = PayloadCodec<_ShipmentReadyEvent>.json( decode: _ShipmentReadyEvent.fromJson, typeName: '_ShipmentReadyEvent', ); -final shipmentReadyEvent = WorkflowEventRef<_ShipmentReadyEvent>( +const shipmentReadyEvent = WorkflowEventRef<_ShipmentReadyEvent>( topic: 'shipment.ready', codec: shipmentReadyEventCodec, ); @@ -75,8 +74,7 @@ class _ShipmentReadyEvent { Map toJson() => {'trackingId': trackingId}; - static _ShipmentReadyEvent fromJson(Object? payload) { - final json = payload! as Map; + static _ShipmentReadyEvent fromJson(Map json) { return _ShipmentReadyEvent(trackingId: json['trackingId'] as String); } } diff --git a/packages/stem/lib/src/core/payload_codec.dart b/packages/stem/lib/src/core/payload_codec.dart index 4c63bcdd..cdd620d8 100644 --- a/packages/stem/lib/src/core/payload_codec.dart +++ b/packages/stem/lib/src/core/payload_codec.dart @@ -16,7 +16,8 @@ class PayloadCodec { /// Creates a payload codec for DTOs that serialize to a durable map payload. /// - /// This is the common author-facing case for workflow/task DTOs: + /// Use this when you need a custom map encoder or a decode function that is + /// not the usual `Type.fromJson(...)` shape: /// /// ```dart /// const approvalCodec = PayloadCodec.map( @@ -33,6 +34,24 @@ class PayloadCodec { _decodeMap = decode, _typeName = typeName; + /// Creates a payload codec for DTOs that expose `toJson()` and a matching + /// typed decoder like `Type.fromJson(...)`. + /// + /// This is the shortest happy path for common DTO payloads: + /// + /// ```dart + /// const approvalCodec = PayloadCodec.json( + /// decode: Approval.fromJson, + /// ); + /// ``` + const PayloadCodec.json({ + required T Function(Map payload) decode, + String? typeName, + }) : _encode = _encodeJsonPayload, + _decode = null, + _decodeMap = decode, + _typeName = typeName; + final Object? Function(T value) _encode; final T Function(Object? payload)? _decode; final T Function(Map payload)? _decodeMap; @@ -64,6 +83,19 @@ class PayloadCodec { } } +Object? _encodeJsonPayload(T value) { + try { + final payload = (value as dynamic).toJson(); + return _payloadMap(payload, value.runtimeType.toString()); + // Dynamic `toJson()` probing is the purpose of this helper. + // ignore: avoid_catching_errors + } on NoSuchMethodError { + throw StateError( + '${value.runtimeType} must expose toJson() to use PayloadCodec.json.', + ); + } +} + Map _payloadMap(Object? value, String typeName) { if (value is Map) { return Map.from(value); diff --git a/packages/stem/test/unit/core/payload_codec_test.dart b/packages/stem/test/unit/core/payload_codec_test.dart index 05f5c1a5..e1207bc2 100644 --- a/packages/stem/test/unit/core/payload_codec_test.dart +++ b/packages/stem/test/unit/core/payload_codec_test.dart @@ -2,6 +2,45 @@ import 'package:stem/stem.dart'; import 'package:test/test.dart'; void main() { + group('PayloadCodec.json', () { + test('encodes and decodes DTOs via toJson/fromJson', () { + const codec = PayloadCodec<_CodecPayload>.json( + decode: _CodecPayload.fromJson, + typeName: '_CodecPayload', + ); + + final payload = codec.encode( + const _CodecPayload(id: 'payload-0', count: 1), + ); + final decoded = codec.decode(payload); + + expect(payload, { + 'id': 'payload-0', + 'count': 1, + }); + expect(decoded.id, 'payload-0'); + expect(decoded.count, 1); + }); + + test('rejects values without toJson with a clear error', () { + const codec = PayloadCodec<_NoJsonPayload>.json( + decode: _NoJsonPayload.fromJson, + typeName: '_NoJsonPayload', + ); + + expect( + () => codec.encode(const _NoJsonPayload(id: 'missing')), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('_NoJsonPayload must expose toJson()'), + ), + ), + ); + }); + }); + group('PayloadCodec.map', () { test('decodes typed DTO payloads from durable maps', () { const codec = PayloadCodec<_CodecPayload>.map( @@ -95,3 +134,13 @@ class _CodecPayload { } Object? _encodeCodecPayload(_CodecPayload value) => value.toJson(); + +class _NoJsonPayload { + const _NoJsonPayload({required this.id}); + + factory _NoJsonPayload.fromJson(Map json) { + return _NoJsonPayload(id: json['id']! as String); + } + + final String id; +} diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index c489b73c..fc33822b 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -578,8 +578,7 @@ class _CodecReceipt { Map toJson() => {'id': id}; } -const _codecReceiptCodec = PayloadCodec<_CodecReceipt>.map( - encode: _encodeCodecReceipt, +const _codecReceiptCodec = PayloadCodec<_CodecReceipt>.json( decode: _CodecReceipt.fromJson, typeName: '_CodecReceipt', ); @@ -598,8 +597,6 @@ final _codecReceiptDefinition = decodeResult: _codecReceiptCodec.decode, ); -Object? _encodeCodecReceipt(_CodecReceipt value) => value.toJson(); - class _CodecTaskArgs { const _CodecTaskArgs(this.value); diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 6a62b94e..b875ef2b 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -25,14 +25,12 @@ class _GreetingResult { Map toJson() => {'message': message}; } -const _greetingParamsCodec = PayloadCodec<_GreetingParams>.map( - encode: _encodeGreetingParams, +const _greetingParamsCodec = PayloadCodec<_GreetingParams>.json( decode: _GreetingParams.fromJson, typeName: '_GreetingParams', ); -const _greetingResultCodec = PayloadCodec<_GreetingResult>.map( - encode: _encodeGreetingResult, +const _greetingResultCodec = PayloadCodec<_GreetingResult>.json( decode: _GreetingResult.fromJson, typeName: '_GreetingResult', ); @@ -42,10 +40,6 @@ const _userUpdatedEvent = WorkflowEventRef<_GreetingParams>( codec: _greetingParamsCodec, ); -Object? _encodeGreetingParams(_GreetingParams value) => value.toJson(); - -Object? _encodeGreetingResult(_GreetingResult value) => value.toJson(); - void main() { group('runtime workflow refs', () { test('start and wait helpers work directly with WorkflowRuntime', () async { diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index 0c1186f0..c7c8ac19 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -1534,9 +1534,9 @@ class _RecordingLogDriver extends LogDriver { } } -final _userUpdatedEventCodec = PayloadCodec<_UserUpdatedEvent>( - encode: (value) => value.toJson(), +const _userUpdatedEventCodec = PayloadCodec<_UserUpdatedEvent>.json( decode: _UserUpdatedEvent.fromJson, + typeName: '_UserUpdatedEvent', ); class _UserUpdatedEvent { @@ -1546,8 +1546,7 @@ class _UserUpdatedEvent { Map toJson() => {'id': id}; - static _UserUpdatedEvent fromJson(Object? payload) { - final json = payload! as Map; + static _UserUpdatedEvent fromJson(Map json) { return _UserUpdatedEvent(id: json['id'] as String); } } diff --git a/packages/stem_builder/CHANGELOG.md b/packages/stem_builder/CHANGELOG.md index 1e8c364d..f3ba011f 100644 --- a/packages/stem_builder/CHANGELOG.md +++ b/packages/stem_builder/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.0 +- Switched generated DTO payload codecs to the shorter + `PayloadCodec.json(...)` form for types that already expose `toJson()` and + `fromJson(...)`. - Switched generated DTO payload codecs to `PayloadCodec.map(...)`, removing the old emitted map-normalization helper and the need for handwritten `Object?` decode wrappers in generated output. diff --git a/packages/stem_builder/lib/src/stem_registry_builder.dart b/packages/stem_builder/lib/src/stem_registry_builder.dart index da727235..12464ab4 100644 --- a/packages/stem_builder/lib/src/stem_registry_builder.dart +++ b/packages/stem_builder/lib/src/stem_registry_builder.dart @@ -1356,8 +1356,7 @@ class _RegistryEmitter { final typeCode = entry.key; final symbol = entry.value; buffer.writeln(' static final PayloadCodec<$typeCode> $symbol ='); - buffer.writeln(' PayloadCodec<$typeCode>.map('); - buffer.writeln(' encode: (value) => value.toJson(),'); + buffer.writeln(' PayloadCodec<$typeCode>.json('); buffer.writeln( ' decode: $typeCode.fromJson,', ); diff --git a/packages/stem_builder/test/stem_registry_builder_test.dart b/packages/stem_builder/test/stem_registry_builder_test.dart index a48ddaaf..9adcc983 100644 --- a/packages/stem_builder/test/stem_registry_builder_test.dart +++ b/packages/stem_builder/test/stem_registry_builder_test.dart @@ -19,10 +19,16 @@ class PayloadCodec { required T Function(Map payload) decode, this.typeName, }) : decode = _unsupportedDecode; + const PayloadCodec.json({ + required T Function(Map payload) decode, + this.typeName, + }) : encode = _unsupportedEncode, + decode = _unsupportedDecode; final Object? Function(T value) encode; final T Function(Object? payload) decode; final String? typeName; + static Object? _unsupportedEncode(T value) => throw UnimplementedError(); static T _unsupportedDecode(Object? payload) => throw UnimplementedError(); } @@ -1210,11 +1216,10 @@ Future dtoTask( allOf([ contains('abstract final class StemPayloadCodecs'), contains('PayloadCodec emailRequest ='), - contains('PayloadCodec.map('), + contains('PayloadCodec.json('), contains( 'WorkflowRef script =', ), - contains('encode: (value) => value.toJson(),'), contains('decode: EmailRequest.fromJson,'), contains('typeName: "EmailRequest",'), contains( From fe113712b7a592e17fd144233a0cc4e5afcbc59b Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 05:05:52 -0500 Subject: [PATCH 138/302] Add manual json task and workflow helpers --- .site/docs/core-concepts/tasks.md | 9 ++-- .site/docs/workflows/starting-and-waiting.md | 6 ++- .../docs/workflows/suspensions-and-events.md | 9 ++-- packages/stem/CHANGELOG.md | 4 ++ packages/stem/README.md | 44 ++++++++----------- .../stem/example/docs_snippets/lib/tasks.dart | 9 +--- .../example/docs_snippets/lib/workflows.dart | 9 +--- packages/stem/example/durable_watchers.dart | 7 +-- packages/stem/lib/src/core/contracts.dart | 32 ++++++++++++++ packages/stem/lib/src/workflow/core/flow.dart | 12 +++++ .../workflow/core/workflow_definition.dart | 14 ++++++ .../src/workflow/core/workflow_event_ref.dart | 16 +++++++ .../lib/src/workflow/core/workflow_ref.dart | 28 ++++++++++++ .../src/workflow/core/workflow_script.dart | 12 +++++ .../stem/test/unit/core/stem_core_test.dart | 26 +++++++++++ .../workflow/workflow_runtime_ref_test.dart | 35 ++++++++++++++- 16 files changed, 214 insertions(+), 58 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 025ef785..a6a77c79 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -52,11 +52,10 @@ Typed results flow through `TaskResult` when you call `Canvas.chord`. Supplying a custom `decode` callback on the task signature lets you deserialize complex objects before they reach application code. -If your manual task args are DTOs, prefer -`TaskDefinition.withPayloadCodec(...)` over hand-written `encodeArgs` maps. -Prefer `PayloadCodec.json(...)` when the type already has `toJson()` and -`Type.fromJson(...)`, and use `PayloadCodec.map(...)` when you need a -custom map encoder. Task args still need to encode to `Map` +If your manual task args are DTOs, prefer `TaskDefinition.withJsonCodec(...)` +when the type already has `toJson()` and `Type.fromJson(...)`. Use +`TaskDefinition.withPayloadCodec(...)` when you need a custom +`PayloadCodec`. Task args still need to encode to `Map` because they are published as a map. `TaskEnqueueBuilder` also supports `enqueueAndWait(...)`, and typed task diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index e2d2f036..f12bf2dc 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -58,8 +58,10 @@ final runId = await approvalsRef .startWith(workflowApp); ``` -`refWithCodec(...)` is the manual DTO path. The codec still needs to encode to -`Map` because workflow params are stored as a map. +`refWithJsonCodec(...)` is the shortest manual DTO path when the type already +has `toJson()` and `Type.fromJson(...)`. Use `refWithCodec(...)` when you need +a custom `PayloadCodec`. Workflow params still need to encode to +`Map` because they are stored as a map. For workflows without start params, start directly from the flow or script itself with `startWith(...)`, `startAndWaitWith(...)`, or `startBuilder()`. diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index 0182d622..e7413346 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -60,10 +60,11 @@ Typed event payloads still serialize to the existing `Map` wire format. `emitValue(...)` is a DTO/codec convenience layer, not a new transport shape. -When the topic and codec travel together in your codebase, prefer a typed -`WorkflowEventRef` and `event.emitWith(emitter, dto)` as the happy path. -`emitter.emitEventBuilder(event: ref, value: dto).emit()` and -`event.call(value).emitWith(...)` remain available as lower-level variants. +When the topic and codec travel together in your codebase, prefer +`WorkflowEventRef.json(...)` for normal DTO payloads and keep +`event.emitWith(emitter, dto)` as the happy path. `emitter.emitEventBuilder( +event: ref, value: dto).emit()` and `event.call(value).emitWith(...)` remain +available as lower-level variants. Pair that with `await event.waitWith(ctx)` or `awaitEventRef(...)`. ## Inspect waiting runs diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index ad15908c..87ee5535 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.1 +- Added `TaskDefinition.withJsonCodec(...)`, `refWithJsonCodec(...)`, and + `WorkflowEventRef.json(...)` so manual DTO-backed tasks, workflows, and + typed workflow events no longer need a separate codec constant in the common + `toJson()` / `Type.fromJson(...)` case. - Added `PayloadCodec.json(...)` as the shortest DTO helper for types that already expose `toJson()` and `Type.fromJson(...)`, while keeping `PayloadCodec.map(...)` for custom map encoders. diff --git a/packages/stem/README.md b/packages/stem/README.md index 6c35647d..19e353a2 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -160,9 +160,9 @@ Use the new typed wrapper when you want compile-time checking and shared metadat ```dart class HelloTask implements TaskHandler { - static final definition = TaskDefinition.withPayloadCodec( + static final definition = TaskDefinition.withJsonCodec( name: 'demo.hello', - argsCodec: helloArgsCodec, + decodeArgs: HelloArgs.fromJson, metadata: TaskMetadata(description: 'Simple hello world example'), ); @@ -193,11 +193,6 @@ class HelloArgs { } } -const helloArgsCodec = PayloadCodec.json( - decode: HelloArgs.fromJson, - typeName: 'HelloArgs', -); - Future main() async { final client = await StemClient.fromUrl( 'redis://localhost:6379', @@ -224,11 +219,11 @@ Future main() async { producer-only processes do not need to register the worker handler locally just to enqueue typed calls. -Use `TaskDefinition.withPayloadCodec(...)` when your manual task args are DTOs. -Prefer `PayloadCodec.json(...)` when the type already exposes `toJson()` and -`Type.fromJson(...)`, and drop down to `PayloadCodec.map(...)` only when -you need a custom map encoder. Task args still need to encode to -`Map` because they are published as a map. +Use `TaskDefinition.withJsonCodec(...)` when your manual task args are normal +DTOs with `toJson()` and `Type.fromJson(...)`. Drop down to +`TaskDefinition.withPayloadCodec(...)` only when you need a custom +`PayloadCodec`. Task args still need to encode to `Map` +because they are published as a map. For typed task calls, the definition and call objects now expose the common producer operations directly. Prefer `enqueueAndWait(...)` when you only need @@ -483,13 +478,8 @@ final approvalsFlow = Flow( }, ); -const approvalDraftCodec = PayloadCodec.json( - decode: ApprovalDraft.fromJson, - typeName: 'ApprovalDraft', -); - -final approvalsRef = approvalsFlow.refWithCodec( - paramsCodec: approvalDraftCodec, +final approvalsRef = approvalsFlow.refWithJsonCodec( + decodeParams: ApprovalDraft.fromJson, ); final app = await StemWorkflowApp.fromUrl( @@ -522,9 +512,10 @@ final runId = await approvalsRef .startWith(app); ``` -Use `refWithCodec(...)` when your manual workflow start params are DTOs that -already have a `PayloadCodec`. The codec still needs to encode to -`Map` because workflow params are persisted as a map. +Use `refWithJsonCodec(...)` when your manual workflow start params are normal +DTOs with `toJson()` and `Type.fromJson(...)`. Drop down to `refWithCodec(...)` +when you need a custom `PayloadCodec`. Workflow params still need to encode +to `Map` because they are persisted as a map. For workflows without start parameters, start directly from the flow or script itself: @@ -1090,10 +1081,11 @@ backend metadata under `stem.unique.duplicates`. `takeResumeData()` / `takeResumeValue(codec: ...)` when the run resumes. - When you have a DTO event, emit it through `workflowApp.emitValue(...)` (or `runtime.emitValue(...)` when you are intentionally using the low-level - runtime) with a `PayloadCodec`, or bundle the topic and codec once in a - `WorkflowEventRef` and use `event.emitWith(emitter, dto)` as the happy - path, with `emitter.emitEventBuilder(event: ref, value: dto).emit()` and - `event.call(value).emitWith(...)` still available as lower-level variants. + runtime) with a `PayloadCodec`, or use `WorkflowEventRef.json(...)` + as the shortest typed event form and call `event.emitWith(emitter, dto)` as + the happy path. `emitter.emitEventBuilder(event: ref, value: dto).emit()` + and `event.call(value).emitWith(...)` remain available as lower-level + variants. Pair that with `await event.waitWith(ctx)` or `awaitEventRef(...)`. Event payloads still serialize onto the existing `Map` wire format. diff --git a/packages/stem/example/docs_snippets/lib/tasks.dart b/packages/stem/example/docs_snippets/lib/tasks.dart index 14ad1e92..41a1fd03 100644 --- a/packages/stem/example/docs_snippets/lib/tasks.dart +++ b/packages/stem/example/docs_snippets/lib/tasks.dart @@ -58,16 +58,11 @@ class InvoicePayload { } } -const invoicePayloadCodec = PayloadCodec.json( - decode: InvoicePayload.fromJson, - typeName: 'InvoicePayload', -); - class PublishInvoiceTask extends TaskHandler { static final definition = - TaskDefinition.withPayloadCodec( + TaskDefinition.withJsonCodec( name: 'invoice.publish', - argsCodec: invoicePayloadCodec, + decodeArgs: InvoicePayload.fromJson, metadata: const TaskMetadata( description: 'Publishes invoices downstream', ), diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index 8e17bb42..4b39c35e 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -17,11 +17,6 @@ class ApprovalDraft { } } -const approvalDraftCodec = PayloadCodec.json( - decode: ApprovalDraft.fromJson, - typeName: 'ApprovalDraft', -); - // #region workflows-runtime Future bootstrapWorkflowApp() async { // #region workflows-app-create @@ -81,8 +76,8 @@ class ApprovalsFlow { }, ); - static final ref = flow.refWithCodec( - paramsCodec: approvalDraftCodec, + static final ref = flow.refWithJsonCodec( + decodeParams: ApprovalDraft.fromJson, ); } diff --git a/packages/stem/example/durable_watchers.dart b/packages/stem/example/durable_watchers.dart index 8f9a0976..881e8ed0 100644 --- a/packages/stem/example/durable_watchers.dart +++ b/packages/stem/example/durable_watchers.dart @@ -1,13 +1,10 @@ import 'package:stem/stem.dart'; -const shipmentReadyEventCodec = PayloadCodec<_ShipmentReadyEvent>.json( +final shipmentReadyEvent = WorkflowEventRef<_ShipmentReadyEvent>.json( + topic: 'shipment.ready', decode: _ShipmentReadyEvent.fromJson, typeName: '_ShipmentReadyEvent', ); -const shipmentReadyEvent = WorkflowEventRef<_ShipmentReadyEvent>( - topic: 'shipment.ready', - codec: shipmentReadyEventCodec, -); /// Runs a workflow that suspends on `awaitEvent` and resumes once a payload is /// emitted. The example also inspects watcher metadata before the resume. diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 3a475eba..65367df1 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2110,6 +2110,38 @@ class TaskDefinition { ); } + /// Creates a typed task definition for DTO args that already expose + /// `toJson()` and `Type.fromJson(...)`. + factory TaskDefinition.withJsonCodec({ + required String name, + required TArgs Function(Map payload) decodeArgs, + TaskMetaBuilder? encodeMeta, + TaskOptions defaultOptions = const TaskOptions(), + TaskMetadata metadata = const TaskMetadata(), + TResult Function(Map payload)? decodeResultJson, + String? argsTypeName, + String? resultTypeName, + }) { + final resultCodec = + decodeResultJson == null + ? null + : PayloadCodec.json( + decode: decodeResultJson, + typeName: resultTypeName ?? '$TResult', + ); + return TaskDefinition.withPayloadCodec( + name: name, + argsCodec: PayloadCodec.json( + decode: decodeArgs, + typeName: argsTypeName ?? '$TArgs', + ), + encodeMeta: encodeMeta, + defaultOptions: defaultOptions, + metadata: metadata, + resultCodec: resultCodec, + ); + } + /// Creates a typed task definition for handlers with no producer args. static NoArgsTaskDefinition noArgs({ required String name, diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index db67b0d5..0e06a732 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -47,6 +47,18 @@ class Flow { return definition.refWithCodec(paramsCodec: paramsCodec); } + /// Builds a typed [WorkflowRef] for DTO params that already expose + /// `toJson()` and `Type.fromJson(...)`. + WorkflowRef refWithJsonCodec({ + required TParams Function(Map payload) decodeParams, + String? paramsTypeName, + }) { + return definition.refWithJsonCodec( + decodeParams: decodeParams, + paramsTypeName: paramsTypeName, + ); + } + /// Builds a typed [NoArgsWorkflowRef] for flows without start params. NoArgsWorkflowRef ref0() { return definition.ref0(); diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index 4476365d..2545ff9b 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -316,6 +316,20 @@ class WorkflowDefinition { ); } + /// Builds a typed [WorkflowRef] for DTO params that already expose + /// `toJson()` and `Type.fromJson(...)`. + WorkflowRef refWithJsonCodec({ + required TParams Function(Map payload) decodeParams, + String? paramsTypeName, + }) { + return WorkflowRef.withJsonCodec( + name: name, + decodeParams: decodeParams, + decodeResult: (payload) => decodeResult(payload) as T, + paramsTypeName: paramsTypeName, + ); + } + /// Builds a typed [NoArgsWorkflowRef] from this definition. NoArgsWorkflowRef ref0() { return NoArgsWorkflowRef( diff --git a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart index a8ac9cc0..f533c3d4 100644 --- a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart @@ -26,6 +26,22 @@ class WorkflowEventRef { this.codec, }); + /// Creates a typed workflow event reference for DTO payloads that already + /// expose `toJson()` and `Type.fromJson(...)`. + factory WorkflowEventRef.json({ + required String topic, + required T Function(Map payload) decode, + String? typeName, + }) { + return WorkflowEventRef( + topic: topic, + codec: PayloadCodec.json( + decode: decode, + typeName: typeName, + ), + ); + } + /// Durable topic name used to suspend and resume workflow runs. final String topic; diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 03c5b885..5d6883da 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -29,6 +29,34 @@ class WorkflowRef { ); } + /// Creates a typed workflow reference for DTO params that already expose + /// `toJson()` and `Type.fromJson(...)`. + factory WorkflowRef.withJsonCodec({ + required String name, + required TParams Function(Map payload) decodeParams, + TResult Function(Map payload)? decodeResultJson, + TResult Function(Object? payload)? decodeResult, + String? paramsTypeName, + String? resultTypeName, + }) { + final resultCodec = + decodeResultJson == null + ? null + : PayloadCodec.json( + decode: decodeResultJson, + typeName: resultTypeName ?? '$TResult', + ); + return WorkflowRef.withPayloadCodec( + name: name, + paramsCodec: PayloadCodec.json( + decode: decodeParams, + typeName: paramsTypeName ?? '$TParams', + ), + resultCodec: resultCodec, + decodeResult: decodeResult, + ); + } + /// Registered workflow name. final String name; diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index 33759365..23ecc7c1 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -49,6 +49,18 @@ class WorkflowScript { return definition.refWithCodec(paramsCodec: paramsCodec); } + /// Builds a typed [WorkflowRef] for DTO params that already expose + /// `toJson()` and `Type.fromJson(...)`. + WorkflowRef refWithJsonCodec({ + required TParams Function(Map payload) decodeParams, + String? paramsTypeName, + }) { + return definition.refWithJsonCodec( + decodeParams: decodeParams, + paramsTypeName: paramsTypeName, + ); + } + /// Builds a typed [NoArgsWorkflowRef] for scripts without start params. NoArgsWorkflowRef ref0() { return definition.ref0(); diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index fc33822b..67893957 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -123,6 +123,28 @@ void main() { expect(backend.records.single.state, TaskState.queued); }); + test('enqueueCall publishes json-backed task definitions', () async { + final broker = _RecordingBroker(); + final backend = _RecordingBackend(); + final stem = Stem(broker: broker, backend: backend); + final definition = TaskDefinition<_CodecTaskArgs, Object?>.withJsonCodec( + name: 'sample.json.args', + decodeArgs: _CodecTaskArgs.fromJson, + defaultOptions: const TaskOptions(queue: 'typed'), + ); + + final id = await stem.enqueueCall( + definition.call(const _CodecTaskArgs('encoded')), + ); + + expect(id, isNotEmpty); + expect(broker.published.single.envelope.name, 'sample.json.args'); + expect(broker.published.single.envelope.queue, 'typed'); + expect(broker.published.single.envelope.args, {'value': 'encoded'}); + expect(backend.records.single.id, id); + expect(backend.records.single.state, TaskState.queued); + }); + test( 'enqueueCall uses definition encoder metadata on producer-only paths', () async { @@ -600,6 +622,10 @@ final _codecReceiptDefinition = class _CodecTaskArgs { const _CodecTaskArgs(this.value); + factory _CodecTaskArgs.fromJson(Map payload) { + return _CodecTaskArgs(payload['value']! as String); + } + final String value; Map toJson() => {'value': value}; diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index b875ef2b..cf6df22b 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -35,9 +35,10 @@ const _greetingResultCodec = PayloadCodec<_GreetingResult>.json( typeName: '_GreetingResult', ); -const _userUpdatedEvent = WorkflowEventRef<_GreetingParams>( +final _userUpdatedEvent = WorkflowEventRef<_GreetingParams>.json( topic: 'runtime.ref.event', - codec: _greetingParamsCodec, + decode: _GreetingParams.fromJson, + typeName: '_GreetingParams', ); void main() { @@ -150,6 +151,36 @@ void main() { } }); + test('manual workflows can derive json-backed refs', () async { + final flow = Flow( + name: 'runtime.ref.json.flow', + build: (builder) { + builder.step('hello', (ctx) async { + final name = ctx.params['name'] as String? ?? 'world'; + return 'hello $name'; + }); + }, + ); + final workflowRef = flow.refWithJsonCodec<_GreetingParams>( + decodeParams: _GreetingParams.fromJson, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + await workflowApp.start(); + + final result = await workflowRef.startAndWaitWith( + workflowApp.runtime, + const _GreetingParams(name: 'json'), + timeout: const Duration(seconds: 2), + ); + + expect(result?.value, 'hello json'); + } finally { + await workflowApp.shutdown(); + } + }); + test('codec-backed refs preserve workflow result decoding', () async { final flow = Flow<_GreetingResult>( name: 'runtime.ref.codec.result.flow', From 56af4be39744c5d4a143b038496ea0e1530fde92 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 05:31:20 -0500 Subject: [PATCH 139/302] Add json result helpers for manual definitions --- .site/docs/core-concepts/tasks.md | 4 ++ .site/docs/workflows/starting-and-waiting.md | 4 ++ packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 6 ++- packages/stem/lib/src/core/contracts.dart | 31 ++++++++++---- packages/stem/lib/src/workflow/core/flow.dart | 4 ++ .../workflow/core/workflow_definition.dart | 40 +++++++++++++++--- .../src/workflow/core/workflow_script.dart | 4 ++ .../stem/test/unit/core/stem_core_test.dart | 23 ++++++++++- .../workflow/workflow_runtime_ref_test.dart | 41 +++++++++++++++++++ 10 files changed, 142 insertions(+), 18 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index a6a77c79..32ed0042 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -69,6 +69,10 @@ instead. That gives you direct `enqueue(...)` / `enqueueAndWait(...)` helpers without passing a fake empty map and the same `waitFor(...)` decoding surface as normal typed definitions. +If a no-arg task returns a DTO, prefer `decodeResultJson:` when the result +already has `toJson()` and `Type.fromJson(...)`. Use `resultCodec:` only when +you need a custom payload codec. + ## Configuring Retries Workers apply an `ExponentialJitterRetryStrategy` by default. Each retry is diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index f12bf2dc..e8071f5a 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -63,6 +63,10 @@ has `toJson()` and `Type.fromJson(...)`. Use `refWithCodec(...)` when you need a custom `PayloadCodec`. Workflow params still need to encode to `Map` because they are stored as a map. +If a manual flow or script returns a DTO, prefer `decodeResultJson:` on the +definition constructor in the common `toJson()` / `Type.fromJson(...)` case. +Use `resultCodec:` only when the result needs a custom payload codec. + For workflows without start params, start directly from the flow or script itself with `startWith(...)`, `startAndWaitWith(...)`, or `startBuilder()`. Use `ref0()` when another API specifically needs a `NoArgsWorkflowRef`. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 87ee5535..208e1048 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Added `decodeResultJson:` shortcuts on manual `Flow`, `WorkflowScript`, and + `TaskDefinition.noArgs(...)` definitions so common DTO result decoding no + longer needs a separate `PayloadCodec.json(...)` constant. - Added `TaskDefinition.withJsonCodec(...)`, `refWithJsonCodec(...)`, and `WorkflowEventRef.json(...)` so manual DTO-backed tasks, workflows, and typed workflow events no longer need a separate codec constant in the common diff --git a/packages/stem/README.md b/packages/stem/README.md index 19e353a2..8aea68be 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -247,8 +247,10 @@ final healthcheckDefinition = TaskDefinition.noArgs( await healthcheckDefinition.enqueue(stem); ``` -If a no-arg task returns a DTO, pass `resultCodec:` so waiting helpers decode -the result and the task metadata advertises the right result encoder. +If a no-arg task returns a DTO, prefer `decodeResultJson:` in the common +`toJson()` / `Type.fromJson(...)` case. Use `resultCodec:` when you need a +custom payload codec. Both paths keep waiting helpers typed and advertise the +right result encoder in task metadata. You can also build requests fluently from the task definition itself: diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 65367df1..f461cc31 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2122,13 +2122,12 @@ class TaskDefinition { String? argsTypeName, String? resultTypeName, }) { - final resultCodec = - decodeResultJson == null - ? null - : PayloadCodec.json( - decode: decodeResultJson, - typeName: resultTypeName ?? '$TResult', - ); + final resultCodec = decodeResultJson == null + ? null + : PayloadCodec.json( + decode: decodeResultJson, + typeName: resultTypeName ?? '$TResult', + ); return TaskDefinition.withPayloadCodec( name: name, argsCodec: PayloadCodec.json( @@ -2149,16 +2148,30 @@ class TaskDefinition { TaskMetadata metadata = const TaskMetadata(), TaskResultDecoder? decodeResult, PayloadCodec? resultCodec, + TResult Function(Map payload)? decodeResultJson, + String? resultTypeName, }) { + assert( + resultCodec == null || decodeResultJson == null, + 'Specify either resultCodec or decodeResultJson, not both.', + ); + final resolvedResultCodec = + resultCodec ?? + (decodeResultJson == null + ? null + : PayloadCodec.json( + decode: decodeResultJson, + typeName: resultTypeName ?? '$TResult', + )); return NoArgsTaskDefinition( name: name, defaultOptions: defaultOptions, metadata: TaskDefinition._metadataWithResultCodec( name, metadata, - resultCodec, + resolvedResultCodec, ), - decodeResult: decodeResult ?? resultCodec?.decode, + decodeResult: decodeResult ?? resolvedResultCodec?.decode, ); } diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index 0e06a732..c94cbcf8 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -20,6 +20,8 @@ class Flow { String? description, Map? metadata, PayloadCodec? resultCodec, + T Function(Map payload)? decodeResultJson, + String? resultTypeName, }) : definition = WorkflowDefinition.flow( name: name, build: build, @@ -27,6 +29,8 @@ class Flow { description: description, metadata: metadata, resultCodec: resultCodec, + decodeResultJson: decodeResultJson, + resultTypeName: resultTypeName, ); /// The constructed workflow definition. diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index 2545ff9b..0f9b50ae 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -159,7 +159,13 @@ class WorkflowDefinition { String? description, Map? metadata, PayloadCodec? resultCodec, + T Function(Map payload)? decodeResultJson, + String? resultTypeName, }) { + assert( + resultCodec == null || decodeResultJson == null, + 'Specify either resultCodec or decodeResultJson, not both.', + ); final steps = []; build(FlowBuilder(steps)); final edges = []; @@ -168,12 +174,20 @@ class WorkflowDefinition { } Object? Function(Object?)? resultEncoder; Object? Function(Object?)? resultDecoder; - if (resultCodec != null) { + final resolvedResultCodec = + resultCodec ?? + (decodeResultJson == null + ? null + : PayloadCodec.json( + decode: decodeResultJson, + typeName: resultTypeName ?? '$T', + )); + if (resolvedResultCodec != null) { resultEncoder = (Object? value) { - return resultCodec.encodeDynamic(value); + return resolvedResultCodec.encodeDynamic(value); }; resultDecoder = (Object? payload) { - return resultCodec.decodeDynamic(payload); + return resolvedResultCodec.decodeDynamic(payload); }; } return WorkflowDefinition._( @@ -198,15 +212,29 @@ class WorkflowDefinition { String? description, Map? metadata, PayloadCodec? resultCodec, + T Function(Map payload)? decodeResultJson, + String? resultTypeName, }) { + assert( + resultCodec == null || decodeResultJson == null, + 'Specify either resultCodec or decodeResultJson, not both.', + ); Object? Function(Object?)? resultEncoder; Object? Function(Object?)? resultDecoder; - if (resultCodec != null) { + final resolvedResultCodec = + resultCodec ?? + (decodeResultJson == null + ? null + : PayloadCodec.json( + decode: decodeResultJson, + typeName: resultTypeName ?? '$T', + )); + if (resolvedResultCodec != null) { resultEncoder = (Object? value) { - return resultCodec.encodeDynamic(value); + return resolvedResultCodec.encodeDynamic(value); }; resultDecoder = (Object? payload) { - return resultCodec.decodeDynamic(payload); + return resolvedResultCodec.decodeDynamic(payload); }; } return WorkflowDefinition._( diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index 23ecc7c1..d6d76c6d 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -21,6 +21,8 @@ class WorkflowScript { String? description, Map? metadata, PayloadCodec? resultCodec, + T Function(Map payload)? decodeResultJson, + String? resultTypeName, }) : definition = WorkflowDefinition.script( name: name, run: run, @@ -29,6 +31,8 @@ class WorkflowScript { description: description, metadata: metadata, resultCodec: resultCodec, + decodeResultJson: decodeResultJson, + resultTypeName: resultTypeName, ); /// The constructed workflow definition. diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index 67893957..408940b3 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -344,6 +344,27 @@ void main() { expect(backend.records.single.id, id); }, ); + + test( + 'no-arg task definitions can derive json-backed result metadata', + () async { + final broker = _RecordingBroker(); + final backend = _RecordingBackend(); + final stem = Stem(broker: broker, backend: backend); + final definition = TaskDefinition.noArgs<_CodecReceipt>( + name: 'sample.no_args.json', + decodeResultJson: _CodecReceipt.fromJson, + ); + + final id = await definition.enqueue(stem); + + expect( + backend.records.single.meta[stemResultEncoderMetaKey], + endsWith('.result.codec'), + ); + expect(backend.records.single.id, id); + }, + ); }); group('TaskCall helpers', () { @@ -479,7 +500,7 @@ void main() { final stem = Stem(broker: _RecordingBroker(), backend: backend); final definition = TaskDefinition.noArgs<_CodecReceipt>( name: 'no-args.wait', - resultCodec: _codecReceiptCodec, + decodeResultJson: _CodecReceipt.fromJson, ); await backend.set( diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index cf6df22b..c53ea22b 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -212,6 +212,47 @@ void main() { } }); + test('manual workflows can derive json-backed result decoding', () async { + final flow = Flow<_GreetingResult>( + name: 'runtime.ref.json.result.flow', + decodeResultJson: _GreetingResult.fromJson, + build: (builder) { + builder.step( + 'hello', + (ctx) async => const _GreetingResult(message: 'hello flow json'), + ); + }, + ); + final script = WorkflowScript<_GreetingResult>( + name: 'runtime.ref.json.result.script', + decodeResultJson: _GreetingResult.fromJson, + run: (context) async => + const _GreetingResult(message: 'hello script json'), + ); + + final workflowApp = await StemWorkflowApp.inMemory( + flows: [flow], + scripts: [script], + ); + try { + await workflowApp.start(); + + final flowResult = await flow.startAndWaitWith( + workflowApp.runtime, + timeout: const Duration(seconds: 2), + ); + final scriptResult = await script.startAndWaitWith( + workflowApp.runtime, + timeout: const Duration(seconds: 2), + ); + + expect(flowResult?.value?.message, 'hello flow json'); + expect(scriptResult?.value?.message, 'hello script json'); + } finally { + await workflowApp.shutdown(); + } + }); + test('manual workflows expose direct no-args helpers', () async { final flow = Flow( name: 'runtime.ref.no-args.flow', From 5c78007a29412fce86eed1f73d036b6fa858b7fe Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 05:33:28 -0500 Subject: [PATCH 140/302] Add module queue inspection helpers --- .site/docs/core-concepts/stem-builder.md | 10 ++++++ packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 13 ++++++++ .../stem/lib/src/bootstrap/stem_module.dart | 31 +++++++++++++++++++ .../test/bootstrap/module_bootstrap_test.dart | 30 ++++++++++++++++++ 5 files changed, 87 insertions(+) diff --git a/.site/docs/core-concepts/stem-builder.md b/.site/docs/core-concepts/stem-builder.md index 951091af..bc8cbdfa 100644 --- a/.site/docs/core-concepts/stem-builder.md +++ b/.site/docs/core-concepts/stem-builder.md @@ -118,6 +118,16 @@ final workflowApp = await StemWorkflowApp.inMemory( ); ``` +When debugging bootstrap wiring, inspect the queue set a bundle implies before +you create the app: + +```dart +final queues = stemModule.requiredWorkflowQueues( + continuationQueue: 'workflow-continue', + executionQueue: 'workflow-step', +); +``` + If you already manage a `StemApp` for a larger service, reuse it instead of bootstrapping a second app: diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 208e1048..8576cb46 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Added `StemModule.requiredTaskQueues()` and + `StemModule.requiredWorkflowQueues(...)` so bundle queue requirements can be + inspected directly before app/worker bootstrap. - Added `decodeResultJson:` shortcuts on manual `Flow`, `WorkflowScript`, and `TaskDefinition.noArgs(...)` definitions so common DTO result decoding no longer needs a separate `PayloadCodec.json(...)` constant. diff --git a/packages/stem/README.md b/packages/stem/README.md index 8aea68be..57f94fb1 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -746,6 +746,19 @@ final app = await StemWorkflowApp.inMemory( ); ``` +If you want to inspect what a bundled module will require before bootstrapping, +use `requiredTaskQueues()` for task-only workers and +`requiredWorkflowQueues(...)` for workflow-capable workers: + +```dart +final queues = stemModule.requiredWorkflowQueues( + continuationQueue: 'workflow-continue', + executionQueue: 'workflow-step', +); + +print(queues); +``` + If your service already owns a `StemApp`, reuse it: ```dart diff --git a/packages/stem/lib/src/bootstrap/stem_module.dart b/packages/stem/lib/src/bootstrap/stem_module.dart index 561307c4..c3c2c4f8 100644 --- a/packages/stem/lib/src/bootstrap/stem_module.dart +++ b/packages/stem/lib/src/bootstrap/stem_module.dart @@ -247,6 +247,26 @@ class StemModule { return queues.toList(growable: false); } + /// Returns the queues required to run workflow orchestration plus bundled + /// tasks. + /// + /// This is the explicit inspection helper for workflow-capable workers. + /// Bootstrap helpers use the same queue set when inferring workflow worker + /// subscriptions from a module. + List requiredWorkflowQueues({ + String workflowQueue = 'workflow', + String? continuationQueue, + String? executionQueue, + Iterable> additionalTasks = const [], + }) { + return inferredWorkerQueues( + workflowQueue: workflowQueue, + continuationQueue: continuationQueue, + executionQueue: executionQueue, + additionalTasks: additionalTasks, + ); + } + /// Infers a worker subscription from the bundled task handlers. /// /// Returns `null` when only the [workflowQueue] is needed, allowing the @@ -287,6 +307,17 @@ class StemModule { return queues.toList(growable: false); } + /// Returns the queues required by bundled task handlers only. + /// + /// This is the explicit inspection helper for task-only workers. Bootstrap + /// helpers use the same queue set when inferring plain worker subscriptions + /// from a module. + List requiredTaskQueues({ + Iterable> additionalTasks = const [], + }) { + return inferredTaskQueues(additionalTasks: additionalTasks); + } + /// Infers a worker subscription from bundled task handlers only. /// /// Returns `null` when the bundled tasks only target [defaultQueue], allowing diff --git a/packages/stem/test/bootstrap/module_bootstrap_test.dart b/packages/stem/test/bootstrap/module_bootstrap_test.dart index 9f05ec58..94694ba6 100644 --- a/packages/stem/test/bootstrap/module_bootstrap_test.dart +++ b/packages/stem/test/bootstrap/module_bootstrap_test.dart @@ -149,6 +149,36 @@ void main() { ), ); }); + + test('exposes explicit queue inspection helpers', () { + final taskA = FunctionTaskHandler( + name: 'module.queues.task.a', + entrypoint: (context, args) async => 'a', + runInIsolate: false, + ); + final taskB = FunctionTaskHandler( + name: 'module.queues.task.b', + options: const TaskOptions(queue: 'priority'), + entrypoint: (context, args) async => 'b', + runInIsolate: false, + ); + final module = StemModule(tasks: [taskA, taskB]); + + expect(module.requiredTaskQueues(), ['default', 'priority']); + expect( + module.requiredWorkflowQueues( + continuationQueue: 'workflow-continue', + executionQueue: 'workflow-step', + ), + [ + 'default', + 'priority', + 'workflow', + 'workflow-continue', + 'workflow-step', + ], + ); + }); }); group('module bootstrap', () { From d8f2c4592c650b2092b736e6c5602bc28781c2d4 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 05:35:56 -0500 Subject: [PATCH 141/302] Add module subscription inspection helpers --- .site/docs/core-concepts/stem-builder.md | 10 +++++++ packages/stem/CHANGELOG.md | 4 +++ packages/stem/README.md | 10 +++++++ .../stem/lib/src/bootstrap/stem_module.dart | 30 +++++++++++++++++++ .../test/bootstrap/module_bootstrap_test.dart | 19 ++++++++++++ 5 files changed, 73 insertions(+) diff --git a/.site/docs/core-concepts/stem-builder.md b/.site/docs/core-concepts/stem-builder.md index bc8cbdfa..74c6fa1a 100644 --- a/.site/docs/core-concepts/stem-builder.md +++ b/.site/docs/core-concepts/stem-builder.md @@ -128,6 +128,16 @@ final queues = stemModule.requiredWorkflowQueues( ); ``` +If you are wiring a worker manually, the module can also give you the exact +subscription directly: + +```dart +final subscription = stemModule.requiredWorkflowSubscription( + continuationQueue: 'workflow-continue', + executionQueue: 'workflow-step', +); +``` + If you already manage a `StemApp` for a larger service, reuse it instead of bootstrapping a second app: diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 8576cb46..f179e20a 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -5,6 +5,10 @@ - Added `StemModule.requiredTaskQueues()` and `StemModule.requiredWorkflowQueues(...)` so bundle queue requirements can be inspected directly before app/worker bootstrap. +- Added `StemModule.requiredTaskSubscription()` and + `StemModule.requiredWorkflowSubscription(...)` so low-level worker wiring can + reuse the exact subscription implied by a module without rebuilding it by + hand. - Added `decodeResultJson:` shortcuts on manual `Flow`, `WorkflowScript`, and `TaskDefinition.noArgs(...)` definitions so common DTO result decoding no longer needs a separate `PayloadCodec.json(...)` constant. diff --git a/packages/stem/README.md b/packages/stem/README.md index 57f94fb1..f7e078a5 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -759,6 +759,16 @@ final queues = stemModule.requiredWorkflowQueues( print(queues); ``` +For low-level worker wiring, the module can also give you the exact +subscription directly: + +```dart +final subscription = stemModule.requiredWorkflowSubscription( + continuationQueue: 'workflow-continue', + executionQueue: 'workflow-step', +); +``` + If your service already owns a `StemApp`, reuse it: ```dart diff --git a/packages/stem/lib/src/bootstrap/stem_module.dart b/packages/stem/lib/src/bootstrap/stem_module.dart index c3c2c4f8..9252b833 100644 --- a/packages/stem/lib/src/bootstrap/stem_module.dart +++ b/packages/stem/lib/src/bootstrap/stem_module.dart @@ -267,6 +267,25 @@ class StemModule { ); } + /// Returns the explicit subscription required for workflow-capable workers. + RoutingSubscription requiredWorkflowSubscription({ + String workflowQueue = 'workflow', + String? continuationQueue, + String? executionQueue, + Iterable> additionalTasks = const [], + }) { + final queues = requiredWorkflowQueues( + workflowQueue: workflowQueue, + continuationQueue: continuationQueue, + executionQueue: executionQueue, + additionalTasks: additionalTasks, + ); + if (queues.length == 1) { + return RoutingSubscription.singleQueue(queues.single); + } + return RoutingSubscription(queues: queues); + } + /// Infers a worker subscription from the bundled task handlers. /// /// Returns `null` when only the [workflowQueue] is needed, allowing the @@ -318,6 +337,17 @@ class StemModule { return inferredTaskQueues(additionalTasks: additionalTasks); } + /// Returns the explicit subscription required for task-only workers. + RoutingSubscription requiredTaskSubscription({ + Iterable> additionalTasks = const [], + }) { + final queues = requiredTaskQueues(additionalTasks: additionalTasks); + if (queues.length == 1) { + return RoutingSubscription.singleQueue(queues.single); + } + return RoutingSubscription(queues: queues); + } + /// Infers a worker subscription from bundled task handlers only. /// /// Returns `null` when the bundled tasks only target [defaultQueue], allowing diff --git a/packages/stem/test/bootstrap/module_bootstrap_test.dart b/packages/stem/test/bootstrap/module_bootstrap_test.dart index 94694ba6..7aa44fd9 100644 --- a/packages/stem/test/bootstrap/module_bootstrap_test.dart +++ b/packages/stem/test/bootstrap/module_bootstrap_test.dart @@ -165,6 +165,10 @@ void main() { final module = StemModule(tasks: [taskA, taskB]); expect(module.requiredTaskQueues(), ['default', 'priority']); + expect( + module.requiredTaskSubscription().queues, + ['default', 'priority'], + ); expect( module.requiredWorkflowQueues( continuationQueue: 'workflow-continue', @@ -178,6 +182,21 @@ void main() { 'workflow-step', ], ); + expect( + module + .requiredWorkflowSubscription( + continuationQueue: 'workflow-continue', + executionQueue: 'workflow-step', + ) + .queues, + [ + 'default', + 'priority', + 'workflow', + 'workflow-continue', + 'workflow-step', + ], + ); }); }); From 3a2f826c670526581d0242c5daae0983aa2a6f99 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 05:40:28 -0500 Subject: [PATCH 142/302] Add named workflow start aliases --- .site/docs/core-concepts/stem-builder.md | 4 +- .site/docs/workflows/annotated-workflows.md | 6 +- .../workflows/context-and-serialization.md | 9 +- .site/docs/workflows/flows-and-scripts.md | 4 +- .site/docs/workflows/starting-and-waiting.md | 20 +-- packages/stem/CHANGELOG.md | 4 + packages/stem/README.md | 20 +-- .../example/annotated_workflows/README.md | 4 +- .../example/annotated_workflows/bin/main.dart | 36 ++--- packages/stem/lib/src/workflow/core/flow.dart | 34 +++++ .../lib/src/workflow/core/workflow_ref.dart | 123 +++++++++++++++++- .../src/workflow/core/workflow_script.dart | 34 +++++ ...workflow_runtime_call_extensions_test.dart | 114 +++++++++++----- .../workflow/workflow_runtime_ref_test.dart | 4 +- 14 files changed, 322 insertions(+), 94 deletions(-) diff --git a/.site/docs/core-concepts/stem-builder.md b/.site/docs/core-concepts/stem-builder.md index 74c6fa1a..7273daa3 100644 --- a/.site/docs/core-concepts/stem-builder.md +++ b/.site/docs/core-concepts/stem-builder.md @@ -87,9 +87,9 @@ final workflowApp = await StemWorkflowApp.fromUrl( ); await workflowApp.start(); -final result = await StemWorkflowDefinitions.userSignup.startAndWaitWith( +final result = await StemWorkflowDefinitions.userSignup.startAndWait( workflowApp, - 'user@example.com', + params: 'user@example.com', ); ``` diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index 9c84614f..a48ea3fb 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -47,9 +47,9 @@ Use the generated workflow refs when you want a single typed handle for start and wait operations: ```dart -final result = await StemWorkflowDefinitions.userSignup.startAndWaitWith( +final result = await StemWorkflowDefinitions.userSignup.startAndWait( workflowApp, - 'user@example.com', + params: 'user@example.com', ); ``` @@ -132,7 +132,7 @@ This keeps one authoring model: When a workflow needs to start another workflow, do it from a durable boundary: - `FlowContext` and `WorkflowScriptStepContext` both implement - `WorkflowCaller`, so prefer `ref.startAndWaitWith(context, value)` inside + `WorkflowCaller`, so prefer `ref.startAndWait(context, params: value)` inside flow steps and checkpoint methods - use `context.startWorkflowBuilder(...)` only when you need advanced start overrides like `ttl(...)` or `cancellationPolicy(...)` diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index ed763d9f..23d2f833 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -38,8 +38,9 @@ Depending on the context type, you can access: - `takeResumeData()` for event-driven resumes - `takeResumeValue(codec: ...)` for typed event-driven resumes - `idempotencyKey(...)` -- direct child-workflow start helpers such as `ref.startWith(context, value)` - and `ref.startAndWaitWith(context, value)` +- direct child-workflow start helpers such as + `ref.start(context, params: value)` and + `ref.startAndWait(context, params: value)` - direct task enqueue APIs because `FlowContext`, `WorkflowScriptStepContext`, and `TaskInvocationContext` all implement `TaskEnqueuer` @@ -47,8 +48,8 @@ Depending on the context type, you can access: Child workflow starts belong in durable boundaries: -- `ref.startWith(context, value)` inside flow steps -- `ref.startAndWaitWith(context, value)` inside script checkpoints +- `ref.start(context, params: value)` inside flow steps +- `ref.startAndWait(context, params: value)` inside script checkpoints - `context.startWorkflowBuilder(...)` when you need advanced overrides like `ttl(...)` or `cancellationPolicy(...)` diff --git a/.site/docs/workflows/flows-and-scripts.md b/.site/docs/workflows/flows-and-scripts.md index becc30b7..032b9807 100644 --- a/.site/docs/workflows/flows-and-scripts.md +++ b/.site/docs/workflows/flows-and-scripts.md @@ -36,7 +36,7 @@ final approvalsRef = approvalsFlow.ref>( ``` When a flow has no start params, start directly from the flow itself with -`flow.startWith(...)`, `flow.startAndWaitWith(...)`, or `flow.startBuilder()`. +`flow.start(...)`, `flow.startAndWait(...)`, or `flow.startBuilder()`. Use `ref0()` only when another API specifically needs a `NoArgsWorkflowRef`. Use `Flow` when: @@ -60,7 +60,7 @@ final retryRef = retryScript.ref>( ``` When a script has no start params, start directly from the script itself with -`retryScript.startWith(...)`, `retryScript.startAndWaitWith(...)`, or +`retryScript.start(...)`, `retryScript.startAndWait(...)`, or `retryScript.startBuilder()`. Use `ref0()` only when another API specifically needs a `NoArgsWorkflowRef`. diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index e8071f5a..24116e99 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -33,9 +33,9 @@ final approvalsRef = approvalsFlow.refWithCodec( paramsCodec: approvalDraftCodec, ); -final runId = await approvalsRef.startWith( +final runId = await approvalsRef.start( workflowApp, - const ApprovalDraft(documentId: 'doc-42'), + params: const ApprovalDraft(documentId: 'doc-42'), ); final result = await approvalsRef.waitFor(workflowApp, runId); @@ -55,7 +55,7 @@ final runId = await approvalsRef .cancellationPolicy( const WorkflowCancellationPolicy(maxRuntime: Duration(minutes: 10)), ) - .startWith(workflowApp); + .start(workflowApp); ``` `refWithJsonCodec(...)` is the shortest manual DTO path when the type already @@ -68,14 +68,14 @@ definition constructor in the common `toJson()` / `Type.fromJson(...)` case. Use `resultCodec:` only when the result needs a custom payload codec. For workflows without start params, start directly from the flow or script -itself with `startWith(...)`, `startAndWaitWith(...)`, or `startBuilder()`. +itself with `start(...)`, `startAndWait(...)`, or `startBuilder()`. Use `ref0()` when another API specifically needs a `NoArgsWorkflowRef`. ## Wait for completion For workflows defined in code, prefer direct workflow helpers or typed refs -like `ordersFlow.startAndWaitWith(...)` and -`StemWorkflowDefinitions.orders.startAndWaitWith(...)`. +like `ordersFlow.startAndWait(...)` and +`StemWorkflowDefinitions.orders.startAndWait(...)`. `waitForCompletion` is the low-level completion API for name-based runs. It polls the store until the run finishes or the caller times out. @@ -92,9 +92,9 @@ When you use `stem_builder`, generated workflow refs remove the raw workflow-name strings and give you one typed handle for both start and wait: ```dart -final result = await StemWorkflowDefinitions.userSignup.startAndWaitWith( +final result = await StemWorkflowDefinitions.userSignup.startAndWait( workflowApp, - 'user@example.com', + params: 'user@example.com', ); ``` @@ -102,9 +102,9 @@ The same definitions work on `WorkflowRuntime` by passing the runtime as the `WorkflowCaller`: ```dart -final runId = await StemWorkflowDefinitions.userSignup.startWith( +final runId = await StemWorkflowDefinitions.userSignup.start( runtime, - 'user@example.com', + params: 'user@example.com', ); ``` diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index f179e20a..559a5182 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.1 +- Added named workflow start aliases `start(...)` and `startAndWait(...)` on + workflow refs, no-args workflow refs, manual `Flow` / `WorkflowScript` + wrappers, and workflow start calls/builders. The existing + `startWith(...)` / `startAndWaitWith(...)` helpers still work. - Added `StemModule.requiredTaskQueues()` and `StemModule.requiredWorkflowQueues(...)` so bundle queue requirements can be inspected directly before app/worker bootstrap. diff --git a/packages/stem/README.md b/packages/stem/README.md index f7e078a5..3cb09c4f 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -318,7 +318,7 @@ class ParentTask implements TaskHandler { @override Future call(TaskContext context, Map args) async { - final result = await childWorkflow.startAndWaitWith(context); + final result = await childWorkflow.startAndWait(context); return result?.value ?? 'missing'; } } @@ -350,7 +350,7 @@ final app = await StemWorkflowApp.inMemory( flows: [demoWorkflow], ); -final runId = await demoWorkflow.startWith(app); +final runId = await demoWorkflow.start(app); final result = await demoWorkflow.waitFor(app, runId); print(result?.value); // 'hello world' print(result?.state.status); // WorkflowStatus.completed @@ -490,9 +490,9 @@ final app = await StemWorkflowApp.fromUrl( tasks: const [], ); -final runId = await approvalsRef.startWith( +final runId = await approvalsRef.start( app, - const ApprovalDraft(documentId: 'doc-42'), + params: const ApprovalDraft(documentId: 'doc-42'), ); final result = await approvalsRef.waitFor(app, runId); @@ -511,7 +511,7 @@ final runId = await approvalsRef .cancellationPolicy( const WorkflowCancellationPolicy(maxRuntime: Duration(minutes: 10)), ) - .startWith(app); + .start(app); ``` Use `refWithJsonCodec(...)` when your manual workflow start params are normal @@ -523,7 +523,7 @@ For workflows without start parameters, start directly from the flow or script itself: ```dart -final runId = await healthcheckFlow.startWith(app); +final runId = await healthcheckFlow.start(app); ``` If you need to pass a no-args workflow through another API, `ref0()` still @@ -633,7 +633,7 @@ Durable workflow contexts enqueue tasks directly: Child workflows belong in durable execution boundaries: - `FlowContext` and `WorkflowScriptStepContext` both implement - `WorkflowCaller`, so prefer `ref.startAndWaitWith(context, value)` inside + `WorkflowCaller`, so prefer `ref.startAndWait(context, params: value)` inside flow steps and script checkpoints - use `context.startWorkflowBuilder(...)` when you need advanced overrides like `ttl(...)` or `cancellationPolicy(...)` @@ -686,9 +686,9 @@ final app = await StemWorkflowApp.fromUrl( module: stemModule, ); -final result = await StemWorkflowDefinitions.userSignup.startAndWaitWith( +final result = await StemWorkflowDefinitions.userSignup.startAndWait( app, - 'user@example.com', + params: 'user@example.com', ); print(result?.value); await app.close(); @@ -822,7 +822,7 @@ representing the value they produce. For workflows you define in code, prefer their direct helpers or typed refs: ```dart -final result = await ordersWorkflow.startAndWaitWith(app); +final result = await ordersWorkflow.startAndWait(app); print(result.value?.total); ``` diff --git a/packages/stem/example/annotated_workflows/README.md b/packages/stem/example/annotated_workflows/README.md index 8791ffeb..1ced0d00 100644 --- a/packages/stem/example/annotated_workflows/README.md +++ b/packages/stem/example/annotated_workflows/README.md @@ -6,7 +6,7 @@ with the `stem_builder` bundle generator. It now demonstrates the generated script-proxy behavior explicitly: - a flow step using `FlowContext` - a flow step starting and waiting on a child workflow through - `StemWorkflowDefinitions.*.startAndWaitWith(context, value)` + `StemWorkflowDefinitions.*.startAndWait(context, params: value)` - `run(WelcomeRequest request)` calls annotated checkpoint methods directly - `prepareWelcome(...)` calls other annotated checkpoints - `deliverWelcome(...)` calls another annotated checkpoint from inside an @@ -16,7 +16,7 @@ It now demonstrates the generated script-proxy behavior explicitly: expose `runId`, `workflow`, `stepName`, `stepIndex`, and idempotency keys while still calling its annotated checkpoint directly from `run(...)` - a script checkpoint starting and waiting on a child workflow through - `StemWorkflowDefinitions.*.startAndWaitWith(context, value)` + `StemWorkflowDefinitions.*.startAndWait(context, params: value)` - a plain script workflow that returns a codec-backed DTO result and persists a codec-backed DTO checkpoint value - a typed `@TaskDefn` using optional named `TaskInvocationContext? context` diff --git a/packages/stem/example/annotated_workflows/bin/main.dart b/packages/stem/example/annotated_workflows/bin/main.dart index afeecaa1..dbc75bf6 100644 --- a/packages/stem/example/annotated_workflows/bin/main.dart +++ b/packages/stem/example/annotated_workflows/bin/main.dart @@ -7,7 +7,7 @@ Future main() async { final client = await StemClient.inMemory(module: stemModule); final app = await client.createWorkflowApp(); - final flowRunId = await StemWorkflowDefinitions.flow.startWith(app); + final flowRunId = await StemWorkflowDefinitions.flow.start(app); final flowResult = await StemWorkflowDefinitions.flow.waitFor( app, flowRunId, @@ -19,9 +19,9 @@ Future main() async { '${jsonEncode(flowResult?.value?['childResult'])}', ); - final scriptResult = await StemWorkflowDefinitions.script.startAndWaitWith( + final scriptResult = await StemWorkflowDefinitions.script.startAndWait( app, - const WelcomeRequest(email: ' SomeEmail@Example.com '), + params: const WelcomeRequest(email: ' SomeEmail@Example.com '), timeout: const Duration(seconds: 2), ); print('Script result: ${jsonEncode(scriptResult?.value?.toJson())}'); @@ -43,11 +43,11 @@ Future main() async { print('Script detail: ${jsonEncode(scriptDetail?.toJson())}'); final contextResult = await StemWorkflowDefinitions.contextScript - .startAndWaitWith( - app, - const WelcomeRequest(email: ' ContextEmail@Example.com '), - timeout: const Duration(seconds: 2), - ); + .startAndWait( + app, + params: const WelcomeRequest(email: ' ContextEmail@Example.com '), + timeout: const Duration(seconds: 2), + ); print('Context script result: ${jsonEncode(contextResult?.value?.toJson())}'); final contextDetail = await app.viewRunDetail(contextResult!.runId); @@ -64,16 +64,16 @@ Future main() async { final typedTaskResult = await StemTaskDefinitions.sendEmailTyped .enqueueAndWait( - app, - const EmailDispatch( - email: 'typed@example.com', - subject: 'Welcome', - body: 'Codec-backed DTO payloads', - tags: ['welcome', 'transactional', 'annotated'], - ), - meta: const {'origin': 'annotated_workflows_example'}, - timeout: const Duration(seconds: 2), - ); + app, + const EmailDispatch( + email: 'typed@example.com', + subject: 'Welcome', + body: 'Codec-backed DTO payloads', + tags: ['welcome', 'transactional', 'annotated'], + ), + meta: const {'origin': 'annotated_workflows_example'}, + timeout: const Duration(seconds: 2), + ); print('Typed task result: ${jsonEncode(typedTaskResult?.value?.toJson())}'); await app.close(); diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index c94cbcf8..163c1efb 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -88,6 +88,21 @@ class Flow { ); } + /// Starts this flow directly when it does not accept start params. + Future start( + WorkflowCaller caller, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return startWith( + caller, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + /// Starts this flow directly and waits for completion. Future?> startAndWaitWith( WorkflowCaller caller, { @@ -107,6 +122,25 @@ class Flow { ); } + /// Starts this flow directly and waits for completion. + Future?> startAndWait( + WorkflowCaller caller, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return startAndWaitWith( + caller, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + pollInterval: pollInterval, + timeout: timeout, + ); + } + /// Waits for [runId] using this flow's result decoding rules. Future?> waitFor( WorkflowCaller caller, diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 5d6883da..8f0f8f9c 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -39,13 +39,12 @@ class WorkflowRef { String? paramsTypeName, String? resultTypeName, }) { - final resultCodec = - decodeResultJson == null - ? null - : PayloadCodec.json( - decode: decodeResultJson, - typeName: resultTypeName ?? '$TResult', - ); + final resultCodec = decodeResultJson == null + ? null + : PayloadCodec.json( + decode: decodeResultJson, + typeName: resultTypeName ?? '$TResult', + ); return WorkflowRef.withPayloadCodec( name: name, paramsCodec: PayloadCodec.json( @@ -144,6 +143,23 @@ class WorkflowRef { ).startWith(caller); } + /// Starts this workflow ref directly with [caller] using named args. + Future start( + WorkflowCaller caller, { + required TParams params, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return startWith( + caller, + params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + /// Starts this workflow ref with [caller] and waits for the result. Future?> startAndWaitWith( WorkflowCaller caller, @@ -165,6 +181,28 @@ class WorkflowRef { timeout: timeout, ); } + + /// Starts this workflow ref with [caller] and waits for the result using + /// named args. + Future?> startAndWait( + WorkflowCaller caller, { + required TParams params, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return startAndWaitWith( + caller, + params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + pollInterval: pollInterval, + timeout: timeout, + ); + } } /// Typed producer-facing reference for workflows that take no input params. @@ -222,6 +260,21 @@ class NoArgsWorkflowRef { ).startWith(caller); } + /// Starts this workflow ref directly with [caller]. + Future start( + WorkflowCaller caller, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return startWith( + caller, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + /// Starts this workflow ref with [caller] and waits for the result. Future?> startAndWaitWith( WorkflowCaller caller, { @@ -242,6 +295,25 @@ class NoArgsWorkflowRef { ); } + /// Starts this workflow ref with [caller] and waits for the result. + Future?> startAndWait( + WorkflowCaller caller, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return startAndWaitWith( + caller, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + pollInterval: pollInterval, + timeout: timeout, + ); + } + /// Decodes a final workflow result payload. TResult decode(Object? payload) => asRef.decode(payload); @@ -383,11 +455,29 @@ class WorkflowStartBuilder { /// Convenience helpers for dispatching prebuilt [WorkflowStartCall] instances. extension WorkflowStartCallExtension on WorkflowStartCall { + /// Starts this typed workflow call with the provided [caller]. + Future start(WorkflowCaller caller) { + return startWith(caller); + } + /// Starts this typed workflow call with the provided [caller]. Future startWith(WorkflowCaller caller) { return caller.startWorkflowCall(this); } + /// Starts this typed workflow call with [caller] and waits for the result. + Future?> startAndWait( + WorkflowCaller caller, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return startAndWaitWith( + caller, + pollInterval: pollInterval, + timeout: timeout, + ); + } + /// Starts this typed workflow call with [caller] and waits for the result. Future?> startAndWaitWith( WorkflowCaller caller, { @@ -407,11 +497,30 @@ extension WorkflowStartCallExtension /// Convenience helpers for dispatching [WorkflowStartBuilder] instances. extension WorkflowStartBuilderExtension on WorkflowStartBuilder { + /// Builds this workflow call and starts it with the provided [caller]. + Future start(WorkflowCaller caller) { + return startWith(caller); + } + /// Builds this workflow call and starts it with the provided [caller]. Future startWith(WorkflowCaller caller) { return build().startWith(caller); } + /// Builds this workflow call, starts it with [caller], and waits for the + /// result. + Future?> startAndWait( + WorkflowCaller caller, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return startAndWaitWith( + caller, + pollInterval: pollInterval, + timeout: timeout, + ); + } + /// Builds this workflow call, starts it with [caller], and waits for the /// result. Future?> startAndWaitWith( diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index d6d76c6d..e7d548b7 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -90,6 +90,21 @@ class WorkflowScript { ); } + /// Starts this script directly when it does not accept start params. + Future start( + WorkflowCaller caller, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return startWith( + caller, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + /// Starts this script directly and waits for completion. Future?> startAndWaitWith( WorkflowCaller caller, { @@ -109,6 +124,25 @@ class WorkflowScript { ); } + /// Starts this script directly and waits for completion. + Future?> startAndWait( + WorkflowCaller caller, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return startAndWaitWith( + caller, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + pollInterval: pollInterval, + timeout: timeout, + ); + } + /// Waits for [runId] using this script's result decoding rules. Future?> waitFor( WorkflowCaller caller, diff --git a/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart b/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart index 8ade4dca..93511777 100644 --- a/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart @@ -52,46 +52,92 @@ void main() { test( 'WorkflowRef direct helpers mirror WorkflowStartCall dispatch', () async { - final flow = Flow( - name: 'runtime.extension.direct.flow', - build: (builder) { - builder.step('hello', (ctx) async { - final name = ctx.params['name'] as String? ?? 'world'; - return 'hello $name'; - }); - }, - ); - final workflowRef = WorkflowRef, String>( - name: 'runtime.extension.direct.flow', - encodeParams: (params) => params, - ); - - final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); - try { - await workflowApp.start(); - - final runId = await workflowRef.startWith( - workflowApp.runtime, - const {'name': 'runtime'}, + final flow = Flow( + name: 'runtime.extension.direct.flow', + build: (builder) { + builder.step('hello', (ctx) async { + final name = ctx.params['name'] as String? ?? 'world'; + return 'hello $name'; + }); + }, ); - final waited = await workflowRef.waitFor( - workflowApp.runtime, - runId, - timeout: const Duration(seconds: 2), + final workflowRef = WorkflowRef, String>( + name: 'runtime.extension.direct.flow', + encodeParams: (params) => params, ); - expect(waited?.value, 'hello runtime'); + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + await workflowApp.start(); + + final runId = await workflowRef.startWith( + workflowApp.runtime, + const {'name': 'runtime'}, + ); + final waited = await workflowRef.waitFor( + workflowApp.runtime, + runId, + timeout: const Duration(seconds: 2), + ); + + expect(waited?.value, 'hello runtime'); + + final oneShot = await workflowRef.startAndWaitWith( + workflowApp.runtime, + const {'name': 'inline'}, + timeout: const Duration(seconds: 2), + ); - final oneShot = await workflowRef.startAndWaitWith( - workflowApp.runtime, - const {'name': 'inline'}, - timeout: const Duration(seconds: 2), + expect(oneShot?.value, 'hello inline'); + } finally { + await workflowApp.shutdown(); + } + }, + ); + + test( + 'named workflow start aliases mirror the with-suffixed helpers', + () async { + final flow = Flow( + name: 'runtime.extension.named.flow', + build: (builder) { + builder.step('hello', (ctx) async { + final name = ctx.params['name'] as String? ?? 'world'; + return 'hello $name'; + }); + }, ); + final workflowRef = WorkflowRef, String>( + name: 'runtime.extension.named.flow', + encodeParams: (params) => params, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + await workflowApp.start(); - expect(oneShot?.value, 'hello inline'); - } finally { - await workflowApp.shutdown(); - } + final runId = await workflowRef.start( + workflowApp.runtime, + params: const {'name': 'runtime'}, + ); + final waited = await workflowRef.waitFor( + workflowApp.runtime, + runId, + timeout: const Duration(seconds: 2), + ); + + expect(waited?.value, 'hello runtime'); + + final oneShot = await workflowRef.startAndWait( + workflowApp.runtime, + params: const {'name': 'inline'}, + timeout: const Duration(seconds: 2), + ); + + expect(oneShot?.value, 'hello inline'); + } finally { + await workflowApp.shutdown(); + } }, ); }); diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index c53ea22b..e39672a0 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -272,11 +272,11 @@ void main() { try { await workflowApp.start(); - final flowResult = await flow.startAndWaitWith( + final flowResult = await flow.startAndWait( workflowApp, timeout: const Duration(seconds: 2), ); - final scriptRunId = await script.startWith(workflowApp.runtime); + final scriptRunId = await script.start(workflowApp.runtime); final scriptResult = await script.waitFor( workflowApp.runtime, scriptRunId, From a4aba652df1fd3d8f8e5dac6ef25261f5e476e17 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 05:42:47 -0500 Subject: [PATCH 143/302] Add json result helpers to manual workflow refs --- .site/docs/workflows/starting-and-waiting.md | 9 ++--- packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 9 ++--- packages/stem/lib/src/workflow/core/flow.dart | 4 +++ .../workflow/core/workflow_definition.dart | 4 +++ .../src/workflow/core/workflow_script.dart | 4 +++ .../workflow/workflow_runtime_ref_test.dart | 34 +++++++++++++++++++ 7 files changed, 59 insertions(+), 8 deletions(-) diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 24116e99..b3d05a74 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -58,10 +58,11 @@ final runId = await approvalsRef .start(workflowApp); ``` -`refWithJsonCodec(...)` is the shortest manual DTO path when the type already -has `toJson()` and `Type.fromJson(...)`. Use `refWithCodec(...)` when you need -a custom `PayloadCodec`. Workflow params still need to encode to -`Map` because they are stored as a map. +`refWithJsonCodec(...)` is the shortest manual DTO path when the params or +final result already have `toJson()` and `Type.fromJson(...)`. Use +`refWithCodec(...)` when you need a custom `PayloadCodec`. Workflow params +still need to encode to `Map` because they are stored as a +map. If a manual flow or script returns a DTO, prefer `decodeResultJson:` on the definition constructor in the common `toJson()` / `Type.fromJson(...)` case. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 559a5182..e0d413cd 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Added `decodeResultJson:` support to manual `refWithJsonCodec(...)` helpers + on `WorkflowDefinition`, `Flow`, and `WorkflowScript`, so DTO result + decoding can now live entirely on the typed workflow ref path. - Added named workflow start aliases `start(...)` and `startAndWait(...)` on workflow refs, no-args workflow refs, manual `Flow` / `WorkflowScript` wrappers, and workflow start calls/builders. The existing diff --git a/packages/stem/README.md b/packages/stem/README.md index 3cb09c4f..d9ce2ad5 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -514,10 +514,11 @@ final runId = await approvalsRef .start(app); ``` -Use `refWithJsonCodec(...)` when your manual workflow start params are normal -DTOs with `toJson()` and `Type.fromJson(...)`. Drop down to `refWithCodec(...)` -when you need a custom `PayloadCodec`. Workflow params still need to encode -to `Map` because they are persisted as a map. +Use `refWithJsonCodec(...)` when your manual workflow start params or final +result are normal DTOs with `toJson()` and `Type.fromJson(...)`. Drop down to +`refWithCodec(...)` when you need a custom `PayloadCodec`. Workflow params +still need to encode to `Map` because they are persisted as a +map. For workflows without start parameters, start directly from the flow or script itself: diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index 163c1efb..c1c4d947 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -55,11 +55,15 @@ class Flow { /// `toJson()` and `Type.fromJson(...)`. WorkflowRef refWithJsonCodec({ required TParams Function(Map payload) decodeParams, + T Function(Map payload)? decodeResultJson, String? paramsTypeName, + String? resultTypeName, }) { return definition.refWithJsonCodec( decodeParams: decodeParams, + decodeResultJson: decodeResultJson, paramsTypeName: paramsTypeName, + resultTypeName: resultTypeName, ); } diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index 0f9b50ae..69a8627a 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -348,13 +348,17 @@ class WorkflowDefinition { /// `toJson()` and `Type.fromJson(...)`. WorkflowRef refWithJsonCodec({ required TParams Function(Map payload) decodeParams, + T Function(Map payload)? decodeResultJson, String? paramsTypeName, + String? resultTypeName, }) { return WorkflowRef.withJsonCodec( name: name, decodeParams: decodeParams, + decodeResultJson: decodeResultJson, decodeResult: (payload) => decodeResult(payload) as T, paramsTypeName: paramsTypeName, + resultTypeName: resultTypeName, ); } diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index e7d548b7..30634540 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -57,11 +57,15 @@ class WorkflowScript { /// `toJson()` and `Type.fromJson(...)`. WorkflowRef refWithJsonCodec({ required TParams Function(Map payload) decodeParams, + T Function(Map payload)? decodeResultJson, String? paramsTypeName, + String? resultTypeName, }) { return definition.refWithJsonCodec( decodeParams: decodeParams, + decodeResultJson: decodeResultJson, paramsTypeName: paramsTypeName, + resultTypeName: resultTypeName, ); } diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index e39672a0..d3917172 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -181,6 +181,40 @@ void main() { } }); + test( + 'manual workflows can derive json-backed refs with result decoding', + () async { + final flow = Flow<_GreetingResult>( + name: 'runtime.ref.json.ref-result.flow', + build: (builder) { + builder.step( + 'hello', + (ctx) async => const _GreetingResult(message: 'hello ref json'), + ); + }, + ); + final workflowRef = flow.refWithJsonCodec<_GreetingParams>( + decodeParams: _GreetingParams.fromJson, + decodeResultJson: _GreetingResult.fromJson, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + await workflowApp.start(); + + final result = await workflowRef.startAndWait( + workflowApp.runtime, + params: const _GreetingParams(name: 'ignored'), + timeout: const Duration(seconds: 2), + ); + + expect(result?.value?.message, 'hello ref json'); + } finally { + await workflowApp.shutdown(); + } + }, + ); + test('codec-backed refs preserve workflow result decoding', () async { final flow = Flow<_GreetingResult>( name: 'runtime.ref.codec.result.flow', From 77ff2422fe0be5a00480c86e1af47816652c1fdf Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 05:44:50 -0500 Subject: [PATCH 144/302] Add plain app registration helpers --- .site/docs/core-concepts/stem-builder.md | 16 ++++++ packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 6 +++ packages/stem/lib/src/bootstrap/stem_app.dart | 22 ++++++++ .../stem/test/bootstrap/stem_app_test.dart | 51 +++++++++++++++++++ 5 files changed, 98 insertions(+) diff --git a/.site/docs/core-concepts/stem-builder.md b/.site/docs/core-concepts/stem-builder.md index 7273daa3..3fbbc5bd 100644 --- a/.site/docs/core-concepts/stem-builder.md +++ b/.site/docs/core-concepts/stem-builder.md @@ -118,6 +118,22 @@ final workflowApp = await StemWorkflowApp.inMemory( ); ``` +The same bundle-first path works for plain task apps too: + +```dart +final taskApp = await StemApp.fromUrl( + 'redis://localhost:6379', + adapters: const [StemRedisAdapter()], + module: stemModule, +); +``` + +If you need to attach generated or hand-written task definitions after +bootstrap, use the app helpers: + +- `registerTask(...)` / `registerTasks(...)` +- `registerModule(...)` / `registerModules(...)` + When debugging bootstrap wiring, inspect the queue set a bundle implies before you create the app: diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index e0d413cd..76b26578 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Added `StemApp.registerTask(...)`, `registerTasks(...)`, `registerModule(...)`, + and `registerModules(...)` so plain task apps now have the same late + registration ergonomics as `StemWorkflowApp`. - Added `decodeResultJson:` support to manual `refWithJsonCodec(...)` helpers on `WorkflowDefinition`, `Flow`, and `WorkflowScript`, so DTO result decoding can now live entirely on the typed workflow ref path. diff --git a/packages/stem/README.md b/packages/stem/README.md index d9ce2ad5..aae77714 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -723,6 +723,12 @@ final taskApp = await StemApp.fromUrl( `app.canvas` dispatch call, so you only need `await taskApp.start()` when you want explicit lifecycle control. +For late registration on the plain task side, prefer the app helpers instead +of reaching through the registry: + +- `registerTask(...)` / `registerTasks(...)` +- `registerModule(...)` / `registerModules(...)` + When you bootstrap a plain `StemApp`, the worker infers task queue subscriptions from the bundled or explicitly supplied task handlers. Set `workerConfig.subscription` explicitly only when you need broader routing. diff --git a/packages/stem/lib/src/bootstrap/stem_app.dart b/packages/stem/lib/src/bootstrap/stem_app.dart index 0603bfea..ec55e60b 100644 --- a/packages/stem/lib/src/bootstrap/stem_app.dart +++ b/packages/stem/lib/src/bootstrap/stem_app.dart @@ -72,6 +72,28 @@ class StemApp implements StemTaskApp { /// Registers an additional task handler with the underlying registry. void register(TaskHandler handler) => registry.register(handler); + /// Registers [handler] with the underlying registry. + void registerTask(TaskHandler handler) => register(handler); + + /// Registers [handlers] with the underlying registry. + void registerTasks(Iterable> handlers) { + handlers.forEach(register); + } + + /// Registers all task handlers from [module] into this app. + void registerModule(StemModule module) { + registerTasks(module.tasks); + } + + /// Registers all task handlers from [modules] into this app. + void registerModules(Iterable modules) { + final merged = StemModule.combine(modules: modules); + if (merged == null) { + return; + } + registerModule(merged); + } + @override Future enqueue( String name, { diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index dcb013c0..a6de0231 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -159,6 +159,57 @@ void main() { } }); + test('StemApp exposes task registration helpers', () async { + final directHandler = FunctionTaskHandler( + name: 'test.register.direct', + entrypoint: (context, args) async => 'direct-ok', + runInIsolate: false, + ); + final moduleHandler = FunctionTaskHandler( + name: 'test.register.module', + entrypoint: (context, args) async => 'module-ok', + runInIsolate: false, + ); + final extraHandler = FunctionTaskHandler( + name: 'test.register.extra', + entrypoint: (context, args) async => 'extra-ok', + runInIsolate: false, + ); + + final app = await StemApp.inMemory(); + try { + app + ..registerTask(directHandler) + ..registerModule(StemModule(tasks: [moduleHandler])) + ..registerModules([ + StemModule(tasks: [extraHandler]), + ]); + + final directTaskId = await app.enqueue('test.register.direct'); + final directResult = await app.waitForTask( + directTaskId, + timeout: const Duration(seconds: 2), + ); + expect(directResult?.value, 'direct-ok'); + + final moduleTaskId = await app.enqueue('test.register.module'); + final moduleResult = await app.waitForTask( + moduleTaskId, + timeout: const Duration(seconds: 2), + ); + expect(moduleResult?.value, 'module-ok'); + + final extraTaskId = await app.enqueue('test.register.extra'); + final extraResult = await app.waitForTask( + extraTaskId, + timeout: const Duration(seconds: 2), + ); + expect(extraResult?.value, 'extra-ok'); + } finally { + await app.shutdown(); + } + }); + test('inMemory applies worker config overrides', () async { final handler = FunctionTaskHandler( name: 'test.worker-config', From 7c75a1ec2d076fe09f960d054dd34dcbb49cc777 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 05:46:44 -0500 Subject: [PATCH 145/302] Prefer direct workflow start aliases in examples --- packages/stem/CHANGELOG.md | 2 ++ packages/stem/example/docs_snippets/lib/workflows.dart | 4 ++-- packages/stem/example/durable_watchers.dart | 2 +- packages/stem/example/workflows/basic_in_memory.dart | 2 +- packages/stem/example/workflows/custom_factories.dart | 2 +- packages/stem/example/workflows/sleep_and_event.dart | 2 +- packages/stem/example/workflows/sqlite_store.dart | 2 +- packages/stem/example/workflows/versioned_rewind.dart | 2 +- 8 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 76b26578..b0fd82e1 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.1.1 +- Refreshed the public workflow examples and docs snippets to prefer the direct + `start(...)` alias over the older `startWith(...)` helper in the happy path. - Added `StemApp.registerTask(...)`, `registerTasks(...)`, `registerModule(...)`, and `registerModules(...)` so plain task apps now have the same late registration ergonomics as `StemWorkflowApp`. diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index 4b39c35e..02e0f639 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -122,9 +122,9 @@ Future registerScript(StemWorkflowApp workflowApp) async { // #region workflows-run Future runWorkflow(StemWorkflowApp workflowApp) async { - final runId = await ApprovalsFlow.ref.startWith( + final runId = await ApprovalsFlow.ref.start( workflowApp, - const ApprovalDraft(documentId: 'doc-42'), + params: const ApprovalDraft(documentId: 'doc-42'), cancellationPolicy: const WorkflowCancellationPolicy( maxRunDuration: Duration(hours: 2), maxSuspendDuration: Duration(minutes: 30), diff --git a/packages/stem/example/durable_watchers.dart b/packages/stem/example/durable_watchers.dart index 881e8ed0..d73f0943 100644 --- a/packages/stem/example/durable_watchers.dart +++ b/packages/stem/example/durable_watchers.dart @@ -38,7 +38,7 @@ Future main() async { final runId = await shipmentWorkflowRef .call(const {'orderId': 'A-123'}) - .startWith(app); + .start(app); // Drive the run until it suspends on the watcher. await app.executeRun(runId); diff --git a/packages/stem/example/workflows/basic_in_memory.dart b/packages/stem/example/workflows/basic_in_memory.dart index d197eb4c..08477098 100644 --- a/packages/stem/example/workflows/basic_in_memory.dart +++ b/packages/stem/example/workflows/basic_in_memory.dart @@ -15,7 +15,7 @@ Future main() async { flows: [basicHello], ); - final runId = await basicHello.startWith(app); + final runId = await basicHello.start(app); final result = await basicHello.waitFor(app, runId); print('Workflow $runId finished with result: ${result?.value}'); diff --git a/packages/stem/example/workflows/custom_factories.dart b/packages/stem/example/workflows/custom_factories.dart index 7938a249..33e9238a 100644 --- a/packages/stem/example/workflows/custom_factories.dart +++ b/packages/stem/example/workflows/custom_factories.dart @@ -22,7 +22,7 @@ Future main() async { ); try { - final runId = await redisWorkflow.startWith(app); + final runId = await redisWorkflow.start(app); final result = await redisWorkflow.waitFor(app, runId); print('Workflow $runId finished with result: ${result?.value}'); } finally { diff --git a/packages/stem/example/workflows/sleep_and_event.dart b/packages/stem/example/workflows/sleep_and_event.dart index 86f860f4..5f663850 100644 --- a/packages/stem/example/workflows/sleep_and_event.dart +++ b/packages/stem/example/workflows/sleep_and_event.dart @@ -30,7 +30,7 @@ Future main() async { flows: [sleepAndEvent], ); - final runId = await sleepAndEvent.startWith(app); + final runId = await sleepAndEvent.start(app); // Wait until the workflow is suspended before emitting the event to avoid // losing the signal. diff --git a/packages/stem/example/workflows/sqlite_store.dart b/packages/stem/example/workflows/sqlite_store.dart index 6120ac2e..e549468d 100644 --- a/packages/stem/example/workflows/sqlite_store.dart +++ b/packages/stem/example/workflows/sqlite_store.dart @@ -21,7 +21,7 @@ Future main() async { ); try { - final runId = await sqliteExample.startWith(app); + final runId = await sqliteExample.start(app); final result = await sqliteExample.waitFor(app, runId); print('Workflow $runId finished with result: ${result?.value}'); } finally { diff --git a/packages/stem/example/workflows/versioned_rewind.dart b/packages/stem/example/workflows/versioned_rewind.dart index bc1c0fdd..174a7115 100644 --- a/packages/stem/example/workflows/versioned_rewind.dart +++ b/packages/stem/example/workflows/versioned_rewind.dart @@ -18,7 +18,7 @@ Future main() async { flows: [versionedWorkflow], ); - final runId = await versionedWorkflow.startWith(app); + final runId = await versionedWorkflow.start(app); await app.executeRun(runId); // Rewind and execute again to append a new iteration checkpoint. From 55b7278a1ed4710975022a2507892a6860505538 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 05:48:06 -0500 Subject: [PATCH 146/302] Prefer direct child workflow aliases in docs --- .site/docs/core-concepts/tasks.md | 6 +++--- packages/stem/CHANGELOG.md | 2 ++ .../stem/example/annotated_workflows/lib/definitions.dart | 8 ++++---- packages/stem/example/ecommerce/README.md | 2 +- packages/stem/example/ecommerce/lib/src/app.dart | 5 ++--- packages/stem_builder/CHANGELOG.md | 2 ++ packages/stem_builder/README.md | 6 +++--- 7 files changed, 17 insertions(+), 14 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 32ed0042..90c6f698 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -132,9 +132,9 @@ When a task runs inside a workflow-enabled runtime like `StemWorkflowApp`, both `TaskContext` and `TaskInvocationContext` also implement `WorkflowCaller`, so handlers and isolate entrypoints can start or wait for typed child workflows without dropping to raw workflow-name APIs. For manual -flows and scripts, prefer `childFlow.startAndWaitWith(context)` or -`childWorkflowRef.startAndWaitWith(context, value)` for the simple case. Use a -builder only when you need advanced overrides. +flows and scripts, prefer `childFlow.startAndWait(context)` or +`childWorkflowRef.startAndWait(context, params: value)` for the simple case. +Use a builder only when you need advanced overrides. Those same contexts also implement `WorkflowEventEmitter`, so tasks can resume waiting workflows through `emitValue(...)` or typed `WorkflowEventRef` diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index b0fd82e1..7f768c02 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.1.1 +- Refreshed child-workflow examples and docs to prefer the direct + `startAndWait(...)` alias over `startAndWaitWith(...)` in the common case. - Refreshed the public workflow examples and docs snippets to prefer the direct `start(...)` alias over the older `startWith(...)` helper in the happy path. - Added `StemApp.registerTask(...)`, `registerTasks(...)`, `registerModule(...)`, diff --git a/packages/stem/example/annotated_workflows/lib/definitions.dart b/packages/stem/example/annotated_workflows/lib/definitions.dart index be9c4127..cdf0c476 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.dart @@ -194,9 +194,9 @@ class AnnotatedFlowWorkflow { if (!ctx.sleepUntilResumed(const Duration(milliseconds: 50))) { return null; } - final childResult = await StemWorkflowDefinitions.script.startAndWaitWith( + final childResult = await StemWorkflowDefinitions.script.startAndWait( ctx, - const WelcomeRequest(email: 'flow-child@example.com'), + params: const WelcomeRequest(email: 'flow-child@example.com'), timeout: const Duration(seconds: 2), ); return { @@ -274,9 +274,9 @@ class AnnotatedContextScriptWorkflow { final ctx = context!; final normalizedEmail = await normalizeEmail(request.email); final subject = await buildWelcomeSubject(normalizedEmail); - final childResult = await StemWorkflowDefinitions.script.startAndWaitWith( + final childResult = await StemWorkflowDefinitions.script.startAndWait( ctx, - WelcomeRequest(email: normalizedEmail), + params: WelcomeRequest(email: normalizedEmail), timeout: const Duration(seconds: 2), ); return ContextCaptureResult( diff --git a/packages/stem/example/ecommerce/README.md b/packages/stem/example/ecommerce/README.md index 59250867..af8027a7 100644 --- a/packages/stem/example/ecommerce/README.md +++ b/packages/stem/example/ecommerce/README.md @@ -35,7 +35,7 @@ From those annotations, this example uses generated APIs: - `stemModule` (generated workflow/task bundle) - `StemWorkflowDefinitions.addToCart` -- `StemWorkflowDefinitions.addToCart.startAndWaitWith(...)` +- `StemWorkflowDefinitions.addToCart.startAndWait(...)` - `StemTaskDefinitions.ecommerceAuditLog` - direct task definition helpers like `StemTaskDefinitions.ecommerceAuditLog.enqueue(...)` diff --git a/packages/stem/example/ecommerce/lib/src/app.dart b/packages/stem/example/ecommerce/lib/src/app.dart index c0c0c30b..8eef6f5f 100644 --- a/packages/stem/example/ecommerce/lib/src/app.dart +++ b/packages/stem/example/ecommerce/lib/src/app.dart @@ -93,10 +93,9 @@ class EcommerceServer { final sku = payload['sku']?.toString() ?? ''; final quantity = _toInt(payload['quantity']); - final result = await StemWorkflowDefinitions.addToCart - .startAndWaitWith( + final result = await StemWorkflowDefinitions.addToCart.startAndWait( workflowApp, - (cartId: cartId, sku: sku, quantity: quantity), + params: (cartId: cartId, sku: sku, quantity: quantity), timeout: const Duration(seconds: 4), ); diff --git a/packages/stem_builder/CHANGELOG.md b/packages/stem_builder/CHANGELOG.md index f3ba011f..1f78c096 100644 --- a/packages/stem_builder/CHANGELOG.md +++ b/packages/stem_builder/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.1.0 +- Refreshed generated child-workflow examples and docs to prefer the direct + `startAndWait(...)` alias in the common case. - Switched generated DTO payload codecs to the shorter `PayloadCodec.json(...)` form for types that already expose `toJson()` and `fromJson(...)`. diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index 4ee05c13..2919bc50 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -112,7 +112,7 @@ Durable workflow contexts enqueue tasks directly: Child workflows should be started from durable boundaries: - `ref.startWith(context, value)` inside flow steps -- `ref.startAndWaitWith(context, value)` inside script checkpoints +- `ref.startAndWait(context, params: value)` inside script checkpoints - use `context.startWorkflowBuilder(...)` when you need advanced overrides like `ttl(...)` or `cancellationPolicy(...)` @@ -196,9 +196,9 @@ final workflowApp = await StemWorkflowApp.fromUrl( module: stemModule, ); -final result = await StemWorkflowDefinitions.userSignup.startAndWaitWith( +final result = await StemWorkflowDefinitions.userSignup.startAndWait( workflowApp, - 'user@example.com', + params: 'user@example.com', ); ``` From 808a06ce622096770cbd52d3c035447da6421764 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 05:48:56 -0500 Subject: [PATCH 147/302] Prefer direct start aliases in workflow docs --- packages/stem/CHANGELOG.md | 2 ++ packages/stem/example/docs_snippets/lib/workflows.dart | 2 +- packages/stem/example/ecommerce/lib/src/app.dart | 5 ++++- packages/stem/example/persistent_sleep.dart | 2 +- packages/stem_builder/CHANGELOG.md | 2 ++ packages/stem_builder/README.md | 9 ++++++--- 6 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 7f768c02..bc8af23d 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.1.1 +- Refreshed the remaining simple workflow examples to use direct `start(...)` + aliases instead of `startWith(...)` where no advanced overrides are needed. - Refreshed child-workflow examples and docs to prefer the direct `startAndWait(...)` alias over `startAndWaitWith(...)` in the common case. - Refreshed the public workflow examples and docs snippets to prefer the direct diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index 02e0f639..93fe8986 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -266,7 +266,7 @@ Future main() async { final app = await StemWorkflowApp.inMemory(flows: [demoFlow]); - final runId = await demoFlow.startWith(app); + final runId = await demoFlow.start(app); final result = await demoFlow.waitFor( app, runId, diff --git a/packages/stem/example/ecommerce/lib/src/app.dart b/packages/stem/example/ecommerce/lib/src/app.dart index 8eef6f5f..3d501be8 100644 --- a/packages/stem/example/ecommerce/lib/src/app.dart +++ b/packages/stem/example/ecommerce/lib/src/app.dart @@ -130,7 +130,10 @@ class EcommerceServer { }) ..post('/checkout/', (Request request, String cartId) async { try { - final runId = await checkoutWorkflow.call(cartId).startWith(workflowApp); + final runId = await checkoutWorkflow.start( + workflowApp, + params: cartId, + ); final result = await checkoutWorkflow.waitFor( workflowApp, diff --git a/packages/stem/example/persistent_sleep.dart b/packages/stem/example/persistent_sleep.dart index 815bc548..340a4b06 100644 --- a/packages/stem/example/persistent_sleep.dart +++ b/packages/stem/example/persistent_sleep.dart @@ -25,7 +25,7 @@ Future main() async { flows: [sleepLoop], ); - final runId = await sleepLoop.startWith(app); + final runId = await sleepLoop.start(app); await app.executeRun(runId); // After the delay elapses, the runtime should resume without the step diff --git a/packages/stem_builder/CHANGELOG.md b/packages/stem_builder/CHANGELOG.md index 1f78c096..a3264203 100644 --- a/packages/stem_builder/CHANGELOG.md +++ b/packages/stem_builder/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.1.0 +- Refreshed the builder README examples to prefer direct `start(...)` aliases + in the common case. - Refreshed generated child-workflow examples and docs to prefer the direct `startAndWait(...)` alias in the common case. - Switched generated DTO payload codecs to the shorter diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index 2919bc50..48a4e928 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -171,7 +171,10 @@ dart run build_runner build The generated part exports a bundle plus typed refs/definitions so you can avoid raw workflow-name and task-name strings (for example -`StemWorkflowDefinitions.userSignup.startWith(workflowApp, 'user@example.com')` +`StemWorkflowDefinitions.userSignup.start( +workflowApp, +params: 'user@example.com', +)` or `StemTaskDefinitions.builderExamplePing.enqueue(stem)`). Generated output includes: @@ -266,9 +269,9 @@ generated workflow refs work there too: ```dart final runtime = workflowApp.runtime; -final runId = await StemWorkflowDefinitions.userSignup.startWith( +final runId = await StemWorkflowDefinitions.userSignup.start( runtime, - 'user@example.com', + params: 'user@example.com', ); await workflowApp.executeRun(runId); ``` From d6cef59b77acb0c4df626742ffe613c4f81adc21 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 05:49:37 -0500 Subject: [PATCH 148/302] Prefer builder start alias in docs --- packages/stem/CHANGELOG.md | 2 ++ packages/stem/example/workflows/cancellation_policy.dart | 2 +- packages/stem_builder/CHANGELOG.md | 2 ++ packages/stem_builder/README.md | 2 +- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index bc8af23d..e062c8ee 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.1.1 +- Refreshed the cancellation-policy workflow example to use the fluent builder + `start(...)` alias instead of `startWith(...)`. - Refreshed the remaining simple workflow examples to use direct `start(...)` aliases instead of `startWith(...)` where no advanced overrides are needed. - Refreshed child-workflow examples and docs to prefer the direct diff --git a/packages/stem/example/workflows/cancellation_policy.dart b/packages/stem/example/workflows/cancellation_policy.dart index 8d5aa8cf..f3deddcb 100644 --- a/packages/stem/example/workflows/cancellation_policy.dart +++ b/packages/stem/example/workflows/cancellation_policy.dart @@ -37,7 +37,7 @@ Future main() async { maxSuspendDuration: Duration(seconds: 2), ), ) - .startWith(app); + .start(app); // Wait a bit longer than the policy allows so the auto-cancel can trigger. await Future.delayed(const Duration(seconds: 4)); diff --git a/packages/stem_builder/CHANGELOG.md b/packages/stem_builder/CHANGELOG.md index a3264203..bb7d85f5 100644 --- a/packages/stem_builder/CHANGELOG.md +++ b/packages/stem_builder/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.1.0 +- Refreshed the builder README flow-step examples to prefer direct + `start(...)` aliases in the common case. - Refreshed the builder README examples to prefer direct `start(...)` aliases in the common case. - Refreshed generated child-workflow examples and docs to prefer the direct diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index 48a4e928..e80f83f0 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -111,7 +111,7 @@ Durable workflow contexts enqueue tasks directly: Child workflows should be started from durable boundaries: -- `ref.startWith(context, value)` inside flow steps +- `ref.start(context, params: value)` inside flow steps - `ref.startAndWait(context, params: value)` inside script checkpoints - use `context.startWorkflowBuilder(...)` when you need advanced overrides like `ttl(...)` or `cancellationPolicy(...)` From f387094d093f8edb5109757e4b6fb52ea57938f0 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 05:52:05 -0500 Subject: [PATCH 149/302] Add direct workflow event aliases --- .../docs/workflows/suspensions-and-events.md | 8 ++--- packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 13 ++++---- packages/stem/example/durable_watchers.dart | 4 +-- .../example/workflows/sleep_and_event.dart | 4 +-- .../src/workflow/core/workflow_event_ref.dart | 16 +++++++-- .../src/workflow/core/workflow_resume.dart | 33 ++++++++++++++++--- .../unit/workflow/workflow_resume_test.dart | 18 +++++----- .../workflow/workflow_runtime_ref_test.dart | 24 +++++++------- 9 files changed, 79 insertions(+), 44 deletions(-) diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index e7413346..ecbbde8f 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -32,7 +32,7 @@ Typical flow: `WorkflowRuntime.emitValue(...)` (or an app/service wrapper around it) with a payload 4. the runtime resumes the run and exposes the payload through - `waitForEvent(...)`, `event.waitWith(ctx)`, or the lower-level + `waitForEvent(...)`, `event.wait(ctx)`, or the lower-level `takeResumeData()` / `takeResumeValue(codec: ...)` For the common "wait for one event and continue" case, prefer: @@ -62,10 +62,10 @@ transport shape. When the topic and codec travel together in your codebase, prefer `WorkflowEventRef.json(...)` for normal DTO payloads and keep -`event.emitWith(emitter, dto)` as the happy path. `emitter.emitEventBuilder( -event: ref, value: dto).emit()` and `event.call(value).emitWith(...)` remain +`event.emit(emitter, dto)` as the happy path. `emitter.emitEventBuilder( +event: ref, value: dto).emit()` and `event.call(value).emit(...)` remain available as lower-level variants. -Pair that with `await event.waitWith(ctx)` or `awaitEventRef(...)`. +Pair that with `await event.wait(ctx)` or `awaitEventRef(...)`. ## Inspect waiting runs diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index e062c8ee..34dab93c 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Added direct workflow-event aliases `event.emit(...)`, `event.wait(...)`, + and `event.waitValue(...)`, while keeping the older `...With(...)` forms for + compatibility. - Refreshed the cancellation-policy workflow example to use the fluent builder `start(...)` alias instead of `startWith(...)`. - Refreshed the remaining simple workflow examples to use direct `start(...)` diff --git a/packages/stem/README.md b/packages/stem/README.md index aae77714..5f77ce49 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -329,7 +329,7 @@ class NotifyTask implements TaskHandler { @override Future call(TaskContext context, Map args) async { - await childReadyEvent.emitWith(context, {'status': 'ready'}); + await childReadyEvent.emit(context, {'status': 'ready'}); } } ``` @@ -419,7 +419,7 @@ Inside a script checkpoint you can access the same metadata as `FlowContext`: - `await step.sleepFor(duration: ...)` is the expression-style sleep path. - `await step.waitForEvent(topic: ..., codec: ...)` is the expression-style event wait path. -- `await event.waitWith(step)` keeps typed event waits on the +- `await event.wait(step)` keeps typed event waits on the `WorkflowEventRef` surface. - `step.sleepUntilResumed(...)` handles the common sleep-once, continue-on- resume path. @@ -1114,11 +1114,10 @@ backend metadata under `stem.unique.duplicates`. - When you have a DTO event, emit it through `workflowApp.emitValue(...)` (or `runtime.emitValue(...)` when you are intentionally using the low-level runtime) with a `PayloadCodec`, or use `WorkflowEventRef.json(...)` - as the shortest typed event form and call `event.emitWith(emitter, dto)` as - the happy path. `emitter.emitEventBuilder(event: ref, value: dto).emit()` - and `event.call(value).emitWith(...)` remain available as lower-level - variants. - Pair that with `await event.waitWith(ctx)` or `awaitEventRef(...)`. Event + as the shortest typed event form and call `event.emit(emitter, dto)` as the + happy path. `emitter.emitEventBuilder(event: ref, value: dto).emit()` and + `event.call(value).emit(...)` remain available as lower-level variants. + Pair that with `await event.wait(ctx)` or `awaitEventRef(...)`. Event payloads still serialize onto the existing `Map` wire format. - Only return values you want persisted. If a handler returns `null`, the diff --git a/packages/stem/example/durable_watchers.dart b/packages/stem/example/durable_watchers.dart index d73f0943..b9dfc76b 100644 --- a/packages/stem/example/durable_watchers.dart +++ b/packages/stem/example/durable_watchers.dart @@ -18,7 +18,7 @@ Future main() async { }); final trackingId = await script.step('wait-for-shipment', (step) async { - final payload = await shipmentReadyEvent.waitWith( + final payload = await shipmentReadyEvent.wait( step, deadline: DateTime.now().add(const Duration(minutes: 5)), data: const {'reason': 'waiting-for-carrier'}, @@ -51,7 +51,7 @@ Future main() async { print('Watcher metadata: ${watcher.data}'); } - await shipmentReadyEvent.emitWith( + await shipmentReadyEvent.emit( app, const _ShipmentReadyEvent(trackingId: 'ZX-42'), ); diff --git a/packages/stem/example/workflows/sleep_and_event.dart b/packages/stem/example/workflows/sleep_and_event.dart index 5f663850..7fd9fae7 100644 --- a/packages/stem/example/workflows/sleep_and_event.dart +++ b/packages/stem/example/workflows/sleep_and_event.dart @@ -20,7 +20,7 @@ Future main() async { return 'awake'; }) ..step('await-event', (ctx) async { - final payload = await demoEvent.waitWith(ctx); + final payload = await demoEvent.wait(ctx); return payload['message']; }); }, @@ -42,7 +42,7 @@ Future main() async { await Future.delayed(const Duration(milliseconds: 50)); } - await demoEvent.emitWith(app, const {'message': 'event received'}); + await demoEvent.emit(app, const {'message': 'event received'}); final result = await sleepAndEvent.waitFor(app, runId); print('Workflow $runId resumed and completed with: ${result?.value}'); diff --git a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart index f533c3d4..4ae8d1c7 100644 --- a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart @@ -74,17 +74,27 @@ class WorkflowEventCall { /// Convenience helpers for dispatching typed workflow events. extension WorkflowEventRefExtension on WorkflowEventRef { /// Emits this typed event with the provided [emitter]. - Future emitWith(WorkflowEventEmitter emitter, T value) { + Future emit(WorkflowEventEmitter emitter, T value) { return emitter.emitEvent(this, value); } + + /// Emits this typed event with the provided [emitter]. + Future emitWith(WorkflowEventEmitter emitter, T value) { + return emit(emitter, value); + } } /// Convenience helpers for dispatching prebuilt [WorkflowEventCall] instances. extension WorkflowEventCallExtension on WorkflowEventCall { /// Emits this typed event with the provided [emitter]. - Future emitWith(WorkflowEventEmitter emitter) { + Future emit(WorkflowEventEmitter emitter) { return emitter.emitEvent(event, value); } + + /// Emits this typed event with the provided [emitter]. + Future emitWith(WorkflowEventEmitter emitter) { + return emit(emitter); + } } /// Caller-bound typed workflow event emission call. @@ -103,7 +113,7 @@ class BoundWorkflowEventCall { WorkflowEventCall build() => _call; /// Emits the bound typed workflow event call. - Future emit() => _call.emitWith(_emitter); + Future emit() => _call.emit(_emitter); } /// Convenience helpers for building typed workflow event calls directly from a diff --git a/packages/stem/lib/src/workflow/core/workflow_resume.dart b/packages/stem/lib/src/workflow/core/workflow_resume.dart index 24b085c9..4f8e2af0 100644 --- a/packages/stem/lib/src/workflow/core/workflow_resume.dart +++ b/packages/stem/lib/src/workflow/core/workflow_resume.dart @@ -276,14 +276,14 @@ extension WorkflowScriptStepResumeValues on WorkflowScriptStepContext { /// Direct typed wait helpers on [WorkflowEventRef]. /// -/// These mirror `event.emitWith(...)` so typed workflow events can stay on the +/// These mirror `event.emit(...)` so typed workflow events can stay on the /// event-ref surface for both emit and wait paths. extension WorkflowEventRefWaitExtension on WorkflowEventRef { /// Registers an event wait and returns the resumed payload on the legacy /// null-then-resume path. /// /// [waiter] must be a [FlowContext] or [WorkflowScriptStepContext]. - T? waitValueWith( + T? waitValue( Object waiter, { DateTime? deadline, Map? data, @@ -297,15 +297,27 @@ extension WorkflowEventRefWaitExtension on WorkflowEventRef { throw ArgumentError.value( waiter, 'waiter', - 'WorkflowEventRef.waitValueWith requires a FlowContext or ' + 'WorkflowEventRef.waitValue requires a FlowContext or ' 'WorkflowScriptStepContext.', ); } + /// Registers an event wait and returns the resumed payload on the legacy + /// null-then-resume path. + /// + /// [waiter] must be a [FlowContext] or [WorkflowScriptStepContext]. + T? waitValueWith( + Object waiter, { + DateTime? deadline, + Map? data, + }) { + return waitValue(waiter, deadline: deadline, data: data); + } + /// Suspends until this event is emitted, then returns the decoded payload. /// /// [waiter] must be a [FlowContext] or [WorkflowScriptStepContext]. - Future waitWith( + Future wait( Object waiter, { DateTime? deadline, Map? data, @@ -327,8 +339,19 @@ extension WorkflowEventRefWaitExtension on WorkflowEventRef { throw ArgumentError.value( waiter, 'waiter', - 'WorkflowEventRef.waitWith requires a FlowContext or ' + 'WorkflowEventRef.wait requires a FlowContext or ' 'WorkflowScriptStepContext.', ); } + + /// Suspends until this event is emitted, then returns the decoded payload. + /// + /// [waiter] must be a [FlowContext] or [WorkflowScriptStepContext]. + Future waitWith( + Object waiter, { + DateTime? deadline, + Map? data, + }) { + return wait(waiter, deadline: deadline, data: data); + } } diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart index dc55c160..56041084 100644 --- a/packages/stem/test/unit/workflow/workflow_resume_test.dart +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -449,7 +449,7 @@ void main() { ); test( - 'WorkflowEventRef.waitValueWith delegates to both flow and script ' + 'WorkflowEventRef.waitValue delegates to both flow and script ' 'contexts', () { const event = WorkflowEventRef<_ResumePayload>( @@ -465,7 +465,7 @@ void main() { previousResult: null, stepIndex: 0, ); - expect(event.waitValueWith(flowWaiting), isNull); + expect(event.waitValue(flowWaiting), isNull); expect(flowWaiting.takeControl()?.topic, 'demo.event'); final flowResumed = FlowContext( @@ -477,16 +477,16 @@ void main() { stepIndex: 0, resumeData: const {'message': 'approved'}, ); - expect(event.waitValueWith(flowResumed)?.message, 'approved'); + expect(event.waitValue(flowResumed)?.message, 'approved'); final scriptWaiting = _FakeWorkflowScriptStepContext(); - expect(event.waitValueWith(scriptWaiting), isNull); + expect(event.waitValue(scriptWaiting), isNull); expect(scriptWaiting.awaitedTopics, ['demo.event']); }, ); test( - 'WorkflowEventRef.waitWith delegates to both flow and script contexts', + 'WorkflowEventRef.wait delegates to both flow and script contexts', () { const event = WorkflowEventRef<_ResumePayload>( topic: 'demo.event', @@ -502,7 +502,7 @@ void main() { stepIndex: 0, ); expect( - () => event.waitWith(flowWaiting), + () => event.wait(flowWaiting), throwsA(isA()), ); expect(flowWaiting.takeControl()?.topic, 'demo.event'); @@ -511,7 +511,7 @@ void main() { resumeData: const {'message': 'approved'}, ); expect( - event.waitWith(scriptResumed), + event.wait(scriptResumed), completion( isA<_ResumePayload>().having( (value) => value.message, @@ -530,11 +530,11 @@ void main() { ); expect( - () => event.waitValueWith('invalid'), + () => event.waitValue('invalid'), throwsA(isA()), ); expect( - () => event.waitWith('invalid'), + () => event.wait('invalid'), throwsA(isA()), ); }); diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index d3917172..44e4cbd3 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -463,7 +463,7 @@ void main() { name: 'runtime.ref.event.flow', build: (builder) { builder.step('wait', (ctx) async { - final payload = _userUpdatedEvent.waitValueWith(ctx); + final payload = _userUpdatedEvent.waitValue(ctx); if (payload == null) { return null; } @@ -479,7 +479,7 @@ void main() { final runId = await flow.ref0().startWith(workflowApp); await workflowApp.runtime.executeRun(runId); - await _userUpdatedEvent.emitWith( + await _userUpdatedEvent.emit( workflowApp, const _GreetingParams(name: 'event'), ); @@ -500,10 +500,10 @@ void main() { 'typed workflow event calls emit from the prebuilt call surface', () async { final flow = Flow( - name: 'runtime.ref.event.call.flow', - build: (builder) { - builder.step('wait', (ctx) async { - final payload = await _userUpdatedEvent.waitWith(ctx); + name: 'runtime.ref.event.call.flow', + build: (builder) { + builder.step('wait', (ctx) async { + final payload = await _userUpdatedEvent.wait(ctx); return 'hello ${payload.name}'; }); }, @@ -536,12 +536,12 @@ void main() { test('workflow event emitters expose bound event calls', () async { final flow = Flow( name: 'runtime.ref.event.bound.flow', - build: (builder) { - builder.step('wait', (ctx) async { - final payload = _userUpdatedEvent.waitValueWith(ctx); - if (payload == null) { - return null; - } + build: (builder) { + builder.step('wait', (ctx) async { + final payload = _userUpdatedEvent.waitValue(ctx); + if (payload == null) { + return null; + } return 'hello ${payload.name}'; }); }, From 597ab988a578901ee734538a6a9d2ebcaf248749 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 05:54:51 -0500 Subject: [PATCH 150/302] Deprecate workflow event with-aliases --- packages/stem/CHANGELOG.md | 3 +++ packages/stem/lib/src/workflow/core/workflow_event_ref.dart | 2 ++ packages/stem/lib/src/workflow/core/workflow_resume.dart | 2 ++ packages/stem/test/workflow/workflow_runtime_ref_test.dart | 2 +- packages/stem/test/workflow/workflow_runtime_test.dart | 2 +- 5 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 34dab93c..6abe5346 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Deprecated the older workflow-event `emitWith(...)`, `waitWith(...)`, and + `waitValueWith(...)` helpers in favor of the direct `emit(...)`, `wait(...)`, + and `waitValue(...)` aliases. - Added direct workflow-event aliases `event.emit(...)`, `event.wait(...)`, and `event.waitValue(...)`, while keeping the older `...With(...)` forms for compatibility. diff --git a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart index 4ae8d1c7..91f2df56 100644 --- a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart @@ -79,6 +79,7 @@ extension WorkflowEventRefExtension on WorkflowEventRef { } /// Emits this typed event with the provided [emitter]. + @Deprecated('Use emit(emitter, value) instead.') Future emitWith(WorkflowEventEmitter emitter, T value) { return emit(emitter, value); } @@ -92,6 +93,7 @@ extension WorkflowEventCallExtension on WorkflowEventCall { } /// Emits this typed event with the provided [emitter]. + @Deprecated('Use emit(emitter) instead.') Future emitWith(WorkflowEventEmitter emitter) { return emit(emitter); } diff --git a/packages/stem/lib/src/workflow/core/workflow_resume.dart b/packages/stem/lib/src/workflow/core/workflow_resume.dart index 4f8e2af0..dc2e83ae 100644 --- a/packages/stem/lib/src/workflow/core/workflow_resume.dart +++ b/packages/stem/lib/src/workflow/core/workflow_resume.dart @@ -306,6 +306,7 @@ extension WorkflowEventRefWaitExtension on WorkflowEventRef { /// null-then-resume path. /// /// [waiter] must be a [FlowContext] or [WorkflowScriptStepContext]. + @Deprecated('Use waitValue(waiter, ...) instead.') T? waitValueWith( Object waiter, { DateTime? deadline, @@ -347,6 +348,7 @@ extension WorkflowEventRefWaitExtension on WorkflowEventRef { /// Suspends until this event is emitted, then returns the decoded payload. /// /// [waiter] must be a [FlowContext] or [WorkflowScriptStepContext]. + @Deprecated('Use wait(waiter, ...) instead.') Future waitWith( Object waiter, { DateTime? deadline, diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 44e4cbd3..6f2ae01a 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -518,7 +518,7 @@ void main() { await _userUpdatedEvent .call(const _GreetingParams(name: 'call')) - .emitWith(workflowApp); + .emit(workflowApp); await workflowApp.runtime.executeRun(runId); final result = await workflowApp.waitForCompletion( diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index c7c8ac19..a37d5de5 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -706,7 +706,7 @@ void main() { expect(suspended?.status, WorkflowStatus.suspended); expect(suspended?.waitTopic, event.topic); - await event.emitWith( + await event.emit( runtime, const _UserUpdatedEvent(id: 'user-typed-2'), ); From 6fefcafdb9cbae7dd423218ddd08cd338f805bf5 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 05:58:46 -0500 Subject: [PATCH 151/302] Deprecate workflow start with-aliases --- packages/stem/CHANGELOG.md | 3 + packages/stem/lib/src/workflow/core/flow.dart | 6 +- .../lib/src/workflow/core/workflow_ref.dart | 64 +++++++++++-------- .../src/workflow/core/workflow_script.dart | 6 +- .../stem/test/bootstrap/stem_app_test.dart | 6 +- .../stem/test/bootstrap/stem_client_test.dart | 2 +- ...task_context_enqueue_integration_test.dart | 4 +- .../test/unit/workflow/flow_context_test.dart | 6 +- ...workflow_runtime_call_extensions_test.dart | 12 ++-- .../workflow/workflow_runtime_ref_test.dart | 34 +++++----- .../test/workflow/workflow_runtime_test.dart | 8 +-- packages/stem_builder/example/README.md | 4 +- packages/stem_builder/example/bin/main.dart | 4 +- .../example/bin/runtime_metadata_views.dart | 8 +-- 14 files changed, 93 insertions(+), 74 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 6abe5346..b592963b 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Deprecated the older workflow-start `startWith(...)` and + `startAndWaitWith(...)` helpers in favor of direct `start(...)` and + `startAndWait(...)` aliases. - Deprecated the older workflow-event `emitWith(...)`, `waitWith(...)`, and `waitValueWith(...)` helpers in favor of the direct `emit(...)`, `wait(...)`, and `waitValue(...)` aliases. diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index c1c4d947..3ecff566 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -78,6 +78,7 @@ class Flow { } /// Starts this flow directly when it does not accept start params. + @Deprecated('Use start(caller, ...) instead.') Future startWith( WorkflowCaller caller, { String? parentRunId, @@ -99,7 +100,7 @@ class Flow { Duration? ttl, WorkflowCancellationPolicy? cancellationPolicy, }) { - return startWith( + return ref0().start( caller, parentRunId: parentRunId, ttl: ttl, @@ -108,6 +109,7 @@ class Flow { } /// Starts this flow directly and waits for completion. + @Deprecated('Use startAndWait(caller, ...) instead.') Future?> startAndWaitWith( WorkflowCaller caller, { String? parentRunId, @@ -135,7 +137,7 @@ class Flow { Duration pollInterval = const Duration(milliseconds: 100), Duration? timeout, }) { - return startAndWaitWith( + return ref0().startAndWait( caller, parentRunId: parentRunId, ttl: ttl, diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 8f0f8f9c..51e2b93e 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -128,6 +128,7 @@ class WorkflowRef { } /// Starts this workflow ref directly with [caller]. + @Deprecated('Use start(caller, params: ...) instead.') Future startWith( WorkflowCaller caller, TParams params, { @@ -151,16 +152,16 @@ class WorkflowRef { Duration? ttl, WorkflowCancellationPolicy? cancellationPolicy, }) { - return startWith( - caller, + return call( params, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy, - ); + ).start(caller); } /// Starts this workflow ref with [caller] and waits for the result. + @Deprecated('Use startAndWait(caller, params: ...) instead.') Future?> startAndWaitWith( WorkflowCaller caller, TParams params, { @@ -175,7 +176,7 @@ class WorkflowRef { parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy, - ).startAndWaitWith( + ).startAndWait( caller, pollInterval: pollInterval, timeout: timeout, @@ -193,12 +194,13 @@ class WorkflowRef { Duration pollInterval = const Duration(milliseconds: 100), Duration? timeout, }) { - return startAndWaitWith( - caller, + return call( params, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy, + ).startAndWait( + caller, pollInterval: pollInterval, timeout: timeout, ); @@ -247,6 +249,7 @@ class NoArgsWorkflowRef { } /// Starts this workflow ref directly with [caller]. + @Deprecated('Use start(caller, ...) instead.') Future startWith( WorkflowCaller caller, { String? parentRunId, @@ -267,15 +270,15 @@ class NoArgsWorkflowRef { Duration? ttl, WorkflowCancellationPolicy? cancellationPolicy, }) { - return startWith( - caller, + return call( parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy, - ); + ).start(caller); } /// Starts this workflow ref with [caller] and waits for the result. + @Deprecated('Use startAndWait(caller, ...) instead.') Future?> startAndWaitWith( WorkflowCaller caller, { String? parentRunId, @@ -288,7 +291,7 @@ class NoArgsWorkflowRef { parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy, - ).startAndWaitWith( + ).startAndWait( caller, pollInterval: pollInterval, timeout: timeout, @@ -304,11 +307,12 @@ class NoArgsWorkflowRef { Duration pollInterval = const Duration(milliseconds: 100), Duration? timeout, }) { - return startAndWaitWith( - caller, + return call( parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy, + ).startAndWait( + caller, pollInterval: pollInterval, timeout: timeout, ); @@ -457,12 +461,13 @@ extension WorkflowStartCallExtension on WorkflowStartCall { /// Starts this typed workflow call with the provided [caller]. Future start(WorkflowCaller caller) { - return startWith(caller); + return caller.startWorkflowCall(this); } /// Starts this typed workflow call with the provided [caller]. + @Deprecated('Use start(caller) instead.') Future startWith(WorkflowCaller caller) { - return caller.startWorkflowCall(this); + return start(caller); } /// Starts this typed workflow call with [caller] and waits for the result. @@ -471,20 +476,25 @@ extension WorkflowStartCallExtension Duration pollInterval = const Duration(milliseconds: 100), Duration? timeout, }) { - return startAndWaitWith( - caller, - pollInterval: pollInterval, - timeout: timeout, - ); + final runIdFuture = start(caller); + return runIdFuture.then((runId) { + return definition.waitFor( + caller, + runId, + pollInterval: pollInterval, + timeout: timeout, + ); + }); } /// Starts this typed workflow call with [caller] and waits for the result. + @Deprecated('Use startAndWait(caller, ...) instead.') Future?> startAndWaitWith( WorkflowCaller caller, { Duration pollInterval = const Duration(milliseconds: 100), Duration? timeout, }) async { - final runId = await startWith(caller); + final runId = await start(caller); return definition.waitFor( caller, runId, @@ -499,12 +509,13 @@ extension WorkflowStartBuilderExtension on WorkflowStartBuilder { /// Builds this workflow call and starts it with the provided [caller]. Future start(WorkflowCaller caller) { - return startWith(caller); + return build().start(caller); } /// Builds this workflow call and starts it with the provided [caller]. + @Deprecated('Use start(caller) instead.') Future startWith(WorkflowCaller caller) { - return build().startWith(caller); + return start(caller); } /// Builds this workflow call, starts it with [caller], and waits for the @@ -514,7 +525,7 @@ extension WorkflowStartBuilderExtension Duration pollInterval = const Duration(milliseconds: 100), Duration? timeout, }) { - return startAndWaitWith( + return build().startAndWait( caller, pollInterval: pollInterval, timeout: timeout, @@ -523,12 +534,13 @@ extension WorkflowStartBuilderExtension /// Builds this workflow call, starts it with [caller], and waits for the /// result. + @Deprecated('Use startAndWait(caller, ...) instead.') Future?> startAndWaitWith( WorkflowCaller caller, { Duration pollInterval = const Duration(milliseconds: 100), Duration? timeout, }) { - return build().startAndWaitWith( + return startAndWait( caller, pollInterval: pollInterval, timeout: timeout, @@ -576,7 +588,7 @@ class BoundWorkflowStartBuilder { WorkflowStartCall build() => _builder.build(); /// Starts the built workflow call with the bound caller. - Future start() => _builder.startWith(_caller); + Future start() => _builder.start(_caller); /// Starts the built workflow call with the bound caller and waits for the /// typed workflow result. @@ -584,7 +596,7 @@ class BoundWorkflowStartBuilder { Duration pollInterval = const Duration(milliseconds: 100), Duration? timeout, }) { - return _builder.startAndWaitWith( + return _builder.startAndWait( _caller, pollInterval: pollInterval, timeout: timeout, diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index 30634540..dbab75a3 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -80,6 +80,7 @@ class WorkflowScript { } /// Starts this script directly when it does not accept start params. + @Deprecated('Use start(caller, ...) instead.') Future startWith( WorkflowCaller caller, { String? parentRunId, @@ -101,7 +102,7 @@ class WorkflowScript { Duration? ttl, WorkflowCancellationPolicy? cancellationPolicy, }) { - return startWith( + return ref0().start( caller, parentRunId: parentRunId, ttl: ttl, @@ -110,6 +111,7 @@ class WorkflowScript { } /// Starts this script directly and waits for completion. + @Deprecated('Use startAndWait(caller, ...) instead.') Future?> startAndWaitWith( WorkflowCaller caller, { String? parentRunId, @@ -137,7 +139,7 @@ class WorkflowScript { Duration pollInterval = const Duration(milliseconds: 100), Duration? timeout, }) { - return startAndWaitWith( + return ref0().startAndWait( caller, parentRunId: parentRunId, ttl: ttl, diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index a6de0231..042af3bf 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -722,7 +722,7 @@ void main() { .call( const {'name': 'stem'}, ) - .startWith(workflowApp); + .start(workflowApp); final result = await workflowRef.waitFor( workflowApp, runId, @@ -1156,7 +1156,7 @@ void main() { final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); try { - final runId = await workflowRef.call().startWith(workflowApp); + final runId = await workflowRef.call().start(workflowApp); final result = await workflowRef.waitFor( workflowApp, runId, @@ -1218,7 +1218,7 @@ void main() { final workflowApp = await StemWorkflowApp.inMemory(scripts: [script]); try { - final runId = await workflowRef.call().startWith(workflowApp); + final runId = await workflowRef.call().start(workflowApp); final result = await workflowRef.waitFor( workflowApp, runId, diff --git a/packages/stem/test/bootstrap/stem_client_test.dart b/packages/stem/test/bootstrap/stem_client_test.dart index 89d59130..f6db03ea 100644 --- a/packages/stem/test/bootstrap/stem_client_test.dart +++ b/packages/stem/test/bootstrap/stem_client_test.dart @@ -447,7 +447,7 @@ void main() { .call( const {'name': 'one-shot'}, ) - .startAndWaitWith(app, timeout: const Duration(seconds: 2)); + .startAndWait(app, timeout: const Duration(seconds: 2)); expect(result?.value, 'ok:one-shot'); diff --git a/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart b/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart index 9784864f..58715435 100644 --- a/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart +++ b/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart @@ -120,7 +120,7 @@ void main() { flows: [_waitingWorkflow], ); - final runId = await _waitingWorkflowRef.startWith(app); + final runId = await _waitingWorkflowRef.start(app); final taskId = await app.enqueue('tasks.isolate.emit.workflow.event'); final taskResult = await app.waitForTask( @@ -566,7 +566,7 @@ FutureOr _isolateStartWorkflowEntrypoint( TaskInvocationContext context, Map args, ) async { - final result = await _childWorkflowRef.startAndWaitWith( + final result = await _childWorkflowRef.startAndWait( context, timeout: const Duration(seconds: 2), ); diff --git a/packages/stem/test/unit/workflow/flow_context_test.dart b/packages/stem/test/unit/workflow/flow_context_test.dart index e18bf36b..ac27a490 100644 --- a/packages/stem/test/unit/workflow/flow_context_test.dart +++ b/packages/stem/test/unit/workflow/flow_context_test.dart @@ -91,7 +91,7 @@ void main() { ); expect( - () => childRef.startWith(context, const {'value': 'x'}), + () => childRef.start(context, params: const {'value': 'x'}), throwsStateError, ); }, @@ -114,9 +114,9 @@ void main() { ); expect( - () => childRef.startAndWaitWith( + () => childRef.startAndWait( context, - const {'value': 'x'}, + params: const {'value': 'x'}, ), throwsStateError, ); diff --git a/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart b/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart index 93511777..a21067a8 100644 --- a/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart @@ -26,7 +26,7 @@ void main() { final runId = await workflowRef .call(const {'name': 'runtime'}) - .startWith(workflowApp.runtime); + .start(workflowApp.runtime); final waited = await workflowRef.waitFor( workflowApp.runtime, runId, @@ -37,7 +37,7 @@ void main() { final oneShot = await workflowRef .call(const {'name': 'inline'}) - .startAndWaitWith( + .startAndWait( workflowApp.runtime, timeout: const Duration(seconds: 2), ); @@ -70,9 +70,9 @@ void main() { try { await workflowApp.start(); - final runId = await workflowRef.startWith( + final runId = await workflowRef.start( workflowApp.runtime, - const {'name': 'runtime'}, + params: const {'name': 'runtime'}, ); final waited = await workflowRef.waitFor( workflowApp.runtime, @@ -82,9 +82,9 @@ void main() { expect(waited?.value, 'hello runtime'); - final oneShot = await workflowRef.startAndWaitWith( + final oneShot = await workflowRef.startAndWait( workflowApp.runtime, - const {'name': 'inline'}, + params: const {'name': 'inline'}, timeout: const Duration(seconds: 2), ); diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 6f2ae01a..d581ebad 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -61,9 +61,9 @@ void main() { try { await workflowApp.start(); - final runId = await workflowRef.startWith( + final runId = await workflowRef.start( workflowApp.runtime, - const {'name': 'runtime'}, + params: const {'name': 'runtime'}, ); final waited = await workflowApp.runtime.waitForWorkflowRef( runId, @@ -105,9 +105,9 @@ void main() { try { await workflowApp.start(); - final runId = await workflowRef.startWith( + final runId = await workflowRef.start( workflowApp.runtime, - const {'name': 'runtime'}, + params: const {'name': 'runtime'}, ); final waited = await workflowRef.waitFor( workflowApp.runtime, @@ -139,9 +139,9 @@ void main() { try { await workflowApp.start(); - final result = await workflowRef.startAndWaitWith( + final result = await workflowRef.startAndWait( workflowApp.runtime, - const _GreetingParams(name: 'codec'), + params: const _GreetingParams(name: 'codec'), timeout: const Duration(seconds: 2), ); @@ -169,9 +169,9 @@ void main() { try { await workflowApp.start(); - final result = await workflowRef.startAndWaitWith( + final result = await workflowRef.startAndWait( workflowApp.runtime, - const _GreetingParams(name: 'json'), + params: const _GreetingParams(name: 'json'), timeout: const Duration(seconds: 2), ); @@ -234,9 +234,9 @@ void main() { try { await workflowApp.start(); - final result = await workflowRef.startAndWaitWith( + final result = await workflowRef.startAndWait( workflowApp.runtime, - const _GreetingParams(name: 'codec'), + params: const _GreetingParams(name: 'codec'), timeout: const Duration(seconds: 2), ); @@ -271,11 +271,11 @@ void main() { try { await workflowApp.start(); - final flowResult = await flow.startAndWaitWith( + final flowResult = await flow.startAndWait( workflowApp.runtime, timeout: const Duration(seconds: 2), ); - final scriptResult = await script.startAndWaitWith( + final scriptResult = await script.startAndWait( workflowApp.runtime, timeout: const Duration(seconds: 2), ); @@ -354,7 +354,7 @@ void main() { .ttl(const Duration(minutes: 5)) .parentRunId('parent-builder'); final builtFlowCall = flowBuilder.build(); - final runId = await flowBuilder.startWith(workflowApp.runtime); + final runId = await flowBuilder.start(workflowApp.runtime); final result = await workflowRef.waitFor( workflowApp.runtime, runId, @@ -373,7 +373,7 @@ void main() { ), ); final builtScriptCall = scriptBuilder.build(); - final oneShot = await scriptBuilder.startAndWaitWith( + final oneShot = await scriptBuilder.startAndWait( workflowApp.runtime, timeout: const Duration(seconds: 2), ); @@ -476,7 +476,7 @@ void main() { try { await workflowApp.start(); - final runId = await flow.ref0().startWith(workflowApp); + final runId = await flow.ref0().start(workflowApp); await workflowApp.runtime.executeRun(runId); await _userUpdatedEvent.emit( @@ -513,7 +513,7 @@ void main() { try { await workflowApp.start(); - final runId = await flow.ref0().startWith(workflowApp); + final runId = await flow.ref0().start(workflowApp); await workflowApp.runtime.executeRun(runId); await _userUpdatedEvent @@ -551,7 +551,7 @@ void main() { try { await workflowApp.start(); - final runId = await flow.ref0().startWith(workflowApp); + final runId = await flow.ref0().start(workflowApp); await workflowApp.runtime.executeRun(runId); final call = workflowApp.emitEventBuilder( diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index a37d5de5..769e2a54 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -158,7 +158,7 @@ void main() { flow.step('spawn', (context) async { return childRef .call(const {'value': 'spawned'}) - .startWith(context); + .start(context); }); }, ).definition, @@ -205,7 +205,7 @@ void main() { return script.step('spawn', (context) async { return childRef .call(const {'value': 'script-child'}) - .startWith(context); + .start(context); }); }, ).definition, @@ -251,7 +251,7 @@ void main() { flow.step('spawn', (context) async { final childResult = await childRef .call(const {'value': 'spawned'}) - .startAndWaitWith( + .startAndWait( context, timeout: const Duration(seconds: 2), ); @@ -306,7 +306,7 @@ void main() { ) async { final childResult = await childRef .call(const {'value': 'script-child'}) - .startAndWaitWith( + .startAndWait( context, timeout: const Duration(seconds: 2), ); diff --git a/packages/stem_builder/example/README.md b/packages/stem_builder/example/README.md index 1f44af32..78e7f039 100644 --- a/packages/stem_builder/example/README.md +++ b/packages/stem_builder/example/README.md @@ -5,8 +5,8 @@ This example demonstrates: - Annotated workflow/task definitions - Generated `stemModule` - Generated typed workflow refs (no manual workflow-name strings): - - `StemWorkflowDefinitions.flow.startWith(runtime, (...))` - - `StemWorkflowDefinitions.userSignup.startWith(runtime, (...))` + - `StemWorkflowDefinitions.flow.start(runtime, params: (...))` + - `StemWorkflowDefinitions.userSignup.start(runtime, params: (...))` - Generated typed task definitions that use the shared `TaskCall` / `TaskDefinition.waitFor(...)` APIs - Generated zero-arg task definitions with direct helpers from core: diff --git a/packages/stem_builder/example/bin/main.dart b/packages/stem_builder/example/bin/main.dart index d1b9b7d5..9d4481af 100644 --- a/packages/stem_builder/example/bin/main.dart +++ b/packages/stem_builder/example/bin/main.dart @@ -25,9 +25,9 @@ Future main() async { print('\nRuntime manifest:'); print(const JsonEncoder.withIndent(' ').convert(runtimeManifest)); - final runId = await StemWorkflowDefinitions.flow.startWith( + final runId = await StemWorkflowDefinitions.flow.start( app, - 'Stem Builder', + params: 'Stem Builder', ); await app.executeRun(runId); final result = await StemWorkflowDefinitions.flow.waitFor( diff --git a/packages/stem_builder/example/bin/runtime_metadata_views.dart b/packages/stem_builder/example/bin/runtime_metadata_views.dart index 63602bfc..1c81bc83 100644 --- a/packages/stem_builder/example/bin/runtime_metadata_views.dart +++ b/packages/stem_builder/example/bin/runtime_metadata_views.dart @@ -25,16 +25,16 @@ Future main() async { ), ); - final flowRunId = await StemWorkflowDefinitions.flow.startWith( + final flowRunId = await StemWorkflowDefinitions.flow.start( runtime, - 'runtime metadata', + params: 'runtime metadata', ); await app.executeRun(flowRunId); final scriptRunId = await StemWorkflowDefinitions.userSignup - .startWith( + .start( runtime, - 'dev@stem.dev', + params: 'dev@stem.dev', ); await app.executeRun(scriptRunId); From 400446fb551c140ff5cf8b3dc55cda878bbed2e8 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 06:00:09 -0500 Subject: [PATCH 152/302] Deprecate workflow event builders --- .site/docs/workflows/suspensions-and-events.md | 5 ++--- packages/stem/CHANGELOG.md | 2 ++ packages/stem/README.md | 4 ++-- .../stem/lib/src/workflow/core/workflow_event_ref.dart | 7 +++++++ .../stem/test/workflow/workflow_runtime_ref_test.dart | 9 ++++----- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index ecbbde8f..09a06d81 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -62,9 +62,8 @@ transport shape. When the topic and codec travel together in your codebase, prefer `WorkflowEventRef.json(...)` for normal DTO payloads and keep -`event.emit(emitter, dto)` as the happy path. `emitter.emitEventBuilder( -event: ref, value: dto).emit()` and `event.call(value).emit(...)` remain -available as lower-level variants. +`event.emit(emitter, dto)` as the happy path. `event.call(value).emit(...)` +remains available as the lower-level prebuilt-call variant. Pair that with `await event.wait(ctx)` or `awaitEventRef(...)`. ## Inspect waiting runs diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index b592963b..ba87250f 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.1.1 +- Deprecated `emitEventBuilder(...)` in favor of direct typed event calls via + `event.emit(...)` or `event.call(value).emit(...)`. - Deprecated the older workflow-start `startWith(...)` and `startAndWaitWith(...)` helpers in favor of direct `start(...)` and `startAndWait(...)` aliases. diff --git a/packages/stem/README.md b/packages/stem/README.md index 5f77ce49..fc53c3ff 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -1115,8 +1115,8 @@ backend metadata under `stem.unique.duplicates`. `runtime.emitValue(...)` when you are intentionally using the low-level runtime) with a `PayloadCodec`, or use `WorkflowEventRef.json(...)` as the shortest typed event form and call `event.emit(emitter, dto)` as the - happy path. `emitter.emitEventBuilder(event: ref, value: dto).emit()` and - `event.call(value).emit(...)` remain available as lower-level variants. + happy path. `event.call(value).emit(...)` remains available as the + lower-level prebuilt-call variant. Pair that with `await event.wait(ctx)` or `awaitEventRef(...)`. Event payloads still serialize onto the existing `Map` wire format. diff --git a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart index 91f2df56..9e135467 100644 --- a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart @@ -99,9 +99,15 @@ extension WorkflowEventCallExtension on WorkflowEventCall { } } +@Deprecated( + 'Use WorkflowEventRef.call(value) or event.emit(emitter, value) instead.', +) /// Caller-bound typed workflow event emission call. class BoundWorkflowEventCall { /// Creates a caller-bound typed workflow event emission call. + @Deprecated( + 'Use WorkflowEventRef.call(value) or event.emit(emitter, value) instead.', + ) const BoundWorkflowEventCall._({ required WorkflowEventEmitter emitter, required WorkflowEventCall call, @@ -122,6 +128,7 @@ class BoundWorkflowEventCall { /// workflow event emitter. extension WorkflowEventEmitterBuilderExtension on WorkflowEventEmitter { /// Creates a caller-bound typed workflow event call for [event] and [value]. + @Deprecated('Use event.call(value) or event.emit(this, value) instead.') BoundWorkflowEventCall emitEventBuilder({ required WorkflowEventRef event, required T value, diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index d581ebad..a03de251 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -554,13 +554,12 @@ void main() { final runId = await flow.ref0().start(workflowApp); await workflowApp.runtime.executeRun(runId); - final call = workflowApp.emitEventBuilder( - event: _userUpdatedEvent, - value: const _GreetingParams(name: 'bound'), + final call = _userUpdatedEvent.call( + const _GreetingParams(name: 'bound'), ); - expect(call.build().topic, 'runtime.ref.event'); + expect(call.topic, 'runtime.ref.event'); - await call.emit(); + await call.emit(workflowApp); await workflowApp.runtime.executeRun(runId); final result = await workflowApp.waitForCompletion( From 1bb2848418da38a103a3ade91fc1c5d98bbb1bdb Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 06:02:08 -0500 Subject: [PATCH 153/302] Deprecate context event-ref wait helpers --- packages/stem/CHANGELOG.md | 3 +++ packages/stem/README.md | 4 +-- .../src/workflow/core/workflow_resume.dart | 26 ++++++++++++++----- .../unit/workflow/workflow_resume_test.dart | 16 ++++++------ .../test/workflow/workflow_runtime_test.dart | 2 +- 5 files changed, 34 insertions(+), 17 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index ba87250f..d66436f4 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Deprecated context-side `waitForEventRef(...)` and + `waitForEventRefValue(...)` in favor of `event.waitValue(ctx)` and + `event.wait(ctx)`. - Deprecated `emitEventBuilder(...)` in favor of direct typed event calls via `event.emit(...)` or `event.call(value).emit(...)`. - Deprecated the older workflow-start `startWith(...)` and diff --git a/packages/stem/README.md b/packages/stem/README.md index fc53c3ff..39b5cdcb 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -424,8 +424,8 @@ Inside a script checkpoint you can access the same metadata as `FlowContext`: - `step.sleepUntilResumed(...)` handles the common sleep-once, continue-on- resume path. - `step.waitForEventValue(...)` handles the common wait-for-one-event path. -- `step.waitForEventRef(...)` handles the same path when you already have a - typed `WorkflowEventRef`. +- `event.waitValue(step)` handles the same path when you already have a typed + `WorkflowEventRef`. - `step.awaitEventRef(...)` keeps the lower-level suspend-first path on that same typed event ref instead of dropping back to a raw topic string. - `step.takeResumeData()` and `step.takeResumeValue(codec: ...)` surface diff --git a/packages/stem/lib/src/workflow/core/workflow_resume.dart b/packages/stem/lib/src/workflow/core/workflow_resume.dart index dc2e83ae..ba28ab9c 100644 --- a/packages/stem/lib/src/workflow/core/workflow_resume.dart +++ b/packages/stem/lib/src/workflow/core/workflow_resume.dart @@ -233,6 +233,7 @@ extension WorkflowScriptStepResumeValues on WorkflowScriptStepContext { /// Returns the next event payload from [event] when the checkpoint has /// resumed, or registers an event wait and returns `null` on the first /// invocation. + @Deprecated('Use event.waitValue(this, ...) instead.') T? waitForEventRef( WorkflowEventRef event, { DateTime? deadline, @@ -247,6 +248,7 @@ extension WorkflowScriptStepResumeValues on WorkflowScriptStepContext { } /// Suspends until [event] is emitted, then returns the decoded payload. + @Deprecated('Use event.wait(this, ...) instead.') Future waitForEventRefValue({ required WorkflowEventRef event, DateTime? deadline, @@ -289,10 +291,20 @@ extension WorkflowEventRefWaitExtension on WorkflowEventRef { Map? data, }) { if (waiter case final FlowContext context) { - return context.waitForEventRef(this, deadline: deadline, data: data); + return context.waitForEventValue( + topic, + deadline: deadline, + data: data, + codec: codec, + ); } if (waiter case final WorkflowScriptStepContext context) { - return context.waitForEventRef(this, deadline: deadline, data: data); + return context.waitForEventValue( + topic, + deadline: deadline, + data: data, + codec: codec, + ); } throw ArgumentError.value( waiter, @@ -324,17 +336,19 @@ extension WorkflowEventRefWaitExtension on WorkflowEventRef { Map? data, }) { if (waiter case final FlowContext context) { - return context.waitForEventRefValue( - event: this, + return context.waitForEvent( + topic: topic, deadline: deadline, data: data, + codec: codec, ); } if (waiter case final WorkflowScriptStepContext context) { - return context.waitForEventRefValue( - event: this, + return context.waitForEvent( + topic: topic, deadline: deadline, data: data, + codec: codec, ); } throw ArgumentError.value( diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart index 56041084..efb2c1b2 100644 --- a/packages/stem/test/unit/workflow/workflow_resume_test.dart +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -175,7 +175,7 @@ void main() { stepIndex: 0, ); - final firstResult = firstContext.waitForEventRef(event); + final firstResult = event.waitValue(firstContext); expect(firstResult, isNull); final control = firstContext.takeControl(); @@ -192,7 +192,7 @@ void main() { resumeData: const {'message': 'approved'}, ); - final resumed = resumedContext.waitForEventRef(event); + final resumed = event.waitValue(resumedContext); expect(resumed?.message, 'approved'); }); @@ -213,7 +213,7 @@ void main() { ); expect( - () => waiting.waitForEventRefValue(event: event), + () => event.wait(waiting), throwsA(isA()), ); expect(waiting.takeControl()?.topic, 'demo.event'); @@ -229,7 +229,7 @@ void main() { ); expect( - resumed.waitForEventRefValue(event: event), + event.wait(resumed), completion( isA<_ResumePayload>().having( (value) => value.message, @@ -405,14 +405,14 @@ void main() { codec: _resumePayloadCodec, ); final waiting = _FakeWorkflowScriptStepContext(); - final firstEvent = waiting.waitForEventRef(event); + final firstEvent = event.waitValue(waiting); expect(firstEvent, isNull); expect(waiting.awaitedTopics, ['demo.event']); final resumed = _FakeWorkflowScriptStepContext( resumeData: const {'message': 'approved'}, ); - final resumedValue = resumed.waitForEventRef(event); + final resumedValue = event.waitValue(resumed); expect(resumedValue?.message, 'approved'); }); @@ -427,7 +427,7 @@ void main() { final waiting = _FakeWorkflowScriptStepContext(); expect( - waiting.waitForEventRefValue(event: event), + event.wait(waiting), throwsA(isA()), ); expect(waiting.awaitedTopics, ['demo.event']); @@ -436,7 +436,7 @@ void main() { resumeData: const {'message': 'approved'}, ); expect( - resumed.waitForEventRefValue(event: event), + event.wait(resumed), completion( isA<_ResumePayload>().having( (value) => value.message, diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index 769e2a54..ad01abe2 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -687,7 +687,7 @@ void main() { flow.step( 'wait', (context) async { - final resume = context.waitForEventRef(event); + final resume = event.waitValue(context); if (resume == null) { return null; } From 11fda345b6e198f53b54318df7c09e983171e6e5 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 06:03:40 -0500 Subject: [PATCH 154/302] Deprecate flow event-ref wait methods --- packages/stem/lib/src/workflow/core/workflow_resume.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/stem/lib/src/workflow/core/workflow_resume.dart b/packages/stem/lib/src/workflow/core/workflow_resume.dart index ba28ab9c..1ad5ae61 100644 --- a/packages/stem/lib/src/workflow/core/workflow_resume.dart +++ b/packages/stem/lib/src/workflow/core/workflow_resume.dart @@ -117,6 +117,7 @@ extension FlowContextResumeValues on FlowContext { /// Returns the next event payload from [event] when the step has resumed, or /// registers an event wait and returns `null` on the first invocation. + @Deprecated('Use event.waitValue(this, ...) instead.') T? waitForEventRef( WorkflowEventRef event, { DateTime? deadline, @@ -131,6 +132,7 @@ extension FlowContextResumeValues on FlowContext { } /// Suspends until [event] is emitted, then returns the decoded payload. + @Deprecated('Use event.wait(this, ...) instead.') Future waitForEventRefValue({ required WorkflowEventRef event, DateTime? deadline, From 7e65f0cfac27a02319ca9e4a4793fc310e56bf10 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 06:04:23 -0500 Subject: [PATCH 155/302] Rename stale workflow alias tests --- .../stem/test/unit/workflow/workflow_resume_test.dart | 9 ++++----- .../workflow/workflow_runtime_call_extensions_test.dart | 4 ++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart index efb2c1b2..e03e0989 100644 --- a/packages/stem/test/unit/workflow/workflow_resume_test.dart +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -161,7 +161,7 @@ void main() { }, ); - test('FlowContext.waitForEventRef reuses topic and codec', () { + test('WorkflowEventRef.waitValue reuses topic and codec for flows', () { const event = WorkflowEventRef<_ResumePayload>( topic: 'demo.event', codec: _resumePayloadCodec, @@ -197,7 +197,7 @@ void main() { }); test( - 'FlowContext.waitForEventRefValue uses named args and resumes with payload', + 'WorkflowEventRef.wait uses named args and resumes with payload in flows', () { const event = WorkflowEventRef<_ResumePayload>( topic: 'demo.event', @@ -399,7 +399,7 @@ void main() { }, ); - test('WorkflowScriptStepContext.waitForEventRef reuses topic and codec', () { + test('WorkflowEventRef.waitValue reuses topic and codec in scripts', () { const event = WorkflowEventRef<_ResumePayload>( topic: 'demo.event', codec: _resumePayloadCodec, @@ -417,8 +417,7 @@ void main() { }); test( - 'WorkflowScriptStepContext.waitForEventRefValue uses named args and ' - 'resumes with payload', + 'WorkflowEventRef.wait uses named args and resumes with payload in scripts', () { const event = WorkflowEventRef<_ResumePayload>( topic: 'demo.event', diff --git a/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart b/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart index a21067a8..05e25b03 100644 --- a/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart @@ -4,7 +4,7 @@ import 'package:test/test.dart'; void main() { group('runtime workflow call extensions', () { test( - 'startWith/startAndWaitWith/waitFor use typed workflow refs', + 'start/startAndWait/waitFor use typed workflow refs', () async { final flow = Flow( name: 'runtime.extension.flow', @@ -96,7 +96,7 @@ void main() { ); test( - 'named workflow start aliases mirror the with-suffixed helpers', + 'named workflow start aliases mirror the direct workflow helpers', () async { final flow = Flow( name: 'runtime.extension.named.flow', From 14625b4049008fad1f3a8a81493d80b4feb69cff Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 06:05:26 -0500 Subject: [PATCH 156/302] Deprecate script event-ref wait helper --- .site/docs/workflows/suspensions-and-events.md | 2 +- packages/stem/CHANGELOG.md | 1 + packages/stem/README.md | 5 ++--- .../stem/lib/src/workflow/core/workflow_resume.dart | 1 + .../test/unit/workflow/workflow_resume_test.dart | 13 ++++++++----- 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index 09a06d81..2b9301ec 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -64,7 +64,7 @@ When the topic and codec travel together in your codebase, prefer `WorkflowEventRef.json(...)` for normal DTO payloads and keep `event.emit(emitter, dto)` as the happy path. `event.call(value).emit(...)` remains available as the lower-level prebuilt-call variant. -Pair that with `await event.wait(ctx)` or `awaitEventRef(...)`. +Pair that with `await event.wait(ctx)`. ## Inspect waiting runs diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index d66436f4..f76493e3 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.1.1 +- Deprecated script-step `awaitEventRef(...)` in favor of `await event.wait(ctx)`. - Deprecated context-side `waitForEventRef(...)` and `waitForEventRefValue(...)` in favor of `event.waitValue(ctx)` and `event.wait(ctx)`. diff --git a/packages/stem/README.md b/packages/stem/README.md index 39b5cdcb..ebea96e6 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -1117,9 +1117,8 @@ backend metadata under `stem.unique.duplicates`. as the shortest typed event form and call `event.emit(emitter, dto)` as the happy path. `event.call(value).emit(...)` remains available as the lower-level prebuilt-call variant. - Pair that with `await event.wait(ctx)` or `awaitEventRef(...)`. Event - payloads still serialize onto the existing `Map` wire - format. + Pair that with `await event.wait(ctx)`. Event payloads still serialize onto + the existing `Map` wire format. - Only return values you want persisted. If a handler returns `null`, the runtime treats it as "no result yet" and will run the step again on resume. - Derive outbound idempotency tokens with `ctx.idempotencyKey('charge')` so diff --git a/packages/stem/lib/src/workflow/core/workflow_resume.dart b/packages/stem/lib/src/workflow/core/workflow_resume.dart index 1ad5ae61..b94f37fe 100644 --- a/packages/stem/lib/src/workflow/core/workflow_resume.dart +++ b/packages/stem/lib/src/workflow/core/workflow_resume.dart @@ -265,6 +265,7 @@ extension WorkflowScriptStepResumeValues on WorkflowScriptStepContext { } /// Registers an event wait using a typed [event] reference. + @Deprecated('Use event.wait(this, ...) instead.') Future awaitEventRef( WorkflowEventRef event, { DateTime? deadline, diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart index e03e0989..f6f6d235 100644 --- a/packages/stem/test/unit/workflow/workflow_resume_test.dart +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -539,7 +539,7 @@ void main() { }); test( - 'WorkflowScriptStepContext.awaitEventRef reuses the event topic', + 'WorkflowEventRef.wait registers script-step waits before suspension', () async { const event = WorkflowEventRef<_ResumePayload>( topic: 'demo.event', @@ -548,10 +548,13 @@ void main() { final deadline = DateTime.parse('2026-01-01T00:00:00Z'); final context = _FakeWorkflowScriptStepContext(); - await context.awaitEventRef( - event, - deadline: deadline, - data: const {'source': 'script'}, + await expectLater( + () => event.wait( + context, + deadline: deadline, + data: const {'source': 'script'}, + ), + throwsA(isA()), ); expect(context.awaitedTopics, ['demo.event']); From cfdecea74eab1ec8c71b1252b7ad610d758a026b Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 06:06:29 -0500 Subject: [PATCH 157/302] Add flow event await helper --- packages/stem/CHANGELOG.md | 2 ++ packages/stem/README.md | 4 ++-- .../lib/src/workflow/core/workflow_resume.dart | 15 +++++++++++++++ .../test/unit/workflow/workflow_resume_test.dart | 6 +++--- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index f76493e3..87262561 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.1.1 +- Added `event.awaitOn(step)` for the low-level flow-control event wait path, + and deprecated `FlowContext.awaitEventRef(...)`. - Deprecated script-step `awaitEventRef(...)` in favor of `await event.wait(ctx)`. - Deprecated context-side `waitForEventRef(...)` and `waitForEventRefValue(...)` in favor of `event.waitValue(ctx)` and diff --git a/packages/stem/README.md b/packages/stem/README.md index ebea96e6..8d68982e 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -426,8 +426,8 @@ Inside a script checkpoint you can access the same metadata as `FlowContext`: - `step.waitForEventValue(...)` handles the common wait-for-one-event path. - `event.waitValue(step)` handles the same path when you already have a typed `WorkflowEventRef`. -- `step.awaitEventRef(...)` keeps the lower-level suspend-first path on that - same typed event ref instead of dropping back to a raw topic string. +- `event.awaitOn(step)` keeps the lower-level flow-control suspend-first path + on that same typed event ref instead of dropping back to a raw topic string. - `step.takeResumeData()` and `step.takeResumeValue(codec: ...)` surface payloads from sleeps or awaited events when you need lower-level control. diff --git a/packages/stem/lib/src/workflow/core/workflow_resume.dart b/packages/stem/lib/src/workflow/core/workflow_resume.dart index b94f37fe..fc622d89 100644 --- a/packages/stem/lib/src/workflow/core/workflow_resume.dart +++ b/packages/stem/lib/src/workflow/core/workflow_resume.dart @@ -147,6 +147,7 @@ extension FlowContextResumeValues on FlowContext { } /// Registers an event wait using a typed [event] reference. + @Deprecated('Use event.awaitOn(this, ...) instead.') FlowStepControl awaitEventRef( WorkflowEventRef event, { DateTime? deadline, @@ -284,6 +285,20 @@ extension WorkflowScriptStepResumeValues on WorkflowScriptStepContext { /// These mirror `event.emit(...)` so typed workflow events can stay on the /// event-ref surface for both emit and wait paths. extension WorkflowEventRefWaitExtension on WorkflowEventRef { + /// Registers a low-level flow-control wait while keeping the typed event ref + /// on the call site. + FlowStepControl awaitOn( + FlowContext step, { + DateTime? deadline, + Map? data, + }) { + return step.awaitEvent( + topic, + deadline: deadline, + data: data, + ); + } + /// Registers an event wait and returns the resumed payload on the legacy /// null-then-resume path. /// diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart index f6f6d235..d4c6adb2 100644 --- a/packages/stem/test/unit/workflow/workflow_resume_test.dart +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -285,7 +285,7 @@ void main() { ); }); - test('FlowContext.awaitEventRef reuses the event topic', () { + test('WorkflowEventRef.awaitOn reuses the event topic for flows', () { const event = WorkflowEventRef<_ResumePayload>( topic: 'demo.event', codec: _resumePayloadCodec, @@ -300,8 +300,8 @@ void main() { stepIndex: 0, ); - final control = context.awaitEventRef( - event, + final control = event.awaitOn( + context, deadline: deadline, data: const {'source': 'flow'}, ); From 5919a7a195929f15cfb9e64c0d2ac8c2ba8479e3 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 06:07:41 -0500 Subject: [PATCH 158/302] Prefer direct workflow overrides in docs --- .site/docs/workflows/annotated-workflows.md | 6 ++++-- .../workflows/context-and-serialization.md | 6 ++++-- .site/docs/workflows/starting-and-waiting.md | 18 ++++++++++-------- packages/stem/README.md | 6 ++++-- 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index a48ea3fb..8d7eced5 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -134,8 +134,10 @@ When a workflow needs to start another workflow, do it from a durable boundary: - `FlowContext` and `WorkflowScriptStepContext` both implement `WorkflowCaller`, so prefer `ref.startAndWait(context, params: value)` inside flow steps and checkpoint methods -- use `context.startWorkflowBuilder(...)` only when you need advanced start - overrides like `ttl(...)` or `cancellationPolicy(...)` +- pass `ttl:`, `parentRunId:`, or `cancellationPolicy:` directly to + `ref.start(...)` / `ref.startAndWait(...)` for the normal override cases +- keep `context.startWorkflowBuilder(...)` for the rarer incremental-call + cases where you actually want to build the start request step by step Avoid starting child workflows from the raw `WorkflowScriptContext` body. diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index 23d2f833..f102fb9c 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -50,8 +50,10 @@ Child workflow starts belong in durable boundaries: - `ref.start(context, params: value)` inside flow steps - `ref.startAndWait(context, params: value)` inside script checkpoints -- `context.startWorkflowBuilder(...)` when you need advanced overrides like - `ttl(...)` or `cancellationPolicy(...)` +- pass `ttl:`, `parentRunId:`, or `cancellationPolicy:` directly to those + helpers for the normal override cases +- keep `context.startWorkflowBuilder(...)` for incremental-call assembly when + you genuinely need to build the start request step by step Do not treat the raw `WorkflowScriptContext` body as a safe place for child starts or other replay-sensitive side effects. diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index b3d05a74..5bfaef4b 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -111,21 +111,23 @@ final runId = await StemWorkflowDefinitions.userSignup.start( When you already have a `WorkflowCaller` like `FlowContext`, `WorkflowScriptStepContext`, `WorkflowRuntime`, or `StemWorkflowApp`, prefer -the caller-bound fluent builder: +the direct typed ref helpers, even when you need start overrides: ```dart -final result = await context - .startWorkflowBuilder( - definition: StemWorkflowDefinitions.userSignup, - params: 'user@example.com', - ) - .ttl(const Duration(hours: 1)) - .startAndWait(timeout: const Duration(seconds: 5)); +final result = await StemWorkflowDefinitions.userSignup.startAndWait( + context, + params: 'user@example.com', + ttl: const Duration(hours: 1), + timeout: const Duration(seconds: 5), +); ``` If you still need the run identifier for inspection or operator tooling, read it from `result.runId`. +Keep `context.startWorkflowBuilder(...)` for the rarer cases where you want to +assemble a start request incrementally before dispatch. + ## Parent runs and TTL `WorkflowRuntime.startWorkflow(...)` also supports: diff --git a/packages/stem/README.md b/packages/stem/README.md index 8d68982e..90bfbb90 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -636,8 +636,10 @@ Child workflows belong in durable execution boundaries: - `FlowContext` and `WorkflowScriptStepContext` both implement `WorkflowCaller`, so prefer `ref.startAndWait(context, params: value)` inside flow steps and script checkpoints -- use `context.startWorkflowBuilder(...)` when you need advanced overrides like - `ttl(...)` or `cancellationPolicy(...)` +- pass `ttl:`, `parentRunId:`, or `cancellationPolicy:` directly to + `ref.start(...)` / `ref.startAndWait(...)` for normal override cases +- keep `context.startWorkflowBuilder(...)` for the rarer incremental-call + cases where you actually want to build the start request step by step - do not start child workflows from the raw `WorkflowScriptContext` body unless you are deliberately managing replay/idempotency yourself From d3316ae6305c245e7c8714238698e149edd93340 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 06:08:34 -0500 Subject: [PATCH 159/302] Document flow event await helper --- .site/docs/workflows/context-and-serialization.md | 2 ++ .site/docs/workflows/suspensions-and-events.md | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index f102fb9c..aa6ee3fe 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -35,6 +35,8 @@ Depending on the context type, you can access: - workflow params and previous results - `sleepUntilResumed(...)` for common sleep/retry loops - `waitForEventValue(...)` for common event waits +- `event.awaitOn(step)` when a flow deliberately wants the lower-level + `FlowStepControl` suspend-first path on a typed event ref - `takeResumeData()` for event-driven resumes - `takeResumeValue(codec: ...)` for typed event-driven resumes - `idempotencyKey(...)` diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index 2b9301ec..1706e9e2 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -64,7 +64,9 @@ When the topic and codec travel together in your codebase, prefer `WorkflowEventRef.json(...)` for normal DTO payloads and keep `event.emit(emitter, dto)` as the happy path. `event.call(value).emit(...)` remains available as the lower-level prebuilt-call variant. -Pair that with `await event.wait(ctx)`. +Pair that with `await event.wait(ctx)`. If you are writing a flow and +deliberately want the lower-level `FlowStepControl` path, use +`event.awaitOn(step)` instead of dropping back to a raw topic string. ## Inspect waiting runs From 4937ebeecc4be30cbde91883cfd41537dcebd35f Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 06:09:02 -0500 Subject: [PATCH 160/302] Align stem_builder workflow override docs --- packages/stem_builder/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index e80f83f0..dc1b4ec8 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -113,8 +113,10 @@ Child workflows should be started from durable boundaries: - `ref.start(context, params: value)` inside flow steps - `ref.startAndWait(context, params: value)` inside script checkpoints -- use `context.startWorkflowBuilder(...)` when you need advanced overrides like - `ttl(...)` or `cancellationPolicy(...)` +- pass `ttl:`, `parentRunId:`, or `cancellationPolicy:` directly to + `ref.start(...)` / `ref.startAndWait(...)` for the normal override cases +- keep `context.startWorkflowBuilder(...)` for the rarer incremental-call + cases where you actually want to build the start request step by step Avoid starting child workflows directly from the raw `WorkflowScriptContext` body unless you are explicitly handling replay From 82f4cda1a26f5f8869edf4c2ab87c82e874e3091 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 06:11:40 -0500 Subject: [PATCH 161/302] Rename workflow start builders --- .site/docs/workflows/annotated-workflows.md | 2 +- .../workflows/context-and-serialization.md | 2 +- .site/docs/workflows/starting-and-waiting.md | 2 +- packages/stem/CHANGELOG.md | 4 +++ packages/stem/README.md | 2 +- .../lib/src/workflow/core/workflow_ref.dart | 26 +++++++++++++++++-- .../unit/core/task_context_enqueue_test.dart | 2 +- .../workflow/workflow_runtime_ref_test.dart | 4 +-- packages/stem_builder/README.md | 2 +- 9 files changed, 36 insertions(+), 10 deletions(-) diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index 8d7eced5..f1140055 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -136,7 +136,7 @@ When a workflow needs to start another workflow, do it from a durable boundary: flow steps and checkpoint methods - pass `ttl:`, `parentRunId:`, or `cancellationPolicy:` directly to `ref.start(...)` / `ref.startAndWait(...)` for the normal override cases -- keep `context.startWorkflowBuilder(...)` for the rarer incremental-call +- keep `context.prepareWorkflowStart(...)` for the rarer incremental-call cases where you actually want to build the start request step by step Avoid starting child workflows from the raw `WorkflowScriptContext` body. diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index aa6ee3fe..914236e8 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -54,7 +54,7 @@ Child workflow starts belong in durable boundaries: - `ref.startAndWait(context, params: value)` inside script checkpoints - pass `ttl:`, `parentRunId:`, or `cancellationPolicy:` directly to those helpers for the normal override cases -- keep `context.startWorkflowBuilder(...)` for incremental-call assembly when +- keep `context.prepareWorkflowStart(...)` for incremental-call assembly when you genuinely need to build the start request step by step Do not treat the raw `WorkflowScriptContext` body as a safe place for child diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 5bfaef4b..1659e17d 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -125,7 +125,7 @@ final result = await StemWorkflowDefinitions.userSignup.startAndWait( If you still need the run identifier for inspection or operator tooling, read it from `result.runId`. -Keep `context.startWorkflowBuilder(...)` for the rarer cases where you want to +Keep `context.prepareWorkflowStart(...)` for the rarer cases where you want to assemble a start request incrementally before dispatch. ## Parent runs and TTL diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 87262561..dea62f03 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.1 +- Added `prepareWorkflowStart(...)` / `prepareNoArgsWorkflowStart(...)` as the + clearer names for caller-bound workflow start builders, and deprecated the + older `startWorkflowBuilder(...)` / `startNoArgsWorkflowBuilder(...)` + aliases. - Added `event.awaitOn(step)` for the low-level flow-control event wait path, and deprecated `FlowContext.awaitEventRef(...)`. - Deprecated script-step `awaitEventRef(...)` in favor of `await event.wait(ctx)`. diff --git a/packages/stem/README.md b/packages/stem/README.md index 90bfbb90..bdcda911 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -638,7 +638,7 @@ Child workflows belong in durable execution boundaries: flow steps and script checkpoints - pass `ttl:`, `parentRunId:`, or `cancellationPolicy:` directly to `ref.start(...)` / `ref.startAndWait(...)` for normal override cases -- keep `context.startWorkflowBuilder(...)` for the rarer incremental-call +- keep `context.prepareWorkflowStart(...)` for the rarer incremental-call cases where you actually want to build the start request step by step - do not start child workflows from the raw `WorkflowScriptContext` body unless you are deliberately managing replay/idempotency yourself diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 51e2b93e..3c7675bd 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -609,7 +609,7 @@ class BoundWorkflowStartBuilder { extension WorkflowCallerBuilderExtension on WorkflowCaller { /// Creates a caller-bound fluent start builder for a typed workflow ref. BoundWorkflowStartBuilder - startWorkflowBuilder({ + prepareWorkflowStart({ required WorkflowRef definition, required TParams params, }) { @@ -619,9 +619,22 @@ extension WorkflowCallerBuilderExtension on WorkflowCaller { ); } + /// Creates a caller-bound fluent start builder for a typed workflow ref. + @Deprecated('Use prepareWorkflowStart(...) instead.') + BoundWorkflowStartBuilder + startWorkflowBuilder({ + required WorkflowRef definition, + required TParams params, + }) { + return prepareWorkflowStart( + definition: definition, + params: params, + ); + } + /// Creates a caller-bound fluent start builder for a no-args workflow ref. BoundWorkflowStartBuilder<(), TResult> - startNoArgsWorkflowBuilder({ + prepareNoArgsWorkflowStart({ required NoArgsWorkflowRef definition, }) { return BoundWorkflowStartBuilder._( @@ -629,6 +642,15 @@ extension WorkflowCallerBuilderExtension on WorkflowCaller { builder: definition.startBuilder(), ); } + + /// Creates a caller-bound fluent start builder for a no-args workflow ref. + @Deprecated('Use prepareNoArgsWorkflowStart(...) instead.') + BoundWorkflowStartBuilder<(), TResult> + startNoArgsWorkflowBuilder({ + required NoArgsWorkflowRef definition, + }) { + return prepareNoArgsWorkflowStart(definition: definition); + } } /// Convenience helpers for waiting on typed workflow refs using a generic diff --git a/packages/stem/test/unit/core/task_context_enqueue_test.dart b/packages/stem/test/unit/core/task_context_enqueue_test.dart index f8bc837a..27d482df 100644 --- a/packages/stem/test/unit/core/task_context_enqueue_test.dart +++ b/packages/stem/test/unit/core/task_context_enqueue_test.dart @@ -292,7 +292,7 @@ void main() { ); final result = await context - .startWorkflowBuilder( + .prepareWorkflowStart( definition: definition, params: const {'value': 'child'}, ) diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index a03de251..9ca8097d 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -416,7 +416,7 @@ void main() { await workflowApp.start(); final flowBuilder = workflowApp.runtime - .startWorkflowBuilder( + .prepareWorkflowStart( definition: workflowRef, params: const {'name': 'builder'}, ) @@ -437,7 +437,7 @@ void main() { expect(state?.parentRunId, 'parent-bound'); final scriptBuilder = workflowApp.runtime - .startNoArgsWorkflowBuilder(definition: scriptRef) + .prepareNoArgsWorkflowStart(definition: scriptRef) .cancellationPolicy( const WorkflowCancellationPolicy( maxRunDuration: Duration(seconds: 5), diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index dc1b4ec8..fb6f29f8 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -115,7 +115,7 @@ Child workflows should be started from durable boundaries: - `ref.startAndWait(context, params: value)` inside script checkpoints - pass `ttl:`, `parentRunId:`, or `cancellationPolicy:` directly to `ref.start(...)` / `ref.startAndWait(...)` for the normal override cases -- keep `context.startWorkflowBuilder(...)` for the rarer incremental-call +- keep `context.prepareWorkflowStart(...)` for the rarer incremental-call cases where you actually want to build the start request step by step Avoid starting child workflows directly from the raw From 0e545b5fed50c54533b445bcb551b1eaa0bfdfdf Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 06:16:33 -0500 Subject: [PATCH 162/302] Add prepareEnqueue task builder aliases --- .site/docs/core-concepts/producer.md | 4 +-- .site/docs/core-concepts/tasks.md | 2 +- packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 2 +- .../stem/example/docs_snippets/lib/tasks.dart | 2 +- .../task_context_mixed/lib/shared.dart | 4 +-- .../stem/example/task_usage_patterns.dart | 2 +- packages/stem/lib/src/core/contracts.dart | 31 ++++++++++++++++++- .../stem/lib/src/core/task_invocation.dart | 9 ++++++ .../lib/src/workflow/core/workflow_ref.dart | 2 +- .../unit/core/task_context_enqueue_test.dart | 2 +- .../unit/core/task_enqueue_builder_test.dart | 16 +++++----- 12 files changed, 60 insertions(+), 19 deletions(-) diff --git a/.site/docs/core-concepts/producer.md b/.site/docs/core-concepts/producer.md index 27c1aa6f..392cbce9 100644 --- a/.site/docs/core-concepts/producer.md +++ b/.site/docs/core-concepts/producer.md @@ -52,8 +52,8 @@ metadata, while exposing direct helpers and a fluent builder for overrides Typed helpers are also available on `Canvas` (`definition.toSignature`) so group/chain/chord APIs produce strongly typed `TaskResult` streams. Need to tweak headers/meta/queue at call sites? Start from -`definition.enqueueBuilder(args)` for the neutral builder, or use the -caller-bound `enqueueBuilder(...)` when you want the enqueue target baked in. +`definition.prepareEnqueue(args)` for the neutral builder, or use the +caller-bound `prepareEnqueue(...)` when you want the enqueue target baked in. Raw task-name strings still work, but they are the lower-level interop path. Reach for them when the task name is truly dynamic or you are crossing a diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 90c6f698..bf1bab43 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -60,7 +60,7 @@ because they are published as a map. `TaskEnqueueBuilder` also supports `enqueueAndWait(...)`, and typed task definitions can now create a fluent builder directly through -`definition.enqueueBuilder(...)`. `TaskEnqueuer.enqueueBuilder(...)` remains +`definition.prepareEnqueue(...)`. `TaskEnqueuer.prepareEnqueue(...)` remains available when you want the caller-bound variant that keeps the enqueue target attached to the builder. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index dea62f03..6a4f1412 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Added `prepareEnqueue(...)` / `prepareNoArgsEnqueue(...)` as the clearer + task-side names for advanced enqueue builders, and deprecated the older + `enqueueBuilder(...)` / `enqueueNoArgsBuilder(...)` aliases. - Added `prepareWorkflowStart(...)` / `prepareNoArgsWorkflowStart(...)` as the clearer names for caller-bound workflow start builders, and deprecated the older `startWorkflowBuilder(...)` / `startNoArgsWorkflowBuilder(...)` diff --git a/packages/stem/README.md b/packages/stem/README.md index bdcda911..ac44f54b 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -256,7 +256,7 @@ You can also build requests fluently from the task definition itself: ```dart final result = await HelloTask.definition - .enqueueBuilder(const HelloArgs(name: 'Tenant A')) + .prepareEnqueue(const HelloArgs(name: 'Tenant A')) .header('x-tenant', 'tenant-a') .priority(5) .delay(const Duration(seconds: 30)) diff --git a/packages/stem/example/docs_snippets/lib/tasks.dart b/packages/stem/example/docs_snippets/lib/tasks.dart index 41a1fd03..00be4883 100644 --- a/packages/stem/example/docs_snippets/lib/tasks.dart +++ b/packages/stem/example/docs_snippets/lib/tasks.dart @@ -133,7 +133,7 @@ final childDefinition = TaskDefinition( // #region tasks-invocation-builder Future enqueueWithBuilder(TaskInvocationContext invocation) async { await childDefinition - .enqueueBuilder(const ChildArgs('value')) + .prepareEnqueue(const ChildArgs('value')) .queue('critical') .priority(9) .delay(const Duration(seconds: 5)) diff --git a/packages/stem/example/task_context_mixed/lib/shared.dart b/packages/stem/example/task_context_mixed/lib/shared.dart index b2ad7e97..b04d192e 100644 --- a/packages/stem/example/task_context_mixed/lib/shared.dart +++ b/packages/stem/example/task_context_mixed/lib/shared.dart @@ -307,11 +307,11 @@ FutureOr isolateChildEntrypoint( ); await context - .enqueueBuilder( + .prepareEnqueue( definition: auditDefinition, args: AuditArgs( runId: runId, - message: 'isolate child used enqueueBuilder', + message: 'isolate child used prepareEnqueue', ), ) .header('x-child', 'isolate') diff --git a/packages/stem/example/task_usage_patterns.dart b/packages/stem/example/task_usage_patterns.dart index 425108ec..0daa9e89 100644 --- a/packages/stem/example/task_usage_patterns.dart +++ b/packages/stem/example/task_usage_patterns.dart @@ -66,7 +66,7 @@ FutureOr invocationParentEntrypoint( Map args, ) async { await childDefinition - .enqueueBuilder(const ChildArgs('from-invocation-builder')) + .prepareEnqueue(const ChildArgs('from-invocation-builder')) .priority(5) .delay(const Duration(milliseconds: 100)) .enqueue(context); diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index f461cc31..04b04cf9 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2266,7 +2266,13 @@ class TaskDefinition { } /// Creates a fluent enqueue builder from this definition and [args]. + @Deprecated('Use prepareEnqueue(args) instead.') TaskEnqueueBuilder enqueueBuilder(TArgs args) { + return prepareEnqueue(args); + } + + /// Creates a fluent enqueue builder from this definition and [args]. + TaskEnqueueBuilder prepareEnqueue(TArgs args) { return TaskEnqueueBuilder(definition: this, args: args); } @@ -2340,8 +2346,14 @@ class NoArgsTaskDefinition { } /// Creates a fluent enqueue builder for this no-args task definition. + @Deprecated('Use prepareEnqueue() instead.') TaskEnqueueBuilder<(), TResult> enqueueBuilder() { - return asDefinition.enqueueBuilder(()); + return prepareEnqueue(); + } + + /// Creates a fluent enqueue builder for this no-args task definition. + TaskEnqueueBuilder<(), TResult> prepareEnqueue() { + return asDefinition.prepareEnqueue(()); } /// Decodes a persisted payload into a typed result. @@ -2629,9 +2641,18 @@ class BoundTaskEnqueueBuilder { /// enqueuer. extension TaskEnqueuerBuilderExtension on TaskEnqueuer { /// Creates a caller-bound fluent builder for a typed task definition. + @Deprecated('Use prepareEnqueue(definition: ..., args: ...) instead.') BoundTaskEnqueueBuilder enqueueBuilder({ required TaskDefinition definition, required TArgs args, + }) { + return prepareEnqueue(definition: definition, args: args); + } + + /// Creates a caller-bound fluent builder for a typed task definition. + BoundTaskEnqueueBuilder prepareEnqueue({ + required TaskDefinition definition, + required TArgs args, }) { return BoundTaskEnqueueBuilder( enqueuer: this, @@ -2640,8 +2661,16 @@ extension TaskEnqueuerBuilderExtension on TaskEnqueuer { } /// Creates a caller-bound fluent builder for a no-args task definition. + @Deprecated('Use prepareNoArgsEnqueue(definition: ...) instead.') BoundTaskEnqueueBuilder<(), TResult> enqueueNoArgsBuilder({ required NoArgsTaskDefinition definition, + }) { + return prepareNoArgsEnqueue(definition: definition); + } + + /// Creates a caller-bound fluent builder for a no-args task definition. + BoundTaskEnqueueBuilder<(), TResult> prepareNoArgsEnqueue({ + required NoArgsTaskDefinition definition, }) { return BoundTaskEnqueueBuilder( enqueuer: this, diff --git a/packages/stem/lib/src/core/task_invocation.dart b/packages/stem/lib/src/core/task_invocation.dart index e7c51319..b622a1e6 100644 --- a/packages/stem/lib/src/core/task_invocation.dart +++ b/packages/stem/lib/src/core/task_invocation.dart @@ -560,9 +560,18 @@ class TaskInvocationContext } /// Build a caller-bound fluent enqueue request for this invocation. + @Deprecated('Use prepareEnqueue(definition: ..., args: ...) instead.') BoundTaskEnqueueBuilder enqueueBuilder({ required TaskDefinition definition, required TArgs args, + }) { + return prepareEnqueue(definition: definition, args: args); + } + + /// Build a caller-bound fluent enqueue request for this invocation. + BoundTaskEnqueueBuilder prepareEnqueue({ + required TaskDefinition definition, + required TArgs args, }) { return BoundTaskEnqueueBuilder( enqueuer: this, diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 3c7675bd..2f42c28e 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -550,7 +550,7 @@ extension WorkflowStartBuilderExtension /// Caller-bound fluent workflow start builder. /// -/// This mirrors the role `TaskInvocationContext.enqueueBuilder(...)` plays for +/// This mirrors the role `TaskInvocationContext.prepareEnqueue(...)` plays for /// tasks: a workflow-capable caller can create a fluent start request without /// pivoting back through the workflow ref for dispatch. class BoundWorkflowStartBuilder { diff --git a/packages/stem/test/unit/core/task_context_enqueue_test.dart b/packages/stem/test/unit/core/task_context_enqueue_test.dart index 27d482df..f6d69d02 100644 --- a/packages/stem/test/unit/core/task_context_enqueue_test.dart +++ b/packages/stem/test/unit/core/task_context_enqueue_test.dart @@ -197,7 +197,7 @@ void main() { encodeArgs: (args) => {'value': args.value}, ); - final builder = context.enqueueBuilder( + final builder = context.prepareEnqueue( definition: definition, args: const _ExampleArgs('hello'), ); diff --git a/packages/stem/test/unit/core/task_enqueue_builder_test.dart b/packages/stem/test/unit/core/task_enqueue_builder_test.dart index 4daf8db4..27cc660f 100644 --- a/packages/stem/test/unit/core/task_enqueue_builder_test.dart +++ b/packages/stem/test/unit/core/task_enqueue_builder_test.dart @@ -63,14 +63,14 @@ void main() { expect(call.options?.priority, 9); }); - test('TaskDefinition.enqueueBuilder creates a fluent builder', () { + test('TaskDefinition.prepareEnqueue creates a fluent builder', () { final definition = TaskDefinition, Object?>( name: 'demo.task', encodeArgs: (args) => args, ); final call = definition - .enqueueBuilder(const {'a': 1}) + .prepareEnqueue(const {'a': 1}) .priority(7) .header('h1', 'v1') .build(); @@ -104,7 +104,7 @@ void main() { }, ); - test('TaskEnqueuer.enqueueBuilder binds enqueue to the enqueuer', () async { + test('TaskEnqueuer.prepareEnqueue binds enqueue to the enqueuer', () async { final enqueuer = _RecordingTaskEnqueuer(); final definition = TaskDefinition, String>( name: 'demo.task', @@ -113,7 +113,7 @@ void main() { ); final taskId = await enqueuer - .enqueueBuilder(definition: definition, args: const {'a': 1}) + .prepareEnqueue(definition: definition, args: const {'a': 1}) .header('h1', 'v1') .queue('critical') .enqueue(); @@ -136,7 +136,7 @@ void main() { ); final result = await caller - .enqueueBuilder(definition: definition, args: const {'a': 1}) + .prepareEnqueue(definition: definition, args: const {'a': 1}) .header('h1', 'v1') .enqueueAndWait(); @@ -159,7 +159,7 @@ void main() { expect( () => enqueuer - .enqueueBuilder(definition: definition, args: const {'a': 1}) + .prepareEnqueue(definition: definition, args: const {'a': 1}) .enqueueAndWait(), throwsStateError, ); @@ -200,10 +200,10 @@ void main() { expect(call.meta, containsPair('m', 1)); }); - test('NoArgsTaskDefinition.enqueueBuilder creates a fluent builder', () { + test('NoArgsTaskDefinition.prepareEnqueue creates a fluent builder', () { final definition = TaskDefinition.noArgs(name: 'demo.no_args'); - final call = definition.enqueueBuilder().priority(4).build(); + final call = definition.prepareEnqueue().priority(4).build(); expect(call.name, 'demo.no_args'); expect(call.resolveOptions().priority, 4); From c80f88e5e3c91bd3be90e2bcb6a55e56ab186d29 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 06:19:26 -0500 Subject: [PATCH 163/302] Add direct json task and workflow aliases --- .site/docs/core-concepts/tasks.md | 2 +- .site/docs/workflows/starting-and-waiting.md | 2 +- packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 10 +++---- .../stem/example/docs_snippets/lib/tasks.dart | 2 +- .../example/docs_snippets/lib/workflows.dart | 2 +- packages/stem/lib/src/core/contracts.dart | 29 +++++++++++++++++-- packages/stem/lib/src/workflow/core/flow.dart | 19 +++++++++++- .../workflow/core/workflow_definition.dart | 21 ++++++++++++-- .../lib/src/workflow/core/workflow_ref.dart | 23 ++++++++++++++- .../src/workflow/core/workflow_script.dart | 19 +++++++++++- .../stem/test/unit/core/stem_core_test.dart | 2 +- .../workflow/workflow_runtime_ref_test.dart | 4 +-- 13 files changed, 119 insertions(+), 19 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index bf1bab43..031d65f1 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -52,7 +52,7 @@ Typed results flow through `TaskResult` when you call `Canvas.chord`. Supplying a custom `decode` callback on the task signature lets you deserialize complex objects before they reach application code. -If your manual task args are DTOs, prefer `TaskDefinition.withJsonCodec(...)` +If your manual task args are DTOs, prefer `TaskDefinition.json(...)` when the type already has `toJson()` and `Type.fromJson(...)`. Use `TaskDefinition.withPayloadCodec(...)` when you need a custom `PayloadCodec`. Task args still need to encode to `Map` diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 1659e17d..224c1dbb 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -58,7 +58,7 @@ final runId = await approvalsRef .start(workflowApp); ``` -`refWithJsonCodec(...)` is the shortest manual DTO path when the params or +`refJson(...)` is the shortest manual DTO path when the params or final result already have `toJson()` and `Type.fromJson(...)`. Use `refWithCodec(...)` when you need a custom `PayloadCodec`. Workflow params still need to encode to `Map` because they are stored as a diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 6a4f1412..d7999a8f 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Added direct `TaskDefinition.json(...)`, `WorkflowRef.json(...)`, and + `refJson(...)` helpers for the common DTO path, and deprecated the older + `withJsonCodec(...)` / `refWithJsonCodec(...)` names. - Added `prepareEnqueue(...)` / `prepareNoArgsEnqueue(...)` as the clearer task-side names for advanced enqueue builders, and deprecated the older `enqueueBuilder(...)` / `enqueueNoArgsBuilder(...)` aliases. diff --git a/packages/stem/README.md b/packages/stem/README.md index ac44f54b..bc8d6b54 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -160,7 +160,7 @@ Use the new typed wrapper when you want compile-time checking and shared metadat ```dart class HelloTask implements TaskHandler { - static final definition = TaskDefinition.withJsonCodec( + static final definition = TaskDefinition.json( name: 'demo.hello', decodeArgs: HelloArgs.fromJson, metadata: TaskMetadata(description: 'Simple hello world example'), @@ -219,7 +219,7 @@ Future main() async { producer-only processes do not need to register the worker handler locally just to enqueue typed calls. -Use `TaskDefinition.withJsonCodec(...)` when your manual task args are normal +Use `TaskDefinition.json(...)` when your manual task args are normal DTOs with `toJson()` and `Type.fromJson(...)`. Drop down to `TaskDefinition.withPayloadCodec(...)` only when you need a custom `PayloadCodec`. Task args still need to encode to `Map` @@ -480,7 +480,7 @@ final approvalsFlow = Flow( }, ); -final approvalsRef = approvalsFlow.refWithJsonCodec( +final approvalsRef = approvalsFlow.refJson( decodeParams: ApprovalDraft.fromJson, ); @@ -514,8 +514,8 @@ final runId = await approvalsRef .start(app); ``` -Use `refWithJsonCodec(...)` when your manual workflow start params or final -result are normal DTOs with `toJson()` and `Type.fromJson(...)`. Drop down to +Use `refJson(...)` when your manual workflow start params or final result are +normal DTOs with `toJson()` and `Type.fromJson(...)`. Drop down to `refWithCodec(...)` when you need a custom `PayloadCodec`. Workflow params still need to encode to `Map` because they are persisted as a map. diff --git a/packages/stem/example/docs_snippets/lib/tasks.dart b/packages/stem/example/docs_snippets/lib/tasks.dart index 00be4883..2df5f56d 100644 --- a/packages/stem/example/docs_snippets/lib/tasks.dart +++ b/packages/stem/example/docs_snippets/lib/tasks.dart @@ -60,7 +60,7 @@ class InvoicePayload { class PublishInvoiceTask extends TaskHandler { static final definition = - TaskDefinition.withJsonCodec( + TaskDefinition.json( name: 'invoice.publish', decodeArgs: InvoicePayload.fromJson, metadata: const TaskMetadata( diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index 93fe8986..3e618bc1 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -76,7 +76,7 @@ class ApprovalsFlow { }, ); - static final ref = flow.refWithJsonCodec( + static final ref = flow.refJson( decodeParams: ApprovalDraft.fromJson, ); } diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 04b04cf9..010fc32f 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2112,7 +2112,7 @@ class TaskDefinition { /// Creates a typed task definition for DTO args that already expose /// `toJson()` and `Type.fromJson(...)`. - factory TaskDefinition.withJsonCodec({ + factory TaskDefinition.json({ required String name, required TArgs Function(Map payload) decodeArgs, TaskMetaBuilder? encodeMeta, @@ -2121,7 +2121,7 @@ class TaskDefinition { TResult Function(Map payload)? decodeResultJson, String? argsTypeName, String? resultTypeName, - }) { + }) { final resultCodec = decodeResultJson == null ? null : PayloadCodec.json( @@ -2141,6 +2141,31 @@ class TaskDefinition { ); } + /// Creates a typed task definition for DTO args that already expose + /// `toJson()` and `Type.fromJson(...)`. + @Deprecated('Use TaskDefinition.json(...) instead.') + factory TaskDefinition.withJsonCodec({ + required String name, + required TArgs Function(Map payload) decodeArgs, + TaskMetaBuilder? encodeMeta, + TaskOptions defaultOptions = const TaskOptions(), + TaskMetadata metadata = const TaskMetadata(), + TResult Function(Map payload)? decodeResultJson, + String? argsTypeName, + String? resultTypeName, + }) { + return TaskDefinition.json( + name: name, + decodeArgs: decodeArgs, + encodeMeta: encodeMeta, + defaultOptions: defaultOptions, + metadata: metadata, + decodeResultJson: decodeResultJson, + argsTypeName: argsTypeName, + resultTypeName: resultTypeName, + ); + } + /// Creates a typed task definition for handlers with no producer args. static NoArgsTaskDefinition noArgs({ required String name, diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index 3ecff566..ccfc8f28 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -53,13 +53,30 @@ class Flow { /// Builds a typed [WorkflowRef] for DTO params that already expose /// `toJson()` and `Type.fromJson(...)`. + WorkflowRef refJson({ + required TParams Function(Map payload) decodeParams, + T Function(Map payload)? decodeResultJson, + String? paramsTypeName, + String? resultTypeName, + }) { + return definition.refJson( + decodeParams: decodeParams, + decodeResultJson: decodeResultJson, + paramsTypeName: paramsTypeName, + resultTypeName: resultTypeName, + ); + } + + /// Builds a typed [WorkflowRef] for DTO params that already expose + /// `toJson()` and `Type.fromJson(...)`. + @Deprecated('Use refJson(...) instead.') WorkflowRef refWithJsonCodec({ required TParams Function(Map payload) decodeParams, T Function(Map payload)? decodeResultJson, String? paramsTypeName, String? resultTypeName, }) { - return definition.refWithJsonCodec( + return refJson( decodeParams: decodeParams, decodeResultJson: decodeResultJson, paramsTypeName: paramsTypeName, diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index 69a8627a..e76f2e4d 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -346,13 +346,13 @@ class WorkflowDefinition { /// Builds a typed [WorkflowRef] for DTO params that already expose /// `toJson()` and `Type.fromJson(...)`. - WorkflowRef refWithJsonCodec({ + WorkflowRef refJson({ required TParams Function(Map payload) decodeParams, T Function(Map payload)? decodeResultJson, String? paramsTypeName, String? resultTypeName, }) { - return WorkflowRef.withJsonCodec( + return WorkflowRef.json( name: name, decodeParams: decodeParams, decodeResultJson: decodeResultJson, @@ -362,6 +362,23 @@ class WorkflowDefinition { ); } + /// Builds a typed [WorkflowRef] for DTO params that already expose + /// `toJson()` and `Type.fromJson(...)`. + @Deprecated('Use refJson(...) instead.') + WorkflowRef refWithJsonCodec({ + required TParams Function(Map payload) decodeParams, + T Function(Map payload)? decodeResultJson, + String? paramsTypeName, + String? resultTypeName, + }) { + return refJson( + decodeParams: decodeParams, + decodeResultJson: decodeResultJson, + paramsTypeName: paramsTypeName, + resultTypeName: resultTypeName, + ); + } + /// Builds a typed [NoArgsWorkflowRef] from this definition. NoArgsWorkflowRef ref0() { return NoArgsWorkflowRef( diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 2f42c28e..24ed481f 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -31,7 +31,7 @@ class WorkflowRef { /// Creates a typed workflow reference for DTO params that already expose /// `toJson()` and `Type.fromJson(...)`. - factory WorkflowRef.withJsonCodec({ + factory WorkflowRef.json({ required String name, required TParams Function(Map payload) decodeParams, TResult Function(Map payload)? decodeResultJson, @@ -56,6 +56,27 @@ class WorkflowRef { ); } + /// Creates a typed workflow reference for DTO params that already expose + /// `toJson()` and `Type.fromJson(...)`. + @Deprecated('Use WorkflowRef.json(...) instead.') + factory WorkflowRef.withJsonCodec({ + required String name, + required TParams Function(Map payload) decodeParams, + TResult Function(Map payload)? decodeResultJson, + TResult Function(Object? payload)? decodeResult, + String? paramsTypeName, + String? resultTypeName, + }) { + return WorkflowRef.json( + name: name, + decodeParams: decodeParams, + decodeResultJson: decodeResultJson, + decodeResult: decodeResult, + paramsTypeName: paramsTypeName, + resultTypeName: resultTypeName, + ); + } + /// Registered workflow name. final String name; diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index dbab75a3..94b9d091 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -55,13 +55,30 @@ class WorkflowScript { /// Builds a typed [WorkflowRef] for DTO params that already expose /// `toJson()` and `Type.fromJson(...)`. + WorkflowRef refJson({ + required TParams Function(Map payload) decodeParams, + T Function(Map payload)? decodeResultJson, + String? paramsTypeName, + String? resultTypeName, + }) { + return definition.refJson( + decodeParams: decodeParams, + decodeResultJson: decodeResultJson, + paramsTypeName: paramsTypeName, + resultTypeName: resultTypeName, + ); + } + + /// Builds a typed [WorkflowRef] for DTO params that already expose + /// `toJson()` and `Type.fromJson(...)`. + @Deprecated('Use refJson(...) instead.') WorkflowRef refWithJsonCodec({ required TParams Function(Map payload) decodeParams, T Function(Map payload)? decodeResultJson, String? paramsTypeName, String? resultTypeName, }) { - return definition.refWithJsonCodec( + return refJson( decodeParams: decodeParams, decodeResultJson: decodeResultJson, paramsTypeName: paramsTypeName, diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index 408940b3..f55fc421 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -127,7 +127,7 @@ void main() { final broker = _RecordingBroker(); final backend = _RecordingBackend(); final stem = Stem(broker: broker, backend: backend); - final definition = TaskDefinition<_CodecTaskArgs, Object?>.withJsonCodec( + final definition = TaskDefinition<_CodecTaskArgs, Object?>.json( name: 'sample.json.args', decodeArgs: _CodecTaskArgs.fromJson, defaultOptions: const TaskOptions(queue: 'typed'), diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 9ca8097d..48b409f5 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -161,7 +161,7 @@ void main() { }); }, ); - final workflowRef = flow.refWithJsonCodec<_GreetingParams>( + final workflowRef = flow.refJson<_GreetingParams>( decodeParams: _GreetingParams.fromJson, ); @@ -193,7 +193,7 @@ void main() { ); }, ); - final workflowRef = flow.refWithJsonCodec<_GreetingParams>( + final workflowRef = flow.refJson<_GreetingParams>( decodeParams: _GreetingParams.fromJson, decodeResultJson: _GreetingResult.fromJson, ); From 2ae698ee00ad4e52b445eb1f17e78005ac5c962f Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 06:25:37 -0500 Subject: [PATCH 164/302] Remove old custom codec helper aliases --- .site/docs/core-concepts/tasks.md | 2 +- .site/docs/workflows/starting-and-waiting.md | 4 ++-- packages/stem/CHANGELOG.md | 7 ++++--- packages/stem/README.md | 4 ++-- packages/stem/lib/src/core/contracts.dart | 8 ++++---- packages/stem/lib/src/workflow/core/flow.dart | 4 ++-- .../lib/src/workflow/core/workflow_definition.dart | 4 ++-- .../lib/src/workflow/core/workflow_event_ref.dart | 14 +++++++++++++- .../stem/lib/src/workflow/core/workflow_ref.dart | 8 ++++---- .../lib/src/workflow/core/workflow_script.dart | 4 ++-- packages/stem/test/unit/core/stem_core_test.dart | 6 ++---- .../test/workflow/workflow_runtime_ref_test.dart | 4 ++-- .../stem/test/workflow/workflow_runtime_test.dart | 2 +- 13 files changed, 41 insertions(+), 30 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 031d65f1..dd1706ec 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -54,7 +54,7 @@ lets you deserialize complex objects before they reach application code. If your manual task args are DTOs, prefer `TaskDefinition.json(...)` when the type already has `toJson()` and `Type.fromJson(...)`. Use -`TaskDefinition.withPayloadCodec(...)` when you need a custom +`TaskDefinition.codec(...)` when you need a custom `PayloadCodec`. Task args still need to encode to `Map` because they are published as a map. diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 224c1dbb..5d62b182 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -29,7 +29,7 @@ const approvalDraftCodec = PayloadCodec.json( typeName: 'ApprovalDraft', ); -final approvalsRef = approvalsFlow.refWithCodec( +final approvalsRef = approvalsFlow.refCodec( paramsCodec: approvalDraftCodec, ); @@ -60,7 +60,7 @@ final runId = await approvalsRef `refJson(...)` is the shortest manual DTO path when the params or final result already have `toJson()` and `Type.fromJson(...)`. Use -`refWithCodec(...)` when you need a custom `PayloadCodec`. Workflow params +`refCodec(...)` when you need a custom `PayloadCodec`. Workflow params still need to encode to `Map` because they are stored as a map. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index d7999a8f..e7ffa383 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,9 +2,10 @@ ## 0.1.1 -- Added direct `TaskDefinition.json(...)`, `WorkflowRef.json(...)`, and - `refJson(...)` helpers for the common DTO path, and deprecated the older - `withJsonCodec(...)` / `refWithJsonCodec(...)` names. +- Replaced the older manual DTO helper names with direct forms: + `TaskDefinition.json(...)`, `TaskDefinition.codec(...)`, + `WorkflowRef.json(...)`, `WorkflowRef.codec(...)`, `refJson(...)`, and + `refCodec(...)`. - Added `prepareEnqueue(...)` / `prepareNoArgsEnqueue(...)` as the clearer task-side names for advanced enqueue builders, and deprecated the older `enqueueBuilder(...)` / `enqueueNoArgsBuilder(...)` aliases. diff --git a/packages/stem/README.md b/packages/stem/README.md index bc8d6b54..9fd396a1 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -221,7 +221,7 @@ to enqueue typed calls. Use `TaskDefinition.json(...)` when your manual task args are normal DTOs with `toJson()` and `Type.fromJson(...)`. Drop down to -`TaskDefinition.withPayloadCodec(...)` only when you need a custom +`TaskDefinition.codec(...)` only when you need a custom `PayloadCodec`. Task args still need to encode to `Map` because they are published as a map. @@ -516,7 +516,7 @@ final runId = await approvalsRef Use `refJson(...)` when your manual workflow start params or final result are normal DTOs with `toJson()` and `Type.fromJson(...)`. Drop down to -`refWithCodec(...)` when you need a custom `PayloadCodec`. Workflow params +`refCodec(...)` when you need a custom `PayloadCodec`. Workflow params still need to encode to `Map` because they are persisted as a map. diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 010fc32f..e96673d3 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2092,7 +2092,7 @@ class TaskDefinition { _encodeMeta = encodeMeta; /// Creates a typed task definition backed by payload codecs. - factory TaskDefinition.withPayloadCodec({ + factory TaskDefinition.codec({ required String name, required PayloadCodec argsCodec, TaskMetaBuilder? encodeMeta, @@ -2128,7 +2128,7 @@ class TaskDefinition { decode: decodeResultJson, typeName: resultTypeName ?? '$TResult', ); - return TaskDefinition.withPayloadCodec( + return TaskDefinition.codec( name: name, argsCodec: PayloadCodec.json( decode: decodeArgs, @@ -2230,7 +2230,7 @@ class TaskDefinition { final key = entry.key; if (key is! String) { throw StateError( - 'TaskDefinition.withPayloadCodec($taskName) requires payload keys ' + 'TaskDefinition.codec($taskName) requires payload keys ' 'to be strings, got ${key.runtimeType}.', ); } @@ -2239,7 +2239,7 @@ class TaskDefinition { return normalized; } throw StateError( - 'TaskDefinition.withPayloadCodec($taskName) must encode args to ' + 'TaskDefinition.codec($taskName) must encode args to ' 'Map, got ${payload.runtimeType}.', ); } diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index ccfc8f28..8e798f6e 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -45,10 +45,10 @@ class Flow { } /// Builds a typed [WorkflowRef] backed by a DTO [paramsCodec]. - WorkflowRef refWithCodec({ + WorkflowRef refCodec({ required PayloadCodec paramsCodec, }) { - return definition.refWithCodec(paramsCodec: paramsCodec); + return definition.refCodec(paramsCodec: paramsCodec); } /// Builds a typed [WorkflowRef] for DTO params that already expose diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index e76f2e4d..b5cd8087 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -334,10 +334,10 @@ class WorkflowDefinition { } /// Builds a typed [WorkflowRef] backed by a DTO [paramsCodec]. - WorkflowRef refWithCodec({ + WorkflowRef refCodec({ required PayloadCodec paramsCodec, }) { - return WorkflowRef.withPayloadCodec( + return WorkflowRef.codec( name: name, paramsCodec: paramsCodec, decodeResult: (payload) => decodeResult(payload) as T, diff --git a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart index 9e135467..5af8dc03 100644 --- a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart @@ -26,6 +26,18 @@ class WorkflowEventRef { this.codec, }); + /// Creates a typed workflow event reference for DTO payloads that already + /// expose `toJson()` and `Type.fromJson(...)`. + factory WorkflowEventRef.codec({ + required String topic, + required PayloadCodec codec, + }) { + return WorkflowEventRef( + topic: topic, + codec: codec, + ); + } + /// Creates a typed workflow event reference for DTO payloads that already /// expose `toJson()` and `Type.fromJson(...)`. factory WorkflowEventRef.json({ @@ -33,7 +45,7 @@ class WorkflowEventRef { required T Function(Map payload) decode, String? typeName, }) { - return WorkflowEventRef( + return WorkflowEventRef.codec( topic: topic, codec: PayloadCodec.json( decode: decode, diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 24ed481f..6abaf955 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -16,7 +16,7 @@ class WorkflowRef { }); /// Creates a typed workflow reference backed by payload codecs. - factory WorkflowRef.withPayloadCodec({ + factory WorkflowRef.codec({ required String name, required PayloadCodec paramsCodec, PayloadCodec? resultCodec, @@ -45,7 +45,7 @@ class WorkflowRef { decode: decodeResultJson, typeName: resultTypeName ?? '$TResult', ); - return WorkflowRef.withPayloadCodec( + return WorkflowRef.codec( name: name, paramsCodec: PayloadCodec.json( decode: decodeParams, @@ -101,7 +101,7 @@ class WorkflowRef { final key = entry.key; if (key is! String) { throw StateError( - 'WorkflowRef.withPayloadCodec($workflowName) requires payload ' + 'WorkflowRef.codec($workflowName) requires payload ' 'keys to be strings, got ${key.runtimeType}.', ); } @@ -110,7 +110,7 @@ class WorkflowRef { return normalized; } throw StateError( - 'WorkflowRef.withPayloadCodec($workflowName) must encode params to ' + 'WorkflowRef.codec($workflowName) must encode params to ' 'Map, got ${payload.runtimeType}.', ); } diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index 94b9d091..6882be9f 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -47,10 +47,10 @@ class WorkflowScript { } /// Builds a typed [WorkflowRef] backed by a DTO [paramsCodec]. - WorkflowRef refWithCodec({ + WorkflowRef refCodec({ required PayloadCodec paramsCodec, }) { - return definition.refWithCodec(paramsCodec: paramsCodec); + return definition.refCodec(paramsCodec: paramsCodec); } /// Builds a typed [WorkflowRef] for DTO params that already expose diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index f55fc421..03828989 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -104,8 +104,7 @@ void main() { final broker = _RecordingBroker(); final backend = _RecordingBackend(); final stem = Stem(broker: broker, backend: backend); - final definition = - TaskDefinition<_CodecTaskArgs, Object?>.withPayloadCodec( + final definition = TaskDefinition<_CodecTaskArgs, Object?>.codec( name: 'sample.codec.args', argsCodec: _codecTaskArgsCodec, defaultOptions: const TaskOptions(queue: 'typed'), @@ -189,8 +188,7 @@ void main() { final broker = _RecordingBroker(); final backend = _RecordingBackend(); final stem = Stem(broker: broker, backend: backend); - final definition = - TaskDefinition<_CodecTaskArgs, _CodecReceipt>.withPayloadCodec( + final definition = TaskDefinition<_CodecTaskArgs, _CodecReceipt>.codec( name: 'sample.codec.result', argsCodec: _codecTaskArgsCodec, resultCodec: _codecReceiptCodec, diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 48b409f5..2c6b27e9 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -131,7 +131,7 @@ void main() { }); }, ); - final workflowRef = flow.refWithCodec<_GreetingParams>( + final workflowRef = flow.refCodec<_GreetingParams>( paramsCodec: _greetingParamsCodec, ); @@ -226,7 +226,7 @@ void main() { }); }, ); - final workflowRef = flow.refWithCodec<_GreetingParams>( + final workflowRef = flow.refCodec<_GreetingParams>( paramsCodec: _greetingParamsCodec, ); diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index ad01abe2..02fe040b 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -674,7 +674,7 @@ void main() { }); test('emitEvent resumes flows with typed workflow event refs', () async { - final event = WorkflowEventRef<_UserUpdatedEvent>( + final event = WorkflowEventRef<_UserUpdatedEvent>.codec( topic: 'user.updated.ref', codec: _userUpdatedEventCodec, ); From 12ddda90d45db2bbd79ed09b8acd80dd937e88a7 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 06:37:35 -0500 Subject: [PATCH 165/302] Remove deprecated workflow event aliases --- packages/stem/CHANGELOG.md | 6 + .../src/workflow/core/workflow_event_ref.dart | 53 -------- .../src/workflow/core/workflow_resume.dart | 116 ------------------ 3 files changed, 6 insertions(+), 169 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index e7ffa383..efca1a60 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,12 @@ ## 0.1.1 +- Removed the deprecated workflow-event compatibility helpers: + `emitWith(...)`, `emitEventBuilder(...)`, `waitForEventRef(...)`, + `waitForEventRefValue(...)`, `awaitEventRef(...)`, `waitValueWith(...)`, and + `waitWith(...)`. The direct `event.emit(...)`, `event.call(...).emit(...)`, + `event.wait(...)`, `event.waitValue(...)`, and `event.awaitOn(...)` surfaces + are now the only supported forms. - Replaced the older manual DTO helper names with direct forms: `TaskDefinition.json(...)`, `TaskDefinition.codec(...)`, `WorkflowRef.json(...)`, `WorkflowRef.codec(...)`, `refJson(...)`, and diff --git a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart index 5af8dc03..65917a49 100644 --- a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart @@ -89,12 +89,6 @@ extension WorkflowEventRefExtension on WorkflowEventRef { Future emit(WorkflowEventEmitter emitter, T value) { return emitter.emitEvent(this, value); } - - /// Emits this typed event with the provided [emitter]. - @Deprecated('Use emit(emitter, value) instead.') - Future emitWith(WorkflowEventEmitter emitter, T value) { - return emit(emitter, value); - } } /// Convenience helpers for dispatching prebuilt [WorkflowEventCall] instances. @@ -103,51 +97,4 @@ extension WorkflowEventCallExtension on WorkflowEventCall { Future emit(WorkflowEventEmitter emitter) { return emitter.emitEvent(event, value); } - - /// Emits this typed event with the provided [emitter]. - @Deprecated('Use emit(emitter) instead.') - Future emitWith(WorkflowEventEmitter emitter) { - return emit(emitter); - } -} - -@Deprecated( - 'Use WorkflowEventRef.call(value) or event.emit(emitter, value) instead.', -) -/// Caller-bound typed workflow event emission call. -class BoundWorkflowEventCall { - /// Creates a caller-bound typed workflow event emission call. - @Deprecated( - 'Use WorkflowEventRef.call(value) or event.emit(emitter, value) instead.', - ) - const BoundWorkflowEventCall._({ - required WorkflowEventEmitter emitter, - required WorkflowEventCall call, - }) : _emitter = emitter, - _call = call; - - final WorkflowEventEmitter _emitter; - final WorkflowEventCall _call; - - /// Returns the prebuilt typed workflow event call. - WorkflowEventCall build() => _call; - - /// Emits the bound typed workflow event call. - Future emit() => _call.emit(_emitter); -} - -/// Convenience helpers for building typed workflow event calls directly from a -/// workflow event emitter. -extension WorkflowEventEmitterBuilderExtension on WorkflowEventEmitter { - /// Creates a caller-bound typed workflow event call for [event] and [value]. - @Deprecated('Use event.call(value) or event.emit(this, value) instead.') - BoundWorkflowEventCall emitEventBuilder({ - required WorkflowEventRef event, - required T value, - }) { - return BoundWorkflowEventCall._( - emitter: this, - call: event.call(value), - ); - } } diff --git a/packages/stem/lib/src/workflow/core/workflow_resume.dart b/packages/stem/lib/src/workflow/core/workflow_resume.dart index fc622d89..c8ee93b6 100644 --- a/packages/stem/lib/src/workflow/core/workflow_resume.dart +++ b/packages/stem/lib/src/workflow/core/workflow_resume.dart @@ -114,51 +114,6 @@ extension FlowContextResumeValues on FlowContext { awaitEvent(topic, deadline: deadline, data: data); throw const WorkflowSuspensionSignal(); } - - /// Returns the next event payload from [event] when the step has resumed, or - /// registers an event wait and returns `null` on the first invocation. - @Deprecated('Use event.waitValue(this, ...) instead.') - T? waitForEventRef( - WorkflowEventRef event, { - DateTime? deadline, - Map? data, - }) { - return waitForEventValue( - event.topic, - deadline: deadline, - data: data, - codec: event.codec, - ); - } - - /// Suspends until [event] is emitted, then returns the decoded payload. - @Deprecated('Use event.wait(this, ...) instead.') - Future waitForEventRefValue({ - required WorkflowEventRef event, - DateTime? deadline, - Map? data, - }) { - return waitForEvent( - topic: event.topic, - deadline: deadline, - data: data, - codec: event.codec, - ); - } - - /// Registers an event wait using a typed [event] reference. - @Deprecated('Use event.awaitOn(this, ...) instead.') - FlowStepControl awaitEventRef( - WorkflowEventRef event, { - DateTime? deadline, - Map? data, - }) { - return awaitEvent( - event.topic, - deadline: deadline, - data: data, - ); - } } /// Typed resume helpers for durable script checkpoints. @@ -232,52 +187,6 @@ extension WorkflowScriptStepResumeValues on WorkflowScriptStepContext { await awaitEvent(topic, deadline: deadline, data: data); throw const WorkflowSuspensionSignal(); } - - /// Returns the next event payload from [event] when the checkpoint has - /// resumed, or registers an event wait and returns `null` on the first - /// invocation. - @Deprecated('Use event.waitValue(this, ...) instead.') - T? waitForEventRef( - WorkflowEventRef event, { - DateTime? deadline, - Map? data, - }) { - return waitForEventValue( - event.topic, - deadline: deadline, - data: data, - codec: event.codec, - ); - } - - /// Suspends until [event] is emitted, then returns the decoded payload. - @Deprecated('Use event.wait(this, ...) instead.') - Future waitForEventRefValue({ - required WorkflowEventRef event, - DateTime? deadline, - Map? data, - }) { - return waitForEvent( - topic: event.topic, - deadline: deadline, - data: data, - codec: event.codec, - ); - } - - /// Registers an event wait using a typed [event] reference. - @Deprecated('Use event.wait(this, ...) instead.') - Future awaitEventRef( - WorkflowEventRef event, { - DateTime? deadline, - Map? data, - }) { - return awaitEvent( - event.topic, - deadline: deadline, - data: data, - ); - } } /// Direct typed wait helpers on [WorkflowEventRef]. @@ -332,19 +241,6 @@ extension WorkflowEventRefWaitExtension on WorkflowEventRef { ); } - /// Registers an event wait and returns the resumed payload on the legacy - /// null-then-resume path. - /// - /// [waiter] must be a [FlowContext] or [WorkflowScriptStepContext]. - @Deprecated('Use waitValue(waiter, ...) instead.') - T? waitValueWith( - Object waiter, { - DateTime? deadline, - Map? data, - }) { - return waitValue(waiter, deadline: deadline, data: data); - } - /// Suspends until this event is emitted, then returns the decoded payload. /// /// [waiter] must be a [FlowContext] or [WorkflowScriptStepContext]. @@ -376,16 +272,4 @@ extension WorkflowEventRefWaitExtension on WorkflowEventRef { 'WorkflowScriptStepContext.', ); } - - /// Suspends until this event is emitted, then returns the decoded payload. - /// - /// [waiter] must be a [FlowContext] or [WorkflowScriptStepContext]. - @Deprecated('Use wait(waiter, ...) instead.') - Future waitWith( - Object waiter, { - DateTime? deadline, - Map? data, - }) { - return wait(waiter, deadline: deadline, data: data); - } } From cecc88026dbc133c732024b47c53cb0add390372 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 06:39:29 -0500 Subject: [PATCH 166/302] Remove deprecated workflow start aliases --- packages/stem/CHANGELOG.md | 5 + packages/stem/lib/src/workflow/core/flow.dart | 36 ----- .../lib/src/workflow/core/workflow_ref.dart | 138 ------------------ .../src/workflow/core/workflow_script.dart | 36 ----- 4 files changed, 5 insertions(+), 210 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index efca1a60..e756c8bf 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -8,6 +8,11 @@ `waitWith(...)`. The direct `event.emit(...)`, `event.call(...).emit(...)`, `event.wait(...)`, `event.waitValue(...)`, and `event.awaitOn(...)` surfaces are now the only supported forms. +- Removed the deprecated workflow-start compatibility helpers: + `startWith(...)`, `startAndWaitWith(...)`, `startWorkflowBuilder(...)`, and + `startNoArgsWorkflowBuilder(...)`. The direct `start(...)`, + `startAndWait(...)`, and `prepareWorkflowStart(...)` forms are now the only + supported workflow-start surfaces. - Replaced the older manual DTO helper names with direct forms: `TaskDefinition.json(...)`, `TaskDefinition.codec(...)`, `WorkflowRef.json(...)`, `WorkflowRef.codec(...)`, `refJson(...)`, and diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index 8e798f6e..c7151b5b 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -94,22 +94,6 @@ class Flow { return ref0().startBuilder(); } - /// Starts this flow directly when it does not accept start params. - @Deprecated('Use start(caller, ...) instead.') - Future startWith( - WorkflowCaller caller, { - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return ref0().startWith( - caller, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ); - } - /// Starts this flow directly when it does not accept start params. Future start( WorkflowCaller caller, { @@ -125,26 +109,6 @@ class Flow { ); } - /// Starts this flow directly and waits for completion. - @Deprecated('Use startAndWait(caller, ...) instead.') - Future?> startAndWaitWith( - WorkflowCaller caller, { - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return ref0().startAndWaitWith( - caller, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - pollInterval: pollInterval, - timeout: timeout, - ); - } - /// Starts this flow directly and waits for completion. Future?> startAndWait( WorkflowCaller caller, { diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 6abaf955..ac2bb795 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -148,23 +148,6 @@ class WorkflowRef { return payload as TResult; } - /// Starts this workflow ref directly with [caller]. - @Deprecated('Use start(caller, params: ...) instead.') - Future startWith( - WorkflowCaller caller, - TParams params, { - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return call( - params, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ).startWith(caller); - } - /// Starts this workflow ref directly with [caller] using named args. Future start( WorkflowCaller caller, { @@ -181,29 +164,6 @@ class WorkflowRef { ).start(caller); } - /// Starts this workflow ref with [caller] and waits for the result. - @Deprecated('Use startAndWait(caller, params: ...) instead.') - Future?> startAndWaitWith( - WorkflowCaller caller, - TParams params, { - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return call( - params, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ).startAndWait( - caller, - pollInterval: pollInterval, - timeout: timeout, - ); - } - /// Starts this workflow ref with [caller] and waits for the result using /// named args. Future?> startAndWait( @@ -269,21 +229,6 @@ class NoArgsWorkflowRef { return asRef.startBuilder(()); } - /// Starts this workflow ref directly with [caller]. - @Deprecated('Use start(caller, ...) instead.') - Future startWith( - WorkflowCaller caller, { - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return call( - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ).startWith(caller); - } - /// Starts this workflow ref directly with [caller]. Future start( WorkflowCaller caller, { @@ -298,27 +243,6 @@ class NoArgsWorkflowRef { ).start(caller); } - /// Starts this workflow ref with [caller] and waits for the result. - @Deprecated('Use startAndWait(caller, ...) instead.') - Future?> startAndWaitWith( - WorkflowCaller caller, { - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return call( - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ).startAndWait( - caller, - pollInterval: pollInterval, - timeout: timeout, - ); - } - /// Starts this workflow ref with [caller] and waits for the result. Future?> startAndWait( WorkflowCaller caller, { @@ -485,12 +409,6 @@ extension WorkflowStartCallExtension return caller.startWorkflowCall(this); } - /// Starts this typed workflow call with the provided [caller]. - @Deprecated('Use start(caller) instead.') - Future startWith(WorkflowCaller caller) { - return start(caller); - } - /// Starts this typed workflow call with [caller] and waits for the result. Future?> startAndWait( WorkflowCaller caller, { @@ -508,21 +426,6 @@ extension WorkflowStartCallExtension }); } - /// Starts this typed workflow call with [caller] and waits for the result. - @Deprecated('Use startAndWait(caller, ...) instead.') - Future?> startAndWaitWith( - WorkflowCaller caller, { - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) async { - final runId = await start(caller); - return definition.waitFor( - caller, - runId, - pollInterval: pollInterval, - timeout: timeout, - ); - } } /// Convenience helpers for dispatching [WorkflowStartBuilder] instances. @@ -533,12 +436,6 @@ extension WorkflowStartBuilderExtension return build().start(caller); } - /// Builds this workflow call and starts it with the provided [caller]. - @Deprecated('Use start(caller) instead.') - Future startWith(WorkflowCaller caller) { - return start(caller); - } - /// Builds this workflow call, starts it with [caller], and waits for the /// result. Future?> startAndWait( @@ -553,20 +450,6 @@ extension WorkflowStartBuilderExtension ); } - /// Builds this workflow call, starts it with [caller], and waits for the - /// result. - @Deprecated('Use startAndWait(caller, ...) instead.') - Future?> startAndWaitWith( - WorkflowCaller caller, { - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return startAndWait( - caller, - pollInterval: pollInterval, - timeout: timeout, - ); - } } /// Caller-bound fluent workflow start builder. @@ -640,19 +523,6 @@ extension WorkflowCallerBuilderExtension on WorkflowCaller { ); } - /// Creates a caller-bound fluent start builder for a typed workflow ref. - @Deprecated('Use prepareWorkflowStart(...) instead.') - BoundWorkflowStartBuilder - startWorkflowBuilder({ - required WorkflowRef definition, - required TParams params, - }) { - return prepareWorkflowStart( - definition: definition, - params: params, - ); - } - /// Creates a caller-bound fluent start builder for a no-args workflow ref. BoundWorkflowStartBuilder<(), TResult> prepareNoArgsWorkflowStart({ @@ -664,14 +534,6 @@ extension WorkflowCallerBuilderExtension on WorkflowCaller { ); } - /// Creates a caller-bound fluent start builder for a no-args workflow ref. - @Deprecated('Use prepareNoArgsWorkflowStart(...) instead.') - BoundWorkflowStartBuilder<(), TResult> - startNoArgsWorkflowBuilder({ - required NoArgsWorkflowRef definition, - }) { - return prepareNoArgsWorkflowStart(definition: definition); - } } /// Convenience helpers for waiting on typed workflow refs using a generic diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index 6882be9f..e23602bb 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -96,22 +96,6 @@ class WorkflowScript { return ref0().startBuilder(); } - /// Starts this script directly when it does not accept start params. - @Deprecated('Use start(caller, ...) instead.') - Future startWith( - WorkflowCaller caller, { - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return ref0().startWith( - caller, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ); - } - /// Starts this script directly when it does not accept start params. Future start( WorkflowCaller caller, { @@ -127,26 +111,6 @@ class WorkflowScript { ); } - /// Starts this script directly and waits for completion. - @Deprecated('Use startAndWait(caller, ...) instead.') - Future?> startAndWaitWith( - WorkflowCaller caller, { - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return ref0().startAndWaitWith( - caller, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - pollInterval: pollInterval, - timeout: timeout, - ); - } - /// Starts this script directly and waits for completion. Future?> startAndWait( WorkflowCaller caller, { From 4f96fac95e828492b5a4c256ab72f844b9a9544c Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 06:40:28 -0500 Subject: [PATCH 167/302] Remove deprecated task builder aliases --- packages/stem/CHANGELOG.md | 4 +++ packages/stem/lib/src/core/contracts.dart | 29 ------------------- .../stem/lib/src/core/task_invocation.dart | 9 ------ ...task_context_enqueue_integration_test.dart | 2 +- 4 files changed, 5 insertions(+), 39 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index e756c8bf..72863bc9 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -13,6 +13,10 @@ `startNoArgsWorkflowBuilder(...)`. The direct `start(...)`, `startAndWait(...)`, and `prepareWorkflowStart(...)` forms are now the only supported workflow-start surfaces. +- Removed the deprecated task-builder compatibility helpers: + `enqueueBuilder(...)` and `enqueueNoArgsBuilder(...)`. The direct + `prepareEnqueue(...)` and `prepareNoArgsEnqueue(...)` forms are now the only + supported builder entrypoints. - Replaced the older manual DTO helper names with direct forms: `TaskDefinition.json(...)`, `TaskDefinition.codec(...)`, `WorkflowRef.json(...)`, `WorkflowRef.codec(...)`, `refJson(...)`, and diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index e96673d3..50c314f9 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2290,12 +2290,6 @@ class TaskDefinition { ); } - /// Creates a fluent enqueue builder from this definition and [args]. - @Deprecated('Use prepareEnqueue(args) instead.') - TaskEnqueueBuilder enqueueBuilder(TArgs args) { - return prepareEnqueue(args); - } - /// Creates a fluent enqueue builder from this definition and [args]. TaskEnqueueBuilder prepareEnqueue(TArgs args) { return TaskEnqueueBuilder(definition: this, args: args); @@ -2370,12 +2364,6 @@ class NoArgsTaskDefinition { ); } - /// Creates a fluent enqueue builder for this no-args task definition. - @Deprecated('Use prepareEnqueue() instead.') - TaskEnqueueBuilder<(), TResult> enqueueBuilder() { - return prepareEnqueue(); - } - /// Creates a fluent enqueue builder for this no-args task definition. TaskEnqueueBuilder<(), TResult> prepareEnqueue() { return asDefinition.prepareEnqueue(()); @@ -2665,15 +2653,6 @@ class BoundTaskEnqueueBuilder { /// Convenience helpers for building typed enqueue requests directly from a task /// enqueuer. extension TaskEnqueuerBuilderExtension on TaskEnqueuer { - /// Creates a caller-bound fluent builder for a typed task definition. - @Deprecated('Use prepareEnqueue(definition: ..., args: ...) instead.') - BoundTaskEnqueueBuilder enqueueBuilder({ - required TaskDefinition definition, - required TArgs args, - }) { - return prepareEnqueue(definition: definition, args: args); - } - /// Creates a caller-bound fluent builder for a typed task definition. BoundTaskEnqueueBuilder prepareEnqueue({ required TaskDefinition definition, @@ -2685,14 +2664,6 @@ extension TaskEnqueuerBuilderExtension on TaskEnqueuer { ); } - /// Creates a caller-bound fluent builder for a no-args task definition. - @Deprecated('Use prepareNoArgsEnqueue(definition: ...) instead.') - BoundTaskEnqueueBuilder<(), TResult> enqueueNoArgsBuilder({ - required NoArgsTaskDefinition definition, - }) { - return prepareNoArgsEnqueue(definition: definition); - } - /// Creates a caller-bound fluent builder for a no-args task definition. BoundTaskEnqueueBuilder<(), TResult> prepareNoArgsEnqueue({ required NoArgsTaskDefinition definition, diff --git a/packages/stem/lib/src/core/task_invocation.dart b/packages/stem/lib/src/core/task_invocation.dart index b622a1e6..5a35a911 100644 --- a/packages/stem/lib/src/core/task_invocation.dart +++ b/packages/stem/lib/src/core/task_invocation.dart @@ -559,15 +559,6 @@ class TaskInvocationContext return delegate.emitEvent(event, value); } - /// Build a caller-bound fluent enqueue request for this invocation. - @Deprecated('Use prepareEnqueue(definition: ..., args: ...) instead.') - BoundTaskEnqueueBuilder enqueueBuilder({ - required TaskDefinition definition, - required TArgs args, - }) { - return prepareEnqueue(definition: definition, args: args); - } - /// Build a caller-bound fluent enqueue request for this invocation. BoundTaskEnqueueBuilder prepareEnqueue({ required TaskDefinition definition, diff --git a/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart b/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart index 58715435..5b62e8ea 100644 --- a/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart +++ b/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart @@ -535,7 +535,7 @@ FutureOr _isolateEnqueueEntrypoint( TaskInvocationContext context, Map args, ) async { - final builder = context.enqueueBuilder( + final builder = context.prepareEnqueue( definition: _childDefinition, args: const _ChildArgs('from-isolate'), ); From faf27da19897b6f4df3c9aeb355f3af1f7dcdc5f Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 06:41:42 -0500 Subject: [PATCH 168/302] Remove deprecated json codec aliases --- packages/stem/CHANGELOG.md | 3 +++ packages/stem/lib/src/core/contracts.dart | 25 ------------------- packages/stem/lib/src/workflow/core/flow.dart | 17 ------------- .../workflow/core/workflow_definition.dart | 17 ------------- .../lib/src/workflow/core/workflow_ref.dart | 21 ---------------- .../src/workflow/core/workflow_script.dart | 17 ------------- 6 files changed, 3 insertions(+), 97 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 72863bc9..e4100986 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -17,6 +17,9 @@ `enqueueBuilder(...)` and `enqueueNoArgsBuilder(...)`. The direct `prepareEnqueue(...)` and `prepareNoArgsEnqueue(...)` forms are now the only supported builder entrypoints. +- Removed the deprecated `withJsonCodec(...)` / `refWithJsonCodec(...)` + compatibility helpers. The direct `json(...)` / `refJson(...)` forms are now + the only supported JSON shortcut APIs. - Replaced the older manual DTO helper names with direct forms: `TaskDefinition.json(...)`, `TaskDefinition.codec(...)`, `WorkflowRef.json(...)`, `WorkflowRef.codec(...)`, `refJson(...)`, and diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 50c314f9..ae32c6d8 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2141,31 +2141,6 @@ class TaskDefinition { ); } - /// Creates a typed task definition for DTO args that already expose - /// `toJson()` and `Type.fromJson(...)`. - @Deprecated('Use TaskDefinition.json(...) instead.') - factory TaskDefinition.withJsonCodec({ - required String name, - required TArgs Function(Map payload) decodeArgs, - TaskMetaBuilder? encodeMeta, - TaskOptions defaultOptions = const TaskOptions(), - TaskMetadata metadata = const TaskMetadata(), - TResult Function(Map payload)? decodeResultJson, - String? argsTypeName, - String? resultTypeName, - }) { - return TaskDefinition.json( - name: name, - decodeArgs: decodeArgs, - encodeMeta: encodeMeta, - defaultOptions: defaultOptions, - metadata: metadata, - decodeResultJson: decodeResultJson, - argsTypeName: argsTypeName, - resultTypeName: resultTypeName, - ); - } - /// Creates a typed task definition for handlers with no producer args. static NoArgsTaskDefinition noArgs({ required String name, diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index c7151b5b..093e79fa 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -67,23 +67,6 @@ class Flow { ); } - /// Builds a typed [WorkflowRef] for DTO params that already expose - /// `toJson()` and `Type.fromJson(...)`. - @Deprecated('Use refJson(...) instead.') - WorkflowRef refWithJsonCodec({ - required TParams Function(Map payload) decodeParams, - T Function(Map payload)? decodeResultJson, - String? paramsTypeName, - String? resultTypeName, - }) { - return refJson( - decodeParams: decodeParams, - decodeResultJson: decodeResultJson, - paramsTypeName: paramsTypeName, - resultTypeName: resultTypeName, - ); - } - /// Builds a typed [NoArgsWorkflowRef] for flows without start params. NoArgsWorkflowRef ref0() { return definition.ref0(); diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index b5cd8087..ba4f1983 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -362,23 +362,6 @@ class WorkflowDefinition { ); } - /// Builds a typed [WorkflowRef] for DTO params that already expose - /// `toJson()` and `Type.fromJson(...)`. - @Deprecated('Use refJson(...) instead.') - WorkflowRef refWithJsonCodec({ - required TParams Function(Map payload) decodeParams, - T Function(Map payload)? decodeResultJson, - String? paramsTypeName, - String? resultTypeName, - }) { - return refJson( - decodeParams: decodeParams, - decodeResultJson: decodeResultJson, - paramsTypeName: paramsTypeName, - resultTypeName: resultTypeName, - ); - } - /// Builds a typed [NoArgsWorkflowRef] from this definition. NoArgsWorkflowRef ref0() { return NoArgsWorkflowRef( diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index ac2bb795..371fe02c 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -56,27 +56,6 @@ class WorkflowRef { ); } - /// Creates a typed workflow reference for DTO params that already expose - /// `toJson()` and `Type.fromJson(...)`. - @Deprecated('Use WorkflowRef.json(...) instead.') - factory WorkflowRef.withJsonCodec({ - required String name, - required TParams Function(Map payload) decodeParams, - TResult Function(Map payload)? decodeResultJson, - TResult Function(Object? payload)? decodeResult, - String? paramsTypeName, - String? resultTypeName, - }) { - return WorkflowRef.json( - name: name, - decodeParams: decodeParams, - decodeResultJson: decodeResultJson, - decodeResult: decodeResult, - paramsTypeName: paramsTypeName, - resultTypeName: resultTypeName, - ); - } - /// Registered workflow name. final String name; diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index e23602bb..1e1a7602 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -69,23 +69,6 @@ class WorkflowScript { ); } - /// Builds a typed [WorkflowRef] for DTO params that already expose - /// `toJson()` and `Type.fromJson(...)`. - @Deprecated('Use refJson(...) instead.') - WorkflowRef refWithJsonCodec({ - required TParams Function(Map payload) decodeParams, - T Function(Map payload)? decodeResultJson, - String? paramsTypeName, - String? resultTypeName, - }) { - return refJson( - decodeParams: decodeParams, - decodeResultJson: decodeResultJson, - paramsTypeName: paramsTypeName, - resultTypeName: resultTypeName, - ); - } - /// Builds a typed [NoArgsWorkflowRef] for scripts without start params. NoArgsWorkflowRef ref0() { return definition.ref0(); From dc865c607efbbf5e979d1efdc08c8a2081cd67e2 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 06:42:21 -0500 Subject: [PATCH 169/302] Remove SimpleTaskRegistry alias --- packages/stem/CHANGELOG.md | 2 ++ packages/stem/lib/src/core/contracts.dart | 4 ---- packages/stem/test/unit/core/task_registry_test.dart | 11 ----------- 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index e4100986..560ec66d 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -20,6 +20,8 @@ - Removed the deprecated `withJsonCodec(...)` / `refWithJsonCodec(...)` compatibility helpers. The direct `json(...)` / `refJson(...)` forms are now the only supported JSON shortcut APIs. +- Removed the legacy `SimpleTaskRegistry` compatibility alias. Use + `InMemoryTaskRegistry` directly. - Replaced the older manual DTO helper names with direct forms: `TaskDefinition.json(...)`, `TaskDefinition.codec(...)`, `WorkflowRef.json(...)`, `WorkflowRef.codec(...)`, `refJson(...)`, and diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index ae32c6d8..b88e8638 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2013,10 +2013,6 @@ class InMemoryTaskRegistry implements TaskRegistry { Stream get onRegister => _registerController.stream; } -/// Backwards-compatible alias for the default in-memory registry. -@Deprecated('Use InMemoryTaskRegistry instead.') -typedef SimpleTaskRegistry = InMemoryTaskRegistry; - /// Optional task metadata for documentation and tooling. class TaskMetadata { /// Creates task metadata for documentation and tooling. diff --git a/packages/stem/test/unit/core/task_registry_test.dart b/packages/stem/test/unit/core/task_registry_test.dart index 28fd54e6..b229c555 100644 --- a/packages/stem/test/unit/core/task_registry_test.dart +++ b/packages/stem/test/unit/core/task_registry_test.dart @@ -192,17 +192,6 @@ void main() { expect(handler.metadata.description, 'Example task'); }); - test('retains SimpleTaskRegistry as a compatibility alias', () { - // Compatibility coverage intentionally exercises the deprecated symbol. - // ignore: deprecated_member_use_from_same_package - final registry = SimpleTaskRegistry(); - // A single plain call is clearer here than forcing a one-off cascade. - // ignore: cascade_invocations - registry.register(_TestHandler('legacy.task')); - - expect(registry, isA()); - expect(registry.resolve('legacy.task')?.name, 'legacy.task'); - }); }); group('TaskDefinition', () { From aa7d303724e0c3cd2cd236edf476e15ea1e3cef9 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 06:53:55 -0500 Subject: [PATCH 170/302] Support dynamic-map DTO codecs --- .site/docs/core-concepts/stem-builder.md | 4 +- .site/docs/core-concepts/tasks.md | 5 ++- .site/docs/workflows/annotated-workflows.md | 4 +- .../workflows/context-and-serialization.md | 8 ++-- .site/docs/workflows/starting-and-waiting.md | 4 +- .../docs/workflows/suspensions-and-events.md | 5 +-- .site/docs/workflows/troubleshooting.md | 4 +- packages/stem/CHANGELOG.md | 4 ++ packages/stem/README.md | 19 ++++---- .../example/annotated_workflows/README.md | 4 +- .../annotated_workflows/lib/definitions.dart | 30 ++++++------- .../stem/example/docs_snippets/lib/tasks.dart | 4 +- .../example/docs_snippets/lib/workflows.dart | 4 +- packages/stem/example/durable_watchers.dart | 4 +- packages/stem/lib/src/core/contracts.dart | 6 +-- packages/stem/lib/src/core/payload_codec.dart | 21 +++++---- packages/stem/lib/src/workflow/core/flow.dart | 4 +- .../workflow/core/workflow_definition.dart | 4 +- .../src/workflow/core/workflow_event_ref.dart | 2 +- .../lib/src/workflow/core/workflow_ref.dart | 4 +- .../src/workflow/core/workflow_script.dart | 4 +- .../test/unit/core/payload_codec_test.dart | 44 +++++++++++++++++-- .../workflow/workflow_runtime_ref_test.dart | 4 +- .../test/stem_registry_builder_test.dart | 6 +-- 24 files changed, 124 insertions(+), 78 deletions(-) diff --git a/.site/docs/core-concepts/stem-builder.md b/.site/docs/core-concepts/stem-builder.md index 3fbbc5bd..02c77aee 100644 --- a/.site/docs/core-concepts/stem-builder.md +++ b/.site/docs/core-concepts/stem-builder.md @@ -222,8 +222,8 @@ app is creating the worker itself. - `WorkflowScriptContext? context` on `run(...)` - `WorkflowScriptStepContext? context` on the checkpoint method - DTO classes are supported when they provide: - - `Map toJson()` - - `factory Type.fromJson(Map json)` or an equivalent named + - a string-keyed `toJson()` map (typically `Map`) + - `factory Type.fromJson(Map json)` or an equivalent named `fromJson` constructor - Typed task results can use the same DTO convention. - Workflow inputs, checkpoint values, and final workflow results can use the diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index dd1706ec..7281d6b5 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -55,8 +55,9 @@ lets you deserialize complex objects before they reach application code. If your manual task args are DTOs, prefer `TaskDefinition.json(...)` when the type already has `toJson()` and `Type.fromJson(...)`. Use `TaskDefinition.codec(...)` when you need a custom -`PayloadCodec`. Task args still need to encode to `Map` -because they are published as a map. +`PayloadCodec`. Task args still need to encode to a string-keyed map +(typically `Map`) because they are published as JSON-shaped +data. `TaskEnqueueBuilder` also supports `enqueueAndWait(...)`, and typed task definitions can now create a fluent builder directly through diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index f1140055..556ef0c0 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -166,8 +166,8 @@ parameters that are either: - serializable values (`String`, numbers, bools, `List`, `Map`) - codec-backed DTO classes that provide: - - `Map toJson()` - - `factory Type.fromJson(Map json)` or an equivalent named + - a string-keyed `toJson()` map (typically `Map`) + - `factory Type.fromJson(Map json)` or an equivalent named `fromJson` constructor Typed task results can use the same DTO convention. diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index 914236e8..8d7617bb 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -90,9 +90,9 @@ class OrderRequest { final String id; final String customerId; - Map toJson() => {'id': id, 'customerId': customerId}; + Map toJson() => {'id': id, 'customerId': customerId}; - factory OrderRequest.fromJson(Map json) { + factory OrderRequest.fromJson(Map json) { return OrderRequest( id: json['id'] as String, customerId: json['customerId'] as String, @@ -108,8 +108,8 @@ lowers into workflow/task definitions. The same rule applies to workflow resume events: `emitValue(...)` can take a typed DTO plus a `PayloadCodec`, but the codec must still encode to a -`Map` because watcher persistence and event delivery are -map-based today. +string-keyed map because watcher persistence and event delivery are map-based +today. For normal DTOs that expose `toJson()` and `Type.fromJson(...)`, prefer `PayloadCodec.json(...)`. Drop down to `PayloadCodec.map(...)` when you diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 5d62b182..2dca89e0 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -61,8 +61,8 @@ final runId = await approvalsRef `refJson(...)` is the shortest manual DTO path when the params or final result already have `toJson()` and `Type.fromJson(...)`. Use `refCodec(...)` when you need a custom `PayloadCodec`. Workflow params -still need to encode to `Map` because they are stored as a -map. +still need to encode to a string-keyed map (typically +`Map`) because they are stored as JSON-shaped data. If a manual flow or script returns a DTO, prefer `decodeResultJson:` on the definition constructor in the common `toJson()` / `Type.fromJson(...)` case. diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index 1706e9e2..9440af8e 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -56,9 +56,8 @@ await workflowApp.emitValue( ); ``` -Typed event payloads still serialize to the existing `Map` -wire format. `emitValue(...)` is a DTO/codec convenience layer, not a new -transport shape. +Typed event payloads still serialize to a string-keyed JSON-like map. +`emitValue(...)` is a DTO/codec convenience layer, not a new transport shape. When the topic and codec travel together in your codebase, prefer `WorkflowEventRef.json(...)` for normal DTO payloads and keep diff --git a/.site/docs/workflows/troubleshooting.md b/.site/docs/workflows/troubleshooting.md index 60769fb2..e65c52ec 100644 --- a/.site/docs/workflows/troubleshooting.md +++ b/.site/docs/workflows/troubleshooting.md @@ -25,12 +25,12 @@ Check: - the topic passed to `WorkflowRuntime.emit(...)` / `emitValue(...)` or `workflowApp.emitValue(...)` matches the one passed to `awaitEvent(...)` - the run is still waiting on that topic -- the payload encodes to a `Map` +- the payload encodes to a string-keyed map such as `Map` ## Serialization failures Do not pass arbitrary Dart objects across workflow or task boundaries. Encode -domain objects as `Map` or `List` first. +domain objects as string-keyed JSON-like maps or lists first. ## Logs only show `stem.workflow.run` diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 560ec66d..237f377f 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.1 +- Relaxed JSON/codec DTO decoding helpers to accept `Map` + `fromJson(...)` signatures across manual task/workflow/event helpers and the + shared `PayloadCodec` surface. DTO payloads still persist as string-keyed + JSON-like maps. - Removed the deprecated workflow-event compatibility helpers: `emitWith(...)`, `emitEventBuilder(...)`, `waitForEventRef(...)`, `waitForEventRefValue(...)`, `awaitEventRef(...)`, `waitValueWith(...)`, and diff --git a/packages/stem/README.md b/packages/stem/README.md index 9fd396a1..f8fa3eb2 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -186,9 +186,9 @@ class HelloArgs { const HelloArgs({required this.name}); final String name; - Map toJson() => {'name': name}; + Map toJson() => {'name': name}; - factory HelloArgs.fromJson(Map json) { + factory HelloArgs.fromJson(Map json) { return HelloArgs(name: json['name']! as String); } } @@ -222,8 +222,9 @@ to enqueue typed calls. Use `TaskDefinition.json(...)` when your manual task args are normal DTOs with `toJson()` and `Type.fromJson(...)`. Drop down to `TaskDefinition.codec(...)` only when you need a custom -`PayloadCodec`. Task args still need to encode to `Map` -because they are published as a map. +`PayloadCodec`. Task args still need to encode to a string-keyed map +(typically `Map`) because they are published as JSON-shaped +data. For typed task calls, the definition and call objects now expose the common producer operations directly. Prefer `enqueueAndWait(...)` when you only need @@ -517,8 +518,8 @@ final runId = await approvalsRef Use `refJson(...)` when your manual workflow start params or final result are normal DTOs with `toJson()` and `Type.fromJson(...)`. Drop down to `refCodec(...)` when you need a custom `PayloadCodec`. Workflow params -still need to encode to `Map` because they are persisted as a -map. +still need to encode to a string-keyed map (typically +`Map`) because they are persisted as JSON-shaped data. For workflows without start parameters, start directly from the flow or script itself: @@ -653,8 +654,8 @@ Serializable parameter rules for generated workflows and tasks are strict: - `List` where `T` is serializable - `Map` where `T` is serializable - DTO classes with: - - `Map toJson()` - - `factory Type.fromJson(Map json)` or an equivalent + - a string-keyed `toJson()` map (typically `Map`) + - `factory Type.fromJson(Map json)` or an equivalent named `fromJson` constructor - not supported directly: - optional/named business parameters on generated workflow/task entrypoints @@ -1120,7 +1121,7 @@ backend metadata under `stem.unique.duplicates`. happy path. `event.call(value).emit(...)` remains available as the lower-level prebuilt-call variant. Pair that with `await event.wait(ctx)`. Event payloads still serialize onto - the existing `Map` wire format. + a string-keyed JSON-like map. - Only return values you want persisted. If a handler returns `null`, the runtime treats it as "no result yet" and will run the step again on resume. - Derive outbound idempotency tokens with `ctx.idempotencyKey('charge')` so diff --git a/packages/stem/example/annotated_workflows/README.md b/packages/stem/example/annotated_workflows/README.md index 1ced0d00..d2a0f6a6 100644 --- a/packages/stem/example/annotated_workflows/README.md +++ b/packages/stem/example/annotated_workflows/README.md @@ -61,8 +61,8 @@ optional named injected context parameter. - `List` where `T` is serializable - `Map` where `T` is serializable - Dart classes with: - - `Map toJson()` - - `factory Type.fromJson(Map json)` or an equivalent named + - a string-keyed `toJson()` map (typically `Map`) + - `factory Type.fromJson(Map json)` or an equivalent named `fromJson` constructor Typed task results can use the same DTO convention. diff --git a/packages/stem/example/annotated_workflows/lib/definitions.dart b/packages/stem/example/annotated_workflows/lib/definitions.dart index cdf0c476..0a6a8fc6 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.dart @@ -7,9 +7,9 @@ class WelcomeRequest { final String email; - Map toJson() => {'email': email}; + Map toJson() => {'email': email}; - factory WelcomeRequest.fromJson(Map json) { + factory WelcomeRequest.fromJson(Map json) { return WelcomeRequest(email: json['email'] as String); } } @@ -27,14 +27,14 @@ class EmailDispatch { final String body; final List tags; - Map toJson() => { + Map toJson() => { 'email': email, 'subject': subject, 'body': body, 'tags': tags, }; - factory EmailDispatch.fromJson(Map json) { + factory EmailDispatch.fromJson(Map json) { return EmailDispatch( email: json['email'] as String, subject: json['subject'] as String, @@ -59,9 +59,9 @@ class EmailDeliveryReceipt { final String email; final String subject; final List tags; - final Map meta; + final Map meta; - Map toJson() => { + Map toJson() => { 'taskId': taskId, 'attempt': attempt, 'email': email, @@ -70,14 +70,14 @@ class EmailDeliveryReceipt { 'meta': meta, }; - factory EmailDeliveryReceipt.fromJson(Map json) { + factory EmailDeliveryReceipt.fromJson(Map json) { return EmailDeliveryReceipt( taskId: json['taskId'] as String, attempt: json['attempt'] as int, email: json['email'] as String, subject: json['subject'] as String, tags: (json['tags'] as List).cast(), - meta: Map.from(json['meta'] as Map), + meta: Map.from(json['meta'] as Map), ); } } @@ -91,12 +91,12 @@ class WelcomePreparation { final String normalizedEmail; final String subject; - Map toJson() => { + Map toJson() => { 'normalizedEmail': normalizedEmail, 'subject': subject, }; - factory WelcomePreparation.fromJson(Map json) { + factory WelcomePreparation.fromJson(Map json) { return WelcomePreparation( normalizedEmail: json['normalizedEmail'] as String, subject: json['subject'] as String, @@ -115,13 +115,13 @@ class WelcomeWorkflowResult { final String subject; final String followUp; - Map toJson() => { + Map toJson() => { 'normalizedEmail': normalizedEmail, 'subject': subject, 'followUp': followUp, }; - factory WelcomeWorkflowResult.fromJson(Map json) { + factory WelcomeWorkflowResult.fromJson(Map json) { return WelcomeWorkflowResult( normalizedEmail: json['normalizedEmail'] as String, subject: json['subject'] as String, @@ -155,7 +155,7 @@ class ContextCaptureResult { final String childRunId; final WelcomeWorkflowResult childResult; - Map toJson() => { + Map toJson() => { 'workflow': workflow, 'runId': runId, 'stepName': stepName, @@ -168,7 +168,7 @@ class ContextCaptureResult { 'childResult': childResult.toJson(), }; - factory ContextCaptureResult.fromJson(Map json) { + factory ContextCaptureResult.fromJson(Map json) { return ContextCaptureResult( workflow: json['workflow'] as String, runId: json['runId'] as String, @@ -180,7 +180,7 @@ class ContextCaptureResult { subject: json['subject'] as String, childRunId: json['childRunId'] as String, childResult: WelcomeWorkflowResult.fromJson( - Map.from(json['childResult'] as Map), + Map.from(json['childResult'] as Map), ), ); } diff --git a/packages/stem/example/docs_snippets/lib/tasks.dart b/packages/stem/example/docs_snippets/lib/tasks.dart index 2df5f56d..17d74249 100644 --- a/packages/stem/example/docs_snippets/lib/tasks.dart +++ b/packages/stem/example/docs_snippets/lib/tasks.dart @@ -51,9 +51,9 @@ class InvoicePayload { const InvoicePayload({required this.invoiceId}); final String invoiceId; - Map toJson() => {'invoiceId': invoiceId}; + Map toJson() => {'invoiceId': invoiceId}; - factory InvoicePayload.fromJson(Map json) { + factory InvoicePayload.fromJson(Map json) { return InvoicePayload(invoiceId: json['invoiceId']! as String); } } diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index 3e618bc1..8330772b 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -10,9 +10,9 @@ class ApprovalDraft { final String documentId; - Map toJson() => {'documentId': documentId}; + Map toJson() => {'documentId': documentId}; - factory ApprovalDraft.fromJson(Map json) { + factory ApprovalDraft.fromJson(Map json) { return ApprovalDraft(documentId: json['documentId'] as String); } } diff --git a/packages/stem/example/durable_watchers.dart b/packages/stem/example/durable_watchers.dart index b9dfc76b..14ac656b 100644 --- a/packages/stem/example/durable_watchers.dart +++ b/packages/stem/example/durable_watchers.dart @@ -69,9 +69,9 @@ class _ShipmentReadyEvent { final String trackingId; - Map toJson() => {'trackingId': trackingId}; + Map toJson() => {'trackingId': trackingId}; - static _ShipmentReadyEvent fromJson(Map json) { + static _ShipmentReadyEvent fromJson(Map json) { return _ShipmentReadyEvent(trackingId: json['trackingId'] as String); } } diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index b88e8638..7a0f634e 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2110,11 +2110,11 @@ class TaskDefinition { /// `toJson()` and `Type.fromJson(...)`. factory TaskDefinition.json({ required String name, - required TArgs Function(Map payload) decodeArgs, + required TArgs Function(Map payload) decodeArgs, TaskMetaBuilder? encodeMeta, TaskOptions defaultOptions = const TaskOptions(), TaskMetadata metadata = const TaskMetadata(), - TResult Function(Map payload)? decodeResultJson, + TResult Function(Map payload)? decodeResultJson, String? argsTypeName, String? resultTypeName, }) { @@ -2144,7 +2144,7 @@ class TaskDefinition { TaskMetadata metadata = const TaskMetadata(), TaskResultDecoder? decodeResult, PayloadCodec? resultCodec, - TResult Function(Map payload)? decodeResultJson, + TResult Function(Map payload)? decodeResultJson, String? resultTypeName, }) { assert( diff --git a/packages/stem/lib/src/core/payload_codec.dart b/packages/stem/lib/src/core/payload_codec.dart index cdd620d8..766e8485 100644 --- a/packages/stem/lib/src/core/payload_codec.dart +++ b/packages/stem/lib/src/core/payload_codec.dart @@ -27,7 +27,7 @@ class PayloadCodec { /// ``` const PayloadCodec.map({ required Object? Function(T value) encode, - required T Function(Map payload) decode, + required T Function(Map payload) decode, String? typeName, }) : _encode = encode, _decode = null, @@ -45,7 +45,7 @@ class PayloadCodec { /// ); /// ``` const PayloadCodec.json({ - required T Function(Map payload) decode, + required T Function(Map payload) decode, String? typeName, }) : _encode = _encodeJsonPayload, _decode = null, @@ -54,7 +54,7 @@ class PayloadCodec { final Object? Function(T value) _encode; final T Function(Object? payload)? _decode; - final T Function(Map payload)? _decodeMap; + final T Function(Map payload)? _decodeMap; final String? _typeName; /// Converts a typed value into a durable payload representation. @@ -67,7 +67,7 @@ class PayloadCodec { return decode(payload); } final decodeMap = _decodeMap!; - return decodeMap(_payloadMap(payload, _typeName ?? '$T')); + return decodeMap(_payloadJsonMap(payload, _typeName ?? '$T')); } /// Converts an erased author-facing value into a durable payload. @@ -86,7 +86,7 @@ class PayloadCodec { Object? _encodeJsonPayload(T value) { try { final payload = (value as dynamic).toJson(); - return _payloadMap(payload, value.runtimeType.toString()); + return _payloadJsonMap(payload, value.runtimeType.toString()); // Dynamic `toJson()` probing is the purpose of this helper. // ignore: avoid_catching_errors } on NoSuchMethodError { @@ -96,12 +96,15 @@ Object? _encodeJsonPayload(T value) { } } -Map _payloadMap(Object? value, String typeName) { +Map _payloadJsonMap(Object? value, String typeName) { + if (value is Map) { + return Map.from(value); + } if (value is Map) { - return Map.from(value); + return Map.from(value); } if (value is Map) { - final result = {}; + final result = {}; for (final entry in value.entries) { final key = entry.key; if (key is! String) { @@ -112,7 +115,7 @@ Map _payloadMap(Object? value, String typeName) { return result; } throw StateError( - '$typeName payload must decode to Map, got ' + '$typeName payload must decode to a string-keyed map, got ' '${value.runtimeType}.', ); } diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index 093e79fa..ec275ade 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -54,8 +54,8 @@ class Flow { /// Builds a typed [WorkflowRef] for DTO params that already expose /// `toJson()` and `Type.fromJson(...)`. WorkflowRef refJson({ - required TParams Function(Map payload) decodeParams, - T Function(Map payload)? decodeResultJson, + required TParams Function(Map payload) decodeParams, + T Function(Map payload)? decodeResultJson, String? paramsTypeName, String? resultTypeName, }) { diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index ba4f1983..c4aea0a3 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -347,8 +347,8 @@ class WorkflowDefinition { /// Builds a typed [WorkflowRef] for DTO params that already expose /// `toJson()` and `Type.fromJson(...)`. WorkflowRef refJson({ - required TParams Function(Map payload) decodeParams, - T Function(Map payload)? decodeResultJson, + required TParams Function(Map payload) decodeParams, + T Function(Map payload)? decodeResultJson, String? paramsTypeName, String? resultTypeName, }) { diff --git a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart index 65917a49..9aa5d2a1 100644 --- a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart @@ -42,7 +42,7 @@ class WorkflowEventRef { /// expose `toJson()` and `Type.fromJson(...)`. factory WorkflowEventRef.json({ required String topic, - required T Function(Map payload) decode, + required T Function(Map payload) decode, String? typeName, }) { return WorkflowEventRef.codec( diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 371fe02c..938da6c9 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -33,8 +33,8 @@ class WorkflowRef { /// `toJson()` and `Type.fromJson(...)`. factory WorkflowRef.json({ required String name, - required TParams Function(Map payload) decodeParams, - TResult Function(Map payload)? decodeResultJson, + required TParams Function(Map payload) decodeParams, + TResult Function(Map payload)? decodeResultJson, TResult Function(Object? payload)? decodeResult, String? paramsTypeName, String? resultTypeName, diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index 1e1a7602..b895a055 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -56,8 +56,8 @@ class WorkflowScript { /// Builds a typed [WorkflowRef] for DTO params that already expose /// `toJson()` and `Type.fromJson(...)`. WorkflowRef refJson({ - required TParams Function(Map payload) decodeParams, - T Function(Map payload)? decodeResultJson, + required TParams Function(Map payload) decodeParams, + T Function(Map payload)? decodeResultJson, String? paramsTypeName, String? resultTypeName, }) { diff --git a/packages/stem/test/unit/core/payload_codec_test.dart b/packages/stem/test/unit/core/payload_codec_test.dart index e1207bc2..61b0f71a 100644 --- a/packages/stem/test/unit/core/payload_codec_test.dart +++ b/packages/stem/test/unit/core/payload_codec_test.dart @@ -22,6 +22,25 @@ void main() { expect(decoded.count, 1); }); + test('accepts DTO decoders that use Map', () { + const codec = PayloadCodec<_DynamicCodecPayload>.json( + decode: _DynamicCodecPayload.fromJson, + typeName: '_DynamicCodecPayload', + ); + + final payload = codec.encode( + const _DynamicCodecPayload(id: 'payload-dyn', count: 9), + ); + final decoded = codec.decode(payload); + + expect(payload, { + 'id': 'payload-dyn', + 'count': 9, + }); + expect(decoded.id, 'payload-dyn'); + expect(decoded.count, 9); + }); + test('rejects values without toJson with a clear error', () { const codec = PayloadCodec<_NoJsonPayload>.json( decode: _NoJsonPayload.fromJson, @@ -87,7 +106,7 @@ void main() { isA().having( (error) => error.message, 'message', - contains('_CodecPayload payload must decode to Map'), + contains('_CodecPayload payload must decode to a string-keyed map'), ), ), ); @@ -117,7 +136,7 @@ void main() { class _CodecPayload { const _CodecPayload({required this.id, required this.count}); - factory _CodecPayload.fromJson(Map json) { + factory _CodecPayload.fromJson(Map json) { return _CodecPayload( id: json['id']! as String, count: json['count']! as int, @@ -133,12 +152,31 @@ class _CodecPayload { }; } +class _DynamicCodecPayload { + const _DynamicCodecPayload({required this.id, required this.count}); + + factory _DynamicCodecPayload.fromJson(Map json) { + return _DynamicCodecPayload( + id: json['id']! as String, + count: json['count']! as int, + ); + } + + final String id; + final int count; + + Map toJson() => { + 'id': id, + 'count': count, + }; +} + Object? _encodeCodecPayload(_CodecPayload value) => value.toJson(); class _NoJsonPayload { const _NoJsonPayload({required this.id}); - factory _NoJsonPayload.fromJson(Map json) { + factory _NoJsonPayload.fromJson(Map json) { return _NoJsonPayload(id: json['id']! as String); } diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 2c6b27e9..8522f6df 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -4,7 +4,7 @@ import 'package:test/test.dart'; class _GreetingParams { const _GreetingParams({required this.name}); - factory _GreetingParams.fromJson(Map json) { + factory _GreetingParams.fromJson(Map json) { return _GreetingParams(name: json['name']! as String); } @@ -16,7 +16,7 @@ class _GreetingParams { class _GreetingResult { const _GreetingResult({required this.message}); - factory _GreetingResult.fromJson(Map json) { + factory _GreetingResult.fromJson(Map json) { return _GreetingResult(message: json['message']! as String); } diff --git a/packages/stem_builder/test/stem_registry_builder_test.dart b/packages/stem_builder/test/stem_registry_builder_test.dart index 9adcc983..bc687e92 100644 --- a/packages/stem_builder/test/stem_registry_builder_test.dart +++ b/packages/stem_builder/test/stem_registry_builder_test.dart @@ -16,11 +16,11 @@ class PayloadCodec { : typeName = null; const PayloadCodec.map({ required this.encode, - required T Function(Map payload) decode, + required T Function(Map payload) decode, this.typeName, }) : decode = _unsupportedDecode; const PayloadCodec.json({ - required T Function(Map payload) decode, + required T Function(Map payload) decode, this.typeName, }) : encode = _unsupportedEncode, decode = _unsupportedDecode; @@ -1181,7 +1181,7 @@ class EmailRequest { 'retries': retries, }; - factory EmailRequest.fromJson(Map json) => EmailRequest( + factory EmailRequest.fromJson(Map json) => EmailRequest( email: json['email'] as String, retries: json['retries'] as int, ); From c9474253795fd2bb08f96a65d3473b1366f3ad2a Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 06:56:35 -0500 Subject: [PATCH 171/302] Rename workflow start builders to prepareStart --- .site/docs/workflows/flows-and-scripts.md | 4 ++-- .site/docs/workflows/starting-and-waiting.md | 4 ++-- packages/stem/CHANGELOG.md | 4 ++++ packages/stem/README.md | 2 +- .../stem/example/workflows/cancellation_policy.dart | 2 +- packages/stem/lib/src/workflow/core/flow.dart | 4 ++-- packages/stem/lib/src/workflow/core/workflow_ref.dart | 10 +++++----- .../stem/lib/src/workflow/core/workflow_script.dart | 4 ++-- .../stem/test/workflow/workflow_runtime_ref_test.dart | 4 ++-- 9 files changed, 21 insertions(+), 17 deletions(-) diff --git a/.site/docs/workflows/flows-and-scripts.md b/.site/docs/workflows/flows-and-scripts.md index 032b9807..7de22fe0 100644 --- a/.site/docs/workflows/flows-and-scripts.md +++ b/.site/docs/workflows/flows-and-scripts.md @@ -36,7 +36,7 @@ final approvalsRef = approvalsFlow.ref>( ``` When a flow has no start params, start directly from the flow itself with -`flow.start(...)`, `flow.startAndWait(...)`, or `flow.startBuilder()`. +`flow.start(...)`, `flow.startAndWait(...)`, or `flow.prepareStart()`. Use `ref0()` only when another API specifically needs a `NoArgsWorkflowRef`. Use `Flow` when: @@ -61,7 +61,7 @@ final retryRef = retryScript.ref>( When a script has no start params, start directly from the script itself with `retryScript.start(...)`, `retryScript.startAndWait(...)`, or -`retryScript.startBuilder()`. Use `ref0()` only when another API specifically +`retryScript.prepareStart()`. Use `ref0()` only when another API specifically needs a `NoArgsWorkflowRef`. Use `WorkflowScript` when: diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 2dca89e0..cecbcfba 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -49,7 +49,7 @@ builder: ```dart final runId = await approvalsRef - .startBuilder(const ApprovalDraft(documentId: 'doc-42')) + .prepareStart(const ApprovalDraft(documentId: 'doc-42')) .parentRunId('parent-run') .ttl(const Duration(hours: 1)) .cancellationPolicy( @@ -69,7 +69,7 @@ definition constructor in the common `toJson()` / `Type.fromJson(...)` case. Use `resultCodec:` only when the result needs a custom payload codec. For workflows without start params, start directly from the flow or script -itself with `start(...)`, `startAndWait(...)`, or `startBuilder()`. +itself with `start(...)`, `startAndWait(...)`, or `prepareStart()`. Use `ref0()` when another API specifically needs a `NoArgsWorkflowRef`. ## Wait for completion diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 237f377f..62e318d9 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.1 +- Renamed the advanced workflow-ref builder entrypoints from `startBuilder(...)` + / `startBuilder()` to `prepareStart(...)` / `prepareStart()` on + `WorkflowRef`, `NoArgsWorkflowRef`, `Flow`, and `WorkflowScript` so the + workflow side aligns with task-side `prepareEnqueue(...)`. - Relaxed JSON/codec DTO decoding helpers to accept `Map` `fromJson(...)` signatures across manual task/workflow/event helpers and the shared `PayloadCodec` surface. DTO payloads still persist as string-keyed diff --git a/packages/stem/README.md b/packages/stem/README.md index f8fa3eb2..4823d4c5 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -506,7 +506,7 @@ names, use the fluent workflow start builder: ```dart final runId = await approvalsRef - .startBuilder(const ApprovalDraft(documentId: 'doc-42')) + .prepareStart(const ApprovalDraft(documentId: 'doc-42')) .parentRunId('parent-run') .ttl(const Duration(hours: 1)) .cancellationPolicy( diff --git a/packages/stem/example/workflows/cancellation_policy.dart b/packages/stem/example/workflows/cancellation_policy.dart index f3deddcb..f2d87a21 100644 --- a/packages/stem/example/workflows/cancellation_policy.dart +++ b/packages/stem/example/workflows/cancellation_policy.dart @@ -30,7 +30,7 @@ Future main() async { ); final runId = await reportsGenerate - .startBuilder() + .prepareStart() .cancellationPolicy( const WorkflowCancellationPolicy( maxRunDuration: Duration(minutes: 10), diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index ec275ade..04d40e94 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -73,8 +73,8 @@ class Flow { } /// Creates a fluent start builder for flows without start params. - WorkflowStartBuilder<(), T> startBuilder() { - return ref0().startBuilder(); + WorkflowStartBuilder<(), T> prepareStart() { + return ref0().prepareStart(); } /// Starts this flow directly when it does not accept start params. diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 938da6c9..d0dc3262 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -111,7 +111,7 @@ class WorkflowRef { } /// Creates a fluent builder for this workflow start. - WorkflowStartBuilder startBuilder(TParams params) { + WorkflowStartBuilder prepareStart(TParams params) { return WorkflowStartBuilder(definition: this, params: params); } @@ -204,8 +204,8 @@ class NoArgsWorkflowRef { } /// Creates a fluent builder for this workflow start. - WorkflowStartBuilder<(), TResult> startBuilder() { - return asRef.startBuilder(()); + WorkflowStartBuilder<(), TResult> prepareStart() { + return asRef.prepareStart(()); } /// Starts this workflow ref directly with [caller]. @@ -498,7 +498,7 @@ extension WorkflowCallerBuilderExtension on WorkflowCaller { }) { return BoundWorkflowStartBuilder._( caller: this, - builder: definition.startBuilder(params), + builder: definition.prepareStart(params), ); } @@ -509,7 +509,7 @@ extension WorkflowCallerBuilderExtension on WorkflowCaller { }) { return BoundWorkflowStartBuilder._( caller: this, - builder: definition.startBuilder(), + builder: definition.prepareStart(), ); } diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index b895a055..3d473458 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -75,8 +75,8 @@ class WorkflowScript { } /// Creates a fluent start builder for scripts without start params. - WorkflowStartBuilder<(), T> startBuilder() { - return ref0().startBuilder(); + WorkflowStartBuilder<(), T> prepareStart() { + return ref0().prepareStart(); } /// Starts this script directly when it does not accept start params. diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 8522f6df..e90b0af3 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -350,7 +350,7 @@ void main() { await workflowApp.start(); final flowBuilder = workflowRef - .startBuilder(const {'name': 'builder'}) + .prepareStart(const {'name': 'builder'}) .ttl(const Duration(minutes: 5)) .parentRunId('parent-builder'); final builtFlowCall = flowBuilder.build(); @@ -367,7 +367,7 @@ void main() { expect(result?.value, 'hello builder'); expect(state?.parentRunId, 'parent-builder'); - final scriptBuilder = script.startBuilder().cancellationPolicy( + final scriptBuilder = script.prepareStart().cancellationPolicy( const WorkflowCancellationPolicy( maxRunDuration: Duration(seconds: 5), ), From a08639b15128cfb8050824212e369b41f09b2504 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 06:58:53 -0500 Subject: [PATCH 172/302] Rename caller workflow builders to prepareStart --- .site/docs/workflows/annotated-workflows.md | 2 +- .site/docs/workflows/context-and-serialization.md | 2 +- .site/docs/workflows/starting-and-waiting.md | 2 +- packages/stem/CHANGELOG.md | 4 ++++ packages/stem/README.md | 2 +- packages/stem/lib/src/workflow/core/workflow_ref.dart | 4 ++-- packages/stem/test/unit/core/task_context_enqueue_test.dart | 2 +- packages/stem/test/workflow/workflow_runtime_ref_test.dart | 4 ++-- packages/stem_builder/README.md | 2 +- 9 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index 556ef0c0..1763760b 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -136,7 +136,7 @@ When a workflow needs to start another workflow, do it from a durable boundary: flow steps and checkpoint methods - pass `ttl:`, `parentRunId:`, or `cancellationPolicy:` directly to `ref.start(...)` / `ref.startAndWait(...)` for the normal override cases -- keep `context.prepareWorkflowStart(...)` for the rarer incremental-call +- keep `context.prepareStart(...)` for the rarer incremental-call cases where you actually want to build the start request step by step Avoid starting child workflows from the raw `WorkflowScriptContext` body. diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index 8d7617bb..5e68e2c3 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -54,7 +54,7 @@ Child workflow starts belong in durable boundaries: - `ref.startAndWait(context, params: value)` inside script checkpoints - pass `ttl:`, `parentRunId:`, or `cancellationPolicy:` directly to those helpers for the normal override cases -- keep `context.prepareWorkflowStart(...)` for incremental-call assembly when +- keep `context.prepareStart(...)` for incremental-call assembly when you genuinely need to build the start request step by step Do not treat the raw `WorkflowScriptContext` body as a safe place for child diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index cecbcfba..7c57f6d2 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -125,7 +125,7 @@ final result = await StemWorkflowDefinitions.userSignup.startAndWait( If you still need the run identifier for inspection or operator tooling, read it from `result.runId`. -Keep `context.prepareWorkflowStart(...)` for the rarer cases where you want to +Keep `context.prepareStart(...)` for the rarer cases where you want to assemble a start request incrementally before dispatch. ## Parent runs and TTL diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 62e318d9..6b6e535d 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.1 +- Renamed the caller-bound advanced child-workflow helpers from + `prepareWorkflowStart(...)` / `prepareNoArgsWorkflowStart(...)` to + `prepareStart(...)` / `prepareNoArgsStart(...)` so the caller side aligns + with task-side `prepareEnqueue(...)` and ref-side `prepareStart(...)`. - Renamed the advanced workflow-ref builder entrypoints from `startBuilder(...)` / `startBuilder()` to `prepareStart(...)` / `prepareStart()` on `WorkflowRef`, `NoArgsWorkflowRef`, `Flow`, and `WorkflowScript` so the diff --git a/packages/stem/README.md b/packages/stem/README.md index 4823d4c5..edbc74a9 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -639,7 +639,7 @@ Child workflows belong in durable execution boundaries: flow steps and script checkpoints - pass `ttl:`, `parentRunId:`, or `cancellationPolicy:` directly to `ref.start(...)` / `ref.startAndWait(...)` for normal override cases -- keep `context.prepareWorkflowStart(...)` for the rarer incremental-call +- keep `context.prepareStart(...)` for the rarer incremental-call cases where you actually want to build the start request step by step - do not start child workflows from the raw `WorkflowScriptContext` body unless you are deliberately managing replay/idempotency yourself diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index d0dc3262..c5d076ef 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -492,7 +492,7 @@ class BoundWorkflowStartBuilder { extension WorkflowCallerBuilderExtension on WorkflowCaller { /// Creates a caller-bound fluent start builder for a typed workflow ref. BoundWorkflowStartBuilder - prepareWorkflowStart({ + prepareStart({ required WorkflowRef definition, required TParams params, }) { @@ -504,7 +504,7 @@ extension WorkflowCallerBuilderExtension on WorkflowCaller { /// Creates a caller-bound fluent start builder for a no-args workflow ref. BoundWorkflowStartBuilder<(), TResult> - prepareNoArgsWorkflowStart({ + prepareNoArgsStart({ required NoArgsWorkflowRef definition, }) { return BoundWorkflowStartBuilder._( diff --git a/packages/stem/test/unit/core/task_context_enqueue_test.dart b/packages/stem/test/unit/core/task_context_enqueue_test.dart index f6d69d02..46537be1 100644 --- a/packages/stem/test/unit/core/task_context_enqueue_test.dart +++ b/packages/stem/test/unit/core/task_context_enqueue_test.dart @@ -292,7 +292,7 @@ void main() { ); final result = await context - .prepareWorkflowStart( + .prepareStart( definition: definition, params: const {'value': 'child'}, ) diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index e90b0af3..095e614c 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -416,7 +416,7 @@ void main() { await workflowApp.start(); final flowBuilder = workflowApp.runtime - .prepareWorkflowStart( + .prepareStart( definition: workflowRef, params: const {'name': 'builder'}, ) @@ -437,7 +437,7 @@ void main() { expect(state?.parentRunId, 'parent-bound'); final scriptBuilder = workflowApp.runtime - .prepareNoArgsWorkflowStart(definition: scriptRef) + .prepareNoArgsStart(definition: scriptRef) .cancellationPolicy( const WorkflowCancellationPolicy( maxRunDuration: Duration(seconds: 5), diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index fb6f29f8..7050b1b9 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -115,7 +115,7 @@ Child workflows should be started from durable boundaries: - `ref.startAndWait(context, params: value)` inside script checkpoints - pass `ttl:`, `parentRunId:`, or `cancellationPolicy:` directly to `ref.start(...)` / `ref.startAndWait(...)` for the normal override cases -- keep `context.prepareWorkflowStart(...)` for the rarer incremental-call +- keep `context.prepareStart(...)` for the rarer incremental-call cases where you actually want to build the start request step by step Avoid starting child workflows directly from the raw From 5e0191b46556e3b30fd628979d0f98c90cb5df21 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 07:01:56 -0500 Subject: [PATCH 173/302] Relax workflow result json decoders --- packages/stem/CHANGELOG.md | 4 ++++ packages/stem/lib/src/workflow/core/flow.dart | 2 +- packages/stem/lib/src/workflow/core/workflow_definition.dart | 4 ++-- packages/stem/lib/src/workflow/core/workflow_script.dart | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 6b6e535d..93c98f02 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.1 +- Relaxed manual `Flow(...)`, `WorkflowScript(...)`, and + `WorkflowDefinition.flow(...)` / `.script(...)` `decodeResultJson:` helpers + to accept `Map` DTO decoders, matching the newer + `PayloadCodec.json(...)` and `refJson(...)` surfaces. - Renamed the caller-bound advanced child-workflow helpers from `prepareWorkflowStart(...)` / `prepareNoArgsWorkflowStart(...)` to `prepareStart(...)` / `prepareNoArgsStart(...)` so the caller side aligns diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index 04d40e94..fb0aea9e 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -20,7 +20,7 @@ class Flow { String? description, Map? metadata, PayloadCodec? resultCodec, - T Function(Map payload)? decodeResultJson, + T Function(Map payload)? decodeResultJson, String? resultTypeName, }) : definition = WorkflowDefinition.flow( name: name, diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index c4aea0a3..a6d22d28 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -159,7 +159,7 @@ class WorkflowDefinition { String? description, Map? metadata, PayloadCodec? resultCodec, - T Function(Map payload)? decodeResultJson, + T Function(Map payload)? decodeResultJson, String? resultTypeName, }) { assert( @@ -212,7 +212,7 @@ class WorkflowDefinition { String? description, Map? metadata, PayloadCodec? resultCodec, - T Function(Map payload)? decodeResultJson, + T Function(Map payload)? decodeResultJson, String? resultTypeName, }) { assert( diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index 3d473458..f2ecd4cf 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -21,7 +21,7 @@ class WorkflowScript { String? description, Map? metadata, PayloadCodec? resultCodec, - T Function(Map payload)? decodeResultJson, + T Function(Map payload)? decodeResultJson, String? resultTypeName, }) : definition = WorkflowDefinition.script( name: name, From 59388e0b5d6fe158c7fc561aa629f0d05d3f8a8f Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 07:05:13 -0500 Subject: [PATCH 174/302] Add direct json workflow and task helpers --- .site/docs/core-concepts/tasks.md | 7 +++--- .site/docs/workflows/starting-and-waiting.md | 6 ++--- packages/stem/CHANGELOG.md | 3 +++ packages/stem/README.md | 12 ++++++---- packages/stem/lib/src/core/contracts.dart | 17 +++++++++++++ packages/stem/lib/src/workflow/core/flow.dart | 21 ++++++++++++++++ .../src/workflow/core/workflow_script.dart | 24 +++++++++++++++++++ .../stem/test/unit/core/stem_core_test.dart | 8 +++---- .../workflow/workflow_runtime_ref_test.dart | 8 +++---- 9 files changed, 88 insertions(+), 18 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 7281d6b5..174fca77 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -70,9 +70,10 @@ instead. That gives you direct `enqueue(...)` / `enqueueAndWait(...)` helpers without passing a fake empty map and the same `waitFor(...)` decoding surface as normal typed definitions. -If a no-arg task returns a DTO, prefer `decodeResultJson:` when the result -already has `toJson()` and `Type.fromJson(...)`. Use `resultCodec:` only when -you need a custom payload codec. +If a no-arg task returns a DTO, prefer `TaskDefinition.noArgsJson(...)` when +the result already has `toJson()` and `Type.fromJson(...)`. Use +`TaskDefinition.noArgs(...)` with `resultCodec:` only when you need a custom +payload codec. ## Configuring Retries diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 7c57f6d2..4ebc7adf 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -64,9 +64,9 @@ final result already have `toJson()` and `Type.fromJson(...)`. Use still need to encode to a string-keyed map (typically `Map`) because they are stored as JSON-shaped data. -If a manual flow or script returns a DTO, prefer `decodeResultJson:` on the -definition constructor in the common `toJson()` / `Type.fromJson(...)` case. -Use `resultCodec:` only when the result needs a custom payload codec. +If a manual flow or script returns a DTO, prefer `Flow.json(...)` or +`WorkflowScript.json(...)` in the common `toJson()` / `Type.fromJson(...)` +case. Use `resultCodec:` only when the result needs a custom payload codec. For workflows without start params, start directly from the flow or script itself with `start(...)`, `startAndWait(...)`, or `prepareStart()`. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 93c98f02..88fa56c8 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Added `TaskDefinition.noArgsJson(...)`, `Flow.json(...)`, and + `WorkflowScript.json(...)` as the shortest manual DTO result helpers for the + common `toJson()` / `Type.fromJson(...)` path. - Relaxed manual `Flow(...)`, `WorkflowScript(...)`, and `WorkflowDefinition.flow(...)` / `.script(...)` `decodeResultJson:` helpers to accept `Map` DTO decoders, matching the newer diff --git a/packages/stem/README.md b/packages/stem/README.md index edbc74a9..571801c7 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -248,10 +248,10 @@ final healthcheckDefinition = TaskDefinition.noArgs( await healthcheckDefinition.enqueue(stem); ``` -If a no-arg task returns a DTO, prefer `decodeResultJson:` in the common -`toJson()` / `Type.fromJson(...)` case. Use `resultCodec:` when you need a -custom payload codec. Both paths keep waiting helpers typed and advertise the -right result encoder in task metadata. +If a no-arg task returns a DTO, prefer `TaskDefinition.noArgsJson(...)` in the +common `toJson()` / `Type.fromJson(...)` case. Use `TaskDefinition.noArgs(...)` +with `resultCodec:` when you need a custom payload codec. Both paths keep +waiting helpers typed and advertise the right result encoder in task metadata. You can also build requests fluently from the task definition itself: @@ -521,6 +521,10 @@ normal DTOs with `toJson()` and `Type.fromJson(...)`. Drop down to still need to encode to a string-keyed map (typically `Map`) because they are persisted as JSON-shaped data. +If a manual flow or script only needs DTO result decoding, prefer +`Flow.json(...)` or `WorkflowScript.json(...)` instead of passing +`decodeResultJson:` to the base constructor. + For workflows without start parameters, start directly from the flow or script itself: diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 7a0f634e..2d211834 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2137,6 +2137,23 @@ class TaskDefinition { ); } + /// Creates a typed task definition for handlers with no producer args. + static NoArgsTaskDefinition noArgsJson({ + required String name, + required TResult Function(Map payload) decodeResult, + TaskOptions defaultOptions = const TaskOptions(), + TaskMetadata metadata = const TaskMetadata(), + String? resultTypeName, + }) { + return noArgs( + name: name, + defaultOptions: defaultOptions, + metadata: metadata, + decodeResultJson: decodeResult, + resultTypeName: resultTypeName, + ); + } + /// Creates a typed task definition for handlers with no producer args. static NoArgsTaskDefinition noArgs({ required String name, diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index fb0aea9e..9d00ffc2 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -33,6 +33,27 @@ class Flow { resultTypeName: resultTypeName, ); + /// Creates a flow definition whose final result is a DTO-backed JSON value. + factory Flow.json({ + required String name, + required void Function(FlowBuilder builder) build, + required T Function(Map payload) decodeResult, + String? version, + String? description, + Map? metadata, + String? resultTypeName, + }) { + return Flow( + name: name, + build: build, + version: version, + description: description, + metadata: metadata, + decodeResultJson: decodeResult, + resultTypeName: resultTypeName, + ); + } + /// The constructed workflow definition. final WorkflowDefinition definition; diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index f2ecd4cf..05b1b46f 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -35,6 +35,30 @@ class WorkflowScript { resultTypeName: resultTypeName, ); + /// Creates a script definition whose final result is a DTO-backed JSON + /// value. + factory WorkflowScript.json({ + required String name, + required WorkflowScriptBody run, + required T Function(Map payload) decodeResult, + Iterable checkpoints = const [], + String? version, + String? description, + Map? metadata, + String? resultTypeName, + }) { + return WorkflowScript( + name: name, + run: run, + checkpoints: checkpoints, + version: version, + description: description, + metadata: metadata, + decodeResultJson: decodeResult, + resultTypeName: resultTypeName, + ); + } + /// The constructed workflow definition. final WorkflowDefinition definition; diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index 03828989..ad4a8f61 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -349,9 +349,9 @@ void main() { final broker = _RecordingBroker(); final backend = _RecordingBackend(); final stem = Stem(broker: broker, backend: backend); - final definition = TaskDefinition.noArgs<_CodecReceipt>( + final definition = TaskDefinition.noArgsJson<_CodecReceipt>( name: 'sample.no_args.json', - decodeResultJson: _CodecReceipt.fromJson, + decodeResult: _CodecReceipt.fromJson, ); final id = await definition.enqueue(stem); @@ -496,9 +496,9 @@ void main() { test('supports no-arg task definitions', () async { final backend = InMemoryResultBackend(); final stem = Stem(broker: _RecordingBroker(), backend: backend); - final definition = TaskDefinition.noArgs<_CodecReceipt>( + final definition = TaskDefinition.noArgsJson<_CodecReceipt>( name: 'no-args.wait', - decodeResultJson: _CodecReceipt.fromJson, + decodeResult: _CodecReceipt.fromJson, ); await backend.set( diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 095e614c..98acba5b 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -247,9 +247,9 @@ void main() { }); test('manual workflows can derive json-backed result decoding', () async { - final flow = Flow<_GreetingResult>( + final flow = Flow<_GreetingResult>.json( name: 'runtime.ref.json.result.flow', - decodeResultJson: _GreetingResult.fromJson, + decodeResult: _GreetingResult.fromJson, build: (builder) { builder.step( 'hello', @@ -257,9 +257,9 @@ void main() { ); }, ); - final script = WorkflowScript<_GreetingResult>( + final script = WorkflowScript<_GreetingResult>.json( name: 'runtime.ref.json.result.script', - decodeResultJson: _GreetingResult.fromJson, + decodeResult: _GreetingResult.fromJson, run: (context) async => const _GreetingResult(message: 'hello script json'), ); From 62d62156ad8edccad900dbab16d15d080cfb91e6 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 07:07:37 -0500 Subject: [PATCH 175/302] Add direct json workflow definition helpers --- .site/docs/workflows/getting-started.md | 4 ++ packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 4 ++ .../workflow/core/workflow_definition.dart | 46 +++++++++++++++++++ .../workflow/workflow_runtime_ref_test.dart | 43 +++++++++++++++++ 5 files changed, 100 insertions(+) diff --git a/.site/docs/workflows/getting-started.md b/.site/docs/workflows/getting-started.md index ed495369..d4331617 100644 --- a/.site/docs/workflows/getting-started.md +++ b/.site/docs/workflows/getting-started.md @@ -70,6 +70,10 @@ runtime registry: - `registerScript(...)` / `registerScripts(...)` - `registerModule(...)` / `registerModules(...)` +If you are registering raw `WorkflowDefinition` values directly, prefer +`WorkflowDefinition.flowJson(...)` and `WorkflowDefinition.scriptJson(...)` +for the common DTO-result path. + ## 5. Move to the right next page - If you need a mental model first, read [Flows and Scripts](./flows-and-scripts.md). diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 88fa56c8..88d7b587 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Added `WorkflowDefinition.flowJson(...)` and + `WorkflowDefinition.scriptJson(...)` so the raw definition path has the same + direct DTO-result helper as `Flow.json(...)` and `WorkflowScript.json(...)`. - Added `TaskDefinition.noArgsJson(...)`, `Flow.json(...)`, and `WorkflowScript.json(...)` as the shortest manual DTO result helpers for the common `toJson()` / `Type.fromJson(...)` path. diff --git a/packages/stem/README.md b/packages/stem/README.md index 571801c7..7bd3dd79 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -371,6 +371,10 @@ For late registration, prefer the app helpers: - `registerScript(...)` / `registerScripts(...)` - `registerModule(...)` +If you are registering raw `WorkflowDefinition` values directly, prefer +`WorkflowDefinition.flowJson(...)` and `WorkflowDefinition.scriptJson(...)` +when the final result is a normal DTO with `toJson()` and `Type.fromJson(...)`. + ### Workflow script facade Prefer the high-level `WorkflowScript` facade when you want to author a diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index a6d22d28..cee5fe7d 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -203,6 +203,28 @@ class WorkflowDefinition { ); } + /// Creates a flow-based workflow definition whose final result is a DTO + /// backed by a JSON payload. + factory WorkflowDefinition.flowJson({ + required String name, + required void Function(FlowBuilder builder) build, + required T Function(Map payload) decodeResult, + String? version, + String? description, + Map? metadata, + String? resultTypeName, + }) { + return WorkflowDefinition.flow( + name: name, + build: build, + version: version, + description: description, + metadata: metadata, + decodeResultJson: decodeResult, + resultTypeName: resultTypeName, + ); + } + /// Creates a script-based workflow definition. factory WorkflowDefinition.script({ required String name, @@ -251,6 +273,30 @@ class WorkflowDefinition { ); } + /// Creates a script-based workflow definition whose final result is a DTO + /// backed by a JSON payload. + factory WorkflowDefinition.scriptJson({ + required String name, + required WorkflowScriptBody run, + required T Function(Map payload) decodeResult, + Iterable checkpoints = const [], + String? version, + String? description, + Map? metadata, + String? resultTypeName, + }) { + return WorkflowDefinition.script( + name: name, + run: run, + checkpoints: checkpoints, + version: version, + description: description, + metadata: metadata, + decodeResultJson: decodeResult, + resultTypeName: resultTypeName, + ); + } + /// Workflow name used for registration and scheduling. final String name; final WorkflowDefinitionKind _kind; diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 98acba5b..723e7a88 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -287,6 +287,49 @@ void main() { } }); + test( + 'raw workflow definitions expose direct json result helpers', + () async { + final flow = WorkflowDefinition<_GreetingResult>.flowJson( + name: 'runtime.ref.definition.json.result.flow', + decodeResult: _GreetingResult.fromJson, + build: (builder) { + builder.step( + 'hello', + (ctx) async => + const _GreetingResult(message: 'hello definition flow json'), + ); + }, + ); + final script = WorkflowDefinition<_GreetingResult>.scriptJson( + name: 'runtime.ref.definition.json.result.script', + decodeResult: _GreetingResult.fromJson, + run: (context) async => + const _GreetingResult(message: 'hello definition script json'), + ); + + final workflowApp = await StemWorkflowApp.inMemory(); + try { + workflowApp.registerWorkflows([flow, script]); + await workflowApp.start(); + + final flowResult = await flow.ref0().startAndWait( + workflowApp.runtime, + timeout: const Duration(seconds: 2), + ); + final scriptResult = await script.ref0().startAndWait( + workflowApp.runtime, + timeout: const Duration(seconds: 2), + ); + + expect(flowResult?.value?.message, 'hello definition flow json'); + expect(scriptResult?.value?.message, 'hello definition script json'); + } finally { + await workflowApp.shutdown(); + } + }, + ); + test('manual workflows expose direct no-args helpers', () async { final flow = Flow( name: 'runtime.ref.no-args.flow', From d3bceb4c37a4cba86d10673fbb468d0e3cfbec7a Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 07:09:53 -0500 Subject: [PATCH 176/302] Add direct codec workflow and task helpers --- .site/docs/core-concepts/tasks.md | 3 +- .site/docs/workflows/getting-started.md | 5 +- .site/docs/workflows/starting-and-waiting.md | 3 +- packages/stem/CHANGELOG.md | 4 ++ packages/stem/README.md | 17 ++++--- packages/stem/lib/src/core/contracts.dart | 15 ++++++ packages/stem/lib/src/workflow/core/flow.dart | 19 +++++++ .../workflow/core/workflow_definition.dart | 42 ++++++++++++++++ .../src/workflow/core/workflow_script.dart | 22 +++++++++ .../stem/test/unit/core/stem_core_test.dart | 2 +- .../workflow/workflow_runtime_ref_test.dart | 49 ++++++++++++++++++- 11 files changed, 167 insertions(+), 14 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 174fca77..41f0ab67 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -72,8 +72,7 @@ instead. That gives you direct `enqueue(...)` / If a no-arg task returns a DTO, prefer `TaskDefinition.noArgsJson(...)` when the result already has `toJson()` and `Type.fromJson(...)`. Use -`TaskDefinition.noArgs(...)` with `resultCodec:` only when you need a custom -payload codec. +`TaskDefinition.noArgsCodec(...)` only when you need a custom payload codec. ## Configuring Retries diff --git a/.site/docs/workflows/getting-started.md b/.site/docs/workflows/getting-started.md index d4331617..cc9b9fe7 100644 --- a/.site/docs/workflows/getting-started.md +++ b/.site/docs/workflows/getting-started.md @@ -71,8 +71,9 @@ runtime registry: - `registerModule(...)` / `registerModules(...)` If you are registering raw `WorkflowDefinition` values directly, prefer -`WorkflowDefinition.flowJson(...)` and `WorkflowDefinition.scriptJson(...)` -for the common DTO-result path. +`WorkflowDefinition.flowJson(...)` / `.scriptJson(...)` for the common DTO +path and `WorkflowDefinition.flowCodec(...)` / `.scriptCodec(...)` when the +result needs a custom codec. ## 5. Move to the right next page diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 4ebc7adf..c2a48edf 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -66,7 +66,8 @@ still need to encode to a string-keyed map (typically If a manual flow or script returns a DTO, prefer `Flow.json(...)` or `WorkflowScript.json(...)` in the common `toJson()` / `Type.fromJson(...)` -case. Use `resultCodec:` only when the result needs a custom payload codec. +case. Use `Flow.codec(...)` or `WorkflowScript.codec(...)` when the result +needs a custom payload codec. For workflows without start params, start directly from the flow or script itself with `start(...)`, `startAndWait(...)`, or `prepareStart()`. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 88d7b587..a2eee062 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.1 +- Added `TaskDefinition.noArgsCodec(...)`, `Flow.codec(...)`, + `WorkflowScript.codec(...)`, `WorkflowDefinition.flowCodec(...)`, and + `.scriptCodec(...)` so the manual result path now has direct custom-codec + helpers alongside the newer JSON shortcuts. - Added `WorkflowDefinition.flowJson(...)` and `WorkflowDefinition.scriptJson(...)` so the raw definition path has the same direct DTO-result helper as `Flow.json(...)` and `WorkflowScript.json(...)`. diff --git a/packages/stem/README.md b/packages/stem/README.md index 7bd3dd79..8ba50c9f 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -249,9 +249,10 @@ await healthcheckDefinition.enqueue(stem); ``` If a no-arg task returns a DTO, prefer `TaskDefinition.noArgsJson(...)` in the -common `toJson()` / `Type.fromJson(...)` case. Use `TaskDefinition.noArgs(...)` -with `resultCodec:` when you need a custom payload codec. Both paths keep -waiting helpers typed and advertise the right result encoder in task metadata. +common `toJson()` / `Type.fromJson(...)` case. Use +`TaskDefinition.noArgsCodec(...)` when you need a custom payload codec. Both +paths keep waiting helpers typed and advertise the right result encoder in task +metadata. You can also build requests fluently from the task definition itself: @@ -372,8 +373,9 @@ For late registration, prefer the app helpers: - `registerModule(...)` If you are registering raw `WorkflowDefinition` values directly, prefer -`WorkflowDefinition.flowJson(...)` and `WorkflowDefinition.scriptJson(...)` -when the final result is a normal DTO with `toJson()` and `Type.fromJson(...)`. +`WorkflowDefinition.flowJson(...)` / `.scriptJson(...)` for the common DTO +path and `WorkflowDefinition.flowCodec(...)` / `.scriptCodec(...)` for custom +result codecs. ### Workflow script facade @@ -526,8 +528,9 @@ still need to encode to a string-keyed map (typically `Map`) because they are persisted as JSON-shaped data. If a manual flow or script only needs DTO result decoding, prefer -`Flow.json(...)` or `WorkflowScript.json(...)` instead of passing -`decodeResultJson:` to the base constructor. +`Flow.json(...)` or `WorkflowScript.json(...)`. If the final result needs a +custom codec, prefer `Flow.codec(...)` or `WorkflowScript.codec(...)` instead +of passing `resultCodec:` to the base constructor. For workflows without start parameters, start directly from the flow or script itself: diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 2d211834..d2cce5ee 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2137,6 +2137,21 @@ class TaskDefinition { ); } + /// Creates a typed task definition for handlers with no producer args. + static NoArgsTaskDefinition noArgsCodec({ + required String name, + required PayloadCodec resultCodec, + TaskOptions defaultOptions = const TaskOptions(), + TaskMetadata metadata = const TaskMetadata(), + }) { + return noArgs( + name: name, + defaultOptions: defaultOptions, + metadata: metadata, + resultCodec: resultCodec, + ); + } + /// Creates a typed task definition for handlers with no producer args. static NoArgsTaskDefinition noArgsJson({ required String name, diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index 9d00ffc2..e980040f 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -33,6 +33,25 @@ class Flow { resultTypeName: resultTypeName, ); + /// Creates a flow definition whose final result uses a custom payload codec. + factory Flow.codec({ + required String name, + required void Function(FlowBuilder builder) build, + required PayloadCodec resultCodec, + String? version, + String? description, + Map? metadata, + }) { + return Flow( + name: name, + build: build, + version: version, + description: description, + metadata: metadata, + resultCodec: resultCodec, + ); + } + /// Creates a flow definition whose final result is a DTO-backed JSON value. factory Flow.json({ required String name, diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index cee5fe7d..60166d26 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -203,6 +203,26 @@ class WorkflowDefinition { ); } + /// Creates a flow-based workflow definition whose final result uses a custom + /// payload codec. + factory WorkflowDefinition.flowCodec({ + required String name, + required void Function(FlowBuilder builder) build, + required PayloadCodec resultCodec, + String? version, + String? description, + Map? metadata, + }) { + return WorkflowDefinition.flow( + name: name, + build: build, + version: version, + description: description, + metadata: metadata, + resultCodec: resultCodec, + ); + } + /// Creates a flow-based workflow definition whose final result is a DTO /// backed by a JSON payload. factory WorkflowDefinition.flowJson({ @@ -273,6 +293,28 @@ class WorkflowDefinition { ); } + /// Creates a script-based workflow definition whose final result uses a + /// custom payload codec. + factory WorkflowDefinition.scriptCodec({ + required String name, + required WorkflowScriptBody run, + required PayloadCodec resultCodec, + Iterable checkpoints = const [], + String? version, + String? description, + Map? metadata, + }) { + return WorkflowDefinition.script( + name: name, + run: run, + checkpoints: checkpoints, + version: version, + description: description, + metadata: metadata, + resultCodec: resultCodec, + ); + } + /// Creates a script-based workflow definition whose final result is a DTO /// backed by a JSON payload. factory WorkflowDefinition.scriptJson({ diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index 05b1b46f..603a1e07 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -35,6 +35,28 @@ class WorkflowScript { resultTypeName: resultTypeName, ); + /// Creates a script definition whose final result uses a custom payload + /// codec. + factory WorkflowScript.codec({ + required String name, + required WorkflowScriptBody run, + required PayloadCodec resultCodec, + Iterable checkpoints = const [], + String? version, + String? description, + Map? metadata, + }) { + return WorkflowScript( + name: name, + run: run, + checkpoints: checkpoints, + version: version, + description: description, + metadata: metadata, + resultCodec: resultCodec, + ); + } + /// Creates a script definition whose final result is a DTO-backed JSON /// value. factory WorkflowScript.json({ diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index ad4a8f61..846c870c 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -328,7 +328,7 @@ void main() { final broker = _RecordingBroker(); final backend = _RecordingBackend(); final stem = Stem(broker: broker, backend: backend); - final definition = TaskDefinition.noArgs<_CodecReceipt>( + final definition = TaskDefinition.noArgsCodec<_CodecReceipt>( name: 'sample.no_args.codec', resultCodec: _codecReceiptCodec, ); diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 723e7a88..afe1088e 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -216,7 +216,7 @@ void main() { ); test('codec-backed refs preserve workflow result decoding', () async { - final flow = Flow<_GreetingResult>( + final flow = Flow<_GreetingResult>.codec( name: 'runtime.ref.codec.result.flow', resultCodec: _greetingResultCodec, build: (builder) { @@ -246,6 +246,53 @@ void main() { } }); + test( + 'raw workflow definitions expose direct codec result helpers', + () async { + final flow = WorkflowDefinition<_GreetingResult>.flowCodec( + name: 'runtime.ref.definition.codec.result.flow', + resultCodec: _greetingResultCodec, + build: (builder) { + builder.step( + 'hello', + (ctx) async => const _GreetingResult( + message: 'hello definition flow codec', + ), + ); + }, + ); + final script = WorkflowDefinition<_GreetingResult>.scriptCodec( + name: 'runtime.ref.definition.codec.result.script', + resultCodec: _greetingResultCodec, + run: (context) async => + const _GreetingResult(message: 'hello definition script codec'), + ); + + final workflowApp = await StemWorkflowApp.inMemory(); + try { + workflowApp.registerWorkflows([flow, script]); + await workflowApp.start(); + + final flowResult = await flow.ref0().startAndWait( + workflowApp.runtime, + timeout: const Duration(seconds: 2), + ); + final scriptResult = await script.ref0().startAndWait( + workflowApp.runtime, + timeout: const Duration(seconds: 2), + ); + + expect(flowResult?.value?.message, 'hello definition flow codec'); + expect( + scriptResult?.value?.message, + 'hello definition script codec', + ); + } finally { + await workflowApp.shutdown(); + } + }, + ); + test('manual workflows can derive json-backed result decoding', () async { final flow = Flow<_GreetingResult>.json( name: 'runtime.ref.json.result.flow', From 6f7e5e9b607ce6c761f42426b62b4fff693dca1f Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 07:17:18 -0500 Subject: [PATCH 177/302] Add shared workflow resume context --- packages/stem/CHANGELOG.md | 3 + .../lib/src/workflow/core/flow_context.dart | 21 ++- .../src/workflow/core/workflow_resume.dart | 151 +++--------------- .../core/workflow_resume_context.dart | 24 +++ .../core/workflow_script_context.dart | 21 ++- .../workflow/runtime/workflow_runtime.dart | 19 ++- packages/stem/lib/src/workflow/workflow.dart | 5 +- .../unit/workflow/workflow_resume_test.dart | 41 +++-- 8 files changed, 140 insertions(+), 145 deletions(-) create mode 100644 packages/stem/lib/src/workflow/core/workflow_resume_context.dart diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index a2eee062..55eddaad 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Added `WorkflowResumeContext` as the shared typed suspension/wait surface for + flow steps and script checkpoints. Typed workflow event waits now target that + shared interface instead of accepting an erased `Object`. - Added `TaskDefinition.noArgsCodec(...)`, `Flow.codec(...)`, `WorkflowScript.codec(...)`, `WorkflowDefinition.flowCodec(...)`, and `.scriptCodec(...)` so the manual result path now has direct custom-codec diff --git a/packages/stem/lib/src/workflow/core/flow_context.dart b/packages/stem/lib/src/workflow/core/flow_context.dart index 71aedc3e..89c211d5 100644 --- a/packages/stem/lib/src/workflow/core/flow_context.dart +++ b/packages/stem/lib/src/workflow/core/flow_context.dart @@ -1,9 +1,10 @@ import 'package:stem/src/core/contracts.dart'; -import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; import 'package:stem/src/workflow/core/flow_step.dart'; +import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; import 'package:stem/src/workflow/core/workflow_clock.dart'; import 'package:stem/src/workflow/core/workflow_ref.dart'; import 'package:stem/src/workflow/core/workflow_result.dart'; +import 'package:stem/src/workflow/core/workflow_resume_context.dart'; /// Context provided to each workflow step invocation. /// @@ -16,7 +17,8 @@ import 'package:stem/src/workflow/core/workflow_result.dart'; /// [iteration] indicates how many times the step has already completed when /// `autoVersion` is enabled, allowing handlers to branch per loop iteration or /// derive unique identifiers. -class FlowContext implements TaskEnqueuer, WorkflowCaller { +class FlowContext + implements TaskEnqueuer, WorkflowCaller, WorkflowResumeContext { /// Creates a workflow step context. FlowContext({ required this.workflow, @@ -114,6 +116,20 @@ class FlowContext implements TaskEnqueuer, WorkflowCaller { return _control!; } + @override + void suspendFor(Duration duration, {Map? data}) { + sleep(duration, data: data); + } + + @override + void waitForTopic( + String topic, { + DateTime? deadline, + Map? data, + }) { + awaitEvent(topic, deadline: deadline, data: data); + } + /// Injects a payload that will be returned the next time [takeResumeData] is /// called. Primarily used by the runtime; tests may also leverage it to mock /// resumption data. @@ -129,6 +145,7 @@ class FlowContext implements TaskEnqueuer, WorkflowCaller { /// The method consumes the payload so subsequent calls during the same step /// return `null`. This makes it safe to guard control-flow with a simple /// `if (takeResumeData() == null) { ... }` pattern. + @override Object? takeResumeData() { final value = _resumeData; _resumeData = null; diff --git a/packages/stem/lib/src/workflow/core/workflow_resume.dart b/packages/stem/lib/src/workflow/core/workflow_resume.dart index c8ee93b6..0769bc7d 100644 --- a/packages/stem/lib/src/workflow/core/workflow_resume.dart +++ b/packages/stem/lib/src/workflow/core/workflow_resume.dart @@ -4,7 +4,7 @@ import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/workflow/core/flow_context.dart'; import 'package:stem/src/workflow/core/flow_step.dart'; import 'package:stem/src/workflow/core/workflow_event_ref.dart'; -import 'package:stem/src/workflow/core/workflow_script_context.dart'; +import 'package:stem/src/workflow/core/workflow_resume_context.dart'; /// Internal control-flow signal used by expression-style suspension helpers. /// @@ -16,7 +16,7 @@ class WorkflowSuspensionSignal implements Exception { } /// Typed resume helpers for durable workflow suspensions. -extension FlowContextResumeValues on FlowContext { +extension WorkflowResumeContextValues on WorkflowResumeContext { /// Returns the next resume payload as [T] and consumes it. /// /// When [codec] is provided, the stored durable payload is decoded through @@ -28,7 +28,7 @@ extension FlowContextResumeValues on FlowContext { return payload as T; } - /// Suspends the current step with [sleep] on the first invocation and + /// Suspends the current step on the first invocation and /// returns `true` once the step resumes. /// /// This helper is for the common: @@ -49,7 +49,10 @@ extension FlowContextResumeValues on FlowContext { if (resume != null) { return true; } - sleep(duration, data: data); + final pending = suspendFor(duration, data: data); + if (pending is Future) { + unawaited(pending); + } return false; } @@ -68,7 +71,7 @@ extension FlowContextResumeValues on FlowContext { if (resume != null) { return; } - sleep(duration, data: data); + await suspendFor(duration, data: data); throw const WorkflowSuspensionSignal(); } @@ -96,80 +99,10 @@ extension FlowContextResumeValues on FlowContext { if (payload != null) { return payload; } - awaitEvent(topic, deadline: deadline, data: data); - return null; - } - - /// Suspends until [topic] is emitted, then returns the resumed payload. - Future waitForEvent({ - required String topic, - DateTime? deadline, - Map? data, - PayloadCodec? codec, - }) async { - final payload = takeResumeValue(codec: codec); - if (payload != null) { - return payload; - } - awaitEvent(topic, deadline: deadline, data: data); - throw const WorkflowSuspensionSignal(); - } -} - -/// Typed resume helpers for durable script checkpoints. -extension WorkflowScriptStepResumeValues on WorkflowScriptStepContext { - /// Returns the next resume payload as [T] and consumes it. - /// - /// When [codec] is provided, the stored durable payload is decoded through - /// that codec before being returned. - T? takeResumeValue({PayloadCodec? codec}) { - final payload = takeResumeData(); - if (payload == null) return null; - if (codec != null) return codec.decodeDynamic(payload) as T; - return payload as T; - } - - /// Suspends the current checkpoint with [sleep] on the first invocation and - /// returns `true` once the checkpoint resumes. - bool sleepUntilResumed( - Duration duration, { - Map? data, - }) { - final resume = takeResumeData(); - if (resume != null) { - return true; + final pending = waitForTopic(topic, deadline: deadline, data: data); + if (pending is Future) { + unawaited(pending); } - unawaited(sleep(duration, data: data)); - return false; - } - - /// Suspends once for [duration] and resumes by replaying the same - /// checkpoint. - Future sleepFor({ - required Duration duration, - Map? data, - }) async { - final resume = takeResumeData(); - if (resume != null) { - return; - } - await sleep(duration, data: data); - throw const WorkflowSuspensionSignal(); - } - - /// Returns the next event payload as [T] when the checkpoint has resumed, or - /// registers an event wait and returns `null` on the first invocation. - T? waitForEventValue( - String topic, { - DateTime? deadline, - Map? data, - PayloadCodec? codec, - }) { - final payload = takeResumeValue(codec: codec); - if (payload != null) { - return payload; - } - unawaited(awaitEvent(topic, deadline: deadline, data: data)); return null; } @@ -184,7 +117,7 @@ extension WorkflowScriptStepResumeValues on WorkflowScriptStepContext { if (payload != null) { return payload; } - await awaitEvent(topic, deadline: deadline, data: data); + await waitForTopic(topic, deadline: deadline, data: data); throw const WorkflowSuspensionSignal(); } } @@ -210,66 +143,30 @@ extension WorkflowEventRefWaitExtension on WorkflowEventRef { /// Registers an event wait and returns the resumed payload on the legacy /// null-then-resume path. - /// - /// [waiter] must be a [FlowContext] or [WorkflowScriptStepContext]. T? waitValue( - Object waiter, { + WorkflowResumeContext waiter, { DateTime? deadline, Map? data, }) { - if (waiter case final FlowContext context) { - return context.waitForEventValue( - topic, - deadline: deadline, - data: data, - codec: codec, - ); - } - if (waiter case final WorkflowScriptStepContext context) { - return context.waitForEventValue( - topic, - deadline: deadline, - data: data, - codec: codec, - ); - } - throw ArgumentError.value( - waiter, - 'waiter', - 'WorkflowEventRef.waitValue requires a FlowContext or ' - 'WorkflowScriptStepContext.', + return waiter.waitForEventValue( + topic, + deadline: deadline, + data: data, + codec: codec, ); } /// Suspends until this event is emitted, then returns the decoded payload. - /// - /// [waiter] must be a [FlowContext] or [WorkflowScriptStepContext]. Future wait( - Object waiter, { + WorkflowResumeContext waiter, { DateTime? deadline, Map? data, }) { - if (waiter case final FlowContext context) { - return context.waitForEvent( - topic: topic, - deadline: deadline, - data: data, - codec: codec, - ); - } - if (waiter case final WorkflowScriptStepContext context) { - return context.waitForEvent( - topic: topic, - deadline: deadline, - data: data, - codec: codec, - ); - } - throw ArgumentError.value( - waiter, - 'waiter', - 'WorkflowEventRef.wait requires a FlowContext or ' - 'WorkflowScriptStepContext.', + return waiter.waitForEvent( + topic: topic, + deadline: deadline, + data: data, + codec: codec, ); } } diff --git a/packages/stem/lib/src/workflow/core/workflow_resume_context.dart b/packages/stem/lib/src/workflow/core/workflow_resume_context.dart new file mode 100644 index 00000000..cc1af913 --- /dev/null +++ b/packages/stem/lib/src/workflow/core/workflow_resume_context.dart @@ -0,0 +1,24 @@ +import 'dart:async'; + +/// Shared suspension/resume surface implemented by flow steps and script +/// checkpoints. +/// +/// This keeps typed event wait helpers on a single workflow-facing capability +/// instead of accepting an erased `Object` and branching at runtime. +abstract interface class WorkflowResumeContext { + /// Returns and clears the resume payload supplied by the runtime. + Object? takeResumeData(); + + /// Schedules a durable wake-up after [duration]. + FutureOr suspendFor( + Duration duration, { + Map? data, + }); + + /// Suspends until [topic] is emitted. + FutureOr waitForTopic( + String topic, { + DateTime? deadline, + Map? data, + }); +} diff --git a/packages/stem/lib/src/workflow/core/workflow_script_context.dart b/packages/stem/lib/src/workflow/core/workflow_script_context.dart index ccd1022a..def9f4f3 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script_context.dart @@ -5,6 +5,7 @@ import 'package:stem/src/workflow/core/flow_context.dart' show FlowContext; import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; import 'package:stem/src/workflow/core/workflow_ref.dart'; import 'package:stem/src/workflow/core/workflow_result.dart'; +import 'package:stem/src/workflow/core/workflow_resume_context.dart'; /// Runtime context exposed to workflow scripts. Implementations are provided by /// the workflow runtime so scripts can execute with durable semantics. @@ -32,7 +33,7 @@ abstract class WorkflowScriptContext { /// Context provided to each script checkpoint invocation. Mirrors /// [FlowContext] but tailored for the facade helpers. abstract class WorkflowScriptStepContext - implements TaskEnqueuer, WorkflowCaller { + implements TaskEnqueuer, WorkflowCaller, WorkflowResumeContext { /// Name of the workflow currently executing. String get workflow; @@ -67,8 +68,26 @@ abstract class WorkflowScriptStepContext /// Returns and clears the resume payload provided by the runtime when the /// checkpoint resumes after a suspension. + @override Object? takeResumeData(); + @override + Future suspendFor( + Duration duration, { + Map? data, + }) { + return sleep(duration, data: data); + } + + @override + Future waitForTopic( + String topic, { + DateTime? deadline, + Map? data, + }) { + return awaitEvent(topic, deadline: deadline, data: data); + } + /// Returns a stable idempotency key derived from workflow/run/checkpoint. String idempotencyKey([String? scope]); diff --git a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart index ea3b69dc..f5259d1e 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart @@ -38,12 +38,12 @@ import 'package:stem/src/signals/emitter.dart'; import 'package:stem/src/signals/payloads.dart'; import 'package:stem/src/workflow/core/event_bus.dart'; import 'package:stem/src/workflow/core/flow_context.dart'; -import 'package:stem/src/workflow/core/workflow_event_ref.dart'; import 'package:stem/src/workflow/core/flow_step.dart'; import 'package:stem/src/workflow/core/run_state.dart'; import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; import 'package:stem/src/workflow/core/workflow_clock.dart'; import 'package:stem/src/workflow/core/workflow_definition.dart'; +import 'package:stem/src/workflow/core/workflow_event_ref.dart'; import 'package:stem/src/workflow/core/workflow_ref.dart'; import 'package:stem/src/workflow/core/workflow_result.dart'; import 'package:stem/src/workflow/core/workflow_resume.dart'; @@ -1813,6 +1813,23 @@ class _WorkflowScriptStepContextImpl implements WorkflowScriptStepContext { ); } + @override + Future suspendFor( + Duration duration, { + Map? data, + }) { + return sleep(duration, data: data); + } + + @override + Future waitForTopic( + String topic, { + DateTime? deadline, + Map? data, + }) { + return awaitEvent(topic, deadline: deadline, data: data); + } + @override /// Suspends the run until the sleep duration elapses. Future sleep(Duration duration, {Map? data}) async { diff --git a/packages/stem/lib/src/workflow/workflow.dart b/packages/stem/lib/src/workflow/workflow.dart index dd920614..cdedaa1d 100644 --- a/packages/stem/lib/src/workflow/workflow.dart +++ b/packages/stem/lib/src/workflow/workflow.dart @@ -7,14 +7,15 @@ export 'core/flow_context.dart'; export 'core/flow_step.dart'; export 'core/run_state.dart'; export 'core/workflow_cancellation_policy.dart'; -export 'core/workflow_clock.dart'; export 'core/workflow_checkpoint.dart'; +export 'core/workflow_clock.dart'; export 'core/workflow_definition.dart'; export 'core/workflow_event_ref.dart'; export 'core/workflow_ref.dart'; export 'core/workflow_result.dart'; -export 'core/workflow_runtime_metadata.dart'; export 'core/workflow_resume.dart'; +export 'core/workflow_resume_context.dart'; +export 'core/workflow_runtime_metadata.dart'; export 'core/workflow_script.dart'; export 'core/workflow_script_context.dart'; export 'core/workflow_status.dart'; diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart index d4c6adb2..d61a7aab 100644 --- a/packages/stem/test/unit/workflow/workflow_resume_test.dart +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -7,6 +7,7 @@ import 'package:stem/src/workflow/core/workflow_event_ref.dart'; import 'package:stem/src/workflow/core/workflow_ref.dart'; import 'package:stem/src/workflow/core/workflow_result.dart'; import 'package:stem/src/workflow/core/workflow_resume.dart'; +import 'package:stem/src/workflow/core/workflow_resume_context.dart'; import 'package:stem/src/workflow/core/workflow_script_context.dart'; import 'package:test/test.dart'; @@ -522,20 +523,19 @@ void main() { }, ); - test('WorkflowEventRef wait helpers reject unsupported waiter types', () { - const event = WorkflowEventRef<_ResumePayload>( - topic: 'demo.event', - codec: _resumePayloadCodec, + test('flow and script step contexts share the resume-context surface', () { + final flowContext = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, ); + final scriptContext = _FakeWorkflowScriptStepContext(); - expect( - () => event.waitValue('invalid'), - throwsA(isA()), - ); - expect( - () => event.wait('invalid'), - throwsA(isA()), - ); + expect(flowContext, isA()); + expect(scriptContext, isA()); }); test( @@ -673,6 +673,23 @@ class _FakeWorkflowScriptStepContext implements WorkflowScriptStepContext { awaitedData = data == null ? null : Map.from(data); } + @override + Future suspendFor( + Duration duration, { + Map? data, + }) { + return sleep(duration, data: data); + } + + @override + Future waitForTopic( + String topic, { + DateTime? deadline, + Map? data, + }) { + return awaitEvent(topic, deadline: deadline, data: data); + } + @override String idempotencyKey([String? scope]) => 'demo.workflow/run-1/${scope ?? stepName}'; From 406ff3d06b22a2556324475381178193fd224f8f Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 07:24:38 -0500 Subject: [PATCH 178/302] Drop unused manual json decoders --- .site/docs/core-concepts/tasks.md | 4 +- .site/docs/workflows/starting-and-waiting.md | 8 +- packages/stem/CHANGELOG.md | 3 + packages/stem/README.md | 17 ++-- .../stem/example/docs_snippets/lib/tasks.dart | 16 ++-- .../example/docs_snippets/lib/workflows.dart | 4 +- packages/stem/lib/src/core/contracts.dart | 24 +++-- packages/stem/lib/src/core/payload_codec.dart | 10 ++ packages/stem/lib/src/workflow/core/flow.dart | 4 +- .../workflow/core/workflow_definition.dart | 4 +- .../lib/src/workflow/core/workflow_ref.dart | 25 ++--- .../src/workflow/core/workflow_script.dart | 4 +- .../stem/test/unit/core/stem_core_test.dart | 21 ++--- .../workflow/workflow_runtime_ref_test.dart | 91 +++++++++---------- 14 files changed, 117 insertions(+), 118 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 41f0ab67..2c54bb69 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -53,8 +53,8 @@ Typed results flow through `TaskResult` when you call lets you deserialize complex objects before they reach application code. If your manual task args are DTOs, prefer `TaskDefinition.json(...)` -when the type already has `toJson()` and `Type.fromJson(...)`. Use -`TaskDefinition.codec(...)` when you need a custom +when the type already has `toJson()`. Use `TaskDefinition.codec(...)` when you +need a custom `PayloadCodec`. Task args still need to encode to a string-keyed map (typically `Map`) because they are published as JSON-shaped data. diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index c2a48edf..9306a073 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -58,10 +58,10 @@ final runId = await approvalsRef .start(workflowApp); ``` -`refJson(...)` is the shortest manual DTO path when the params or -final result already have `toJson()` and `Type.fromJson(...)`. Use -`refCodec(...)` when you need a custom `PayloadCodec`. Workflow params -still need to encode to a string-keyed map (typically +`refJson(...)` is the shortest manual DTO path when the params already have +`toJson()`, or when the final result also needs a `Type.fromJson(...)` +decoder. Use `refCodec(...)` when you need a custom `PayloadCodec`. +Workflow params still need to encode to a string-keyed map (typically `Map`) because they are stored as JSON-shaped data. If a manual flow or script returns a DTO, prefer `Flow.json(...)` or diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 55eddaad..2eb9e8da 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Simplified the manual JSON helper path so `TaskDefinition.json(...)` and + `WorkflowRef.json(...)` no longer require unused producer-side + `decodeArgs`/`decodeParams` callbacks just to publish DTO payloads. - Added `WorkflowResumeContext` as the shared typed suspension/wait surface for flow steps and script checkpoints. Typed workflow event waits now target that shared interface instead of accepting an erased `Object`. diff --git a/packages/stem/README.md b/packages/stem/README.md index 8ba50c9f..e5826be8 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -162,7 +162,6 @@ Use the new typed wrapper when you want compile-time checking and shared metadat class HelloTask implements TaskHandler { static final definition = TaskDefinition.json( name: 'demo.hello', - decodeArgs: HelloArgs.fromJson, metadata: TaskMetadata(description: 'Simple hello world example'), ); @@ -220,8 +219,8 @@ producer-only processes do not need to register the worker handler locally just to enqueue typed calls. Use `TaskDefinition.json(...)` when your manual task args are normal -DTOs with `toJson()` and `Type.fromJson(...)`. Drop down to -`TaskDefinition.codec(...)` only when you need a custom +DTOs with `toJson()`. Drop down to `TaskDefinition.codec(...)` only when you +need a custom `PayloadCodec`. Task args still need to encode to a string-keyed map (typically `Map`) because they are published as JSON-shaped data. @@ -488,7 +487,6 @@ final approvalsFlow = Flow( ); final approvalsRef = approvalsFlow.refJson( - decodeParams: ApprovalDraft.fromJson, ); final app = await StemWorkflowApp.fromUrl( @@ -521,11 +519,12 @@ final runId = await approvalsRef .start(app); ``` -Use `refJson(...)` when your manual workflow start params or final result are -normal DTOs with `toJson()` and `Type.fromJson(...)`. Drop down to -`refCodec(...)` when you need a custom `PayloadCodec`. Workflow params -still need to encode to a string-keyed map (typically -`Map`) because they are persisted as JSON-shaped data. +Use `refJson(...)` when your manual workflow start params are DTOs with +`toJson()`, or when the final result also needs a `Type.fromJson(...)` +decoder. Drop down to `refCodec(...)` when you need a custom +`PayloadCodec`. Workflow params still need to encode to a string-keyed map +(typically `Map`) because they are persisted as JSON-shaped +data. If a manual flow or script only needs DTO result decoding, prefer `Flow.json(...)` or `WorkflowScript.json(...)`. If the final result needs a diff --git a/packages/stem/example/docs_snippets/lib/tasks.dart b/packages/stem/example/docs_snippets/lib/tasks.dart index 17d74249..712fa2c4 100644 --- a/packages/stem/example/docs_snippets/lib/tasks.dart +++ b/packages/stem/example/docs_snippets/lib/tasks.dart @@ -59,15 +59,13 @@ class InvoicePayload { } class PublishInvoiceTask extends TaskHandler { - static final definition = - TaskDefinition.json( - name: 'invoice.publish', - decodeArgs: InvoicePayload.fromJson, - metadata: const TaskMetadata( - description: 'Publishes invoices downstream', - ), - defaultOptions: const TaskOptions(queue: 'billing'), - ); + static final definition = TaskDefinition.json( + name: 'invoice.publish', + metadata: const TaskMetadata( + description: 'Publishes invoices downstream', + ), + defaultOptions: const TaskOptions(queue: 'billing'), + ); @override String get name => definition.name; diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index 8330772b..9ee2e7fd 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -76,9 +76,7 @@ class ApprovalsFlow { }, ); - static final ref = flow.refJson( - decodeParams: ApprovalDraft.fromJson, - ); + static final ref = flow.refJson(); } Future registerFlow(StemWorkflowApp workflowApp) async { diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index d2cce5ee..f674896b 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2107,33 +2107,29 @@ class TaskDefinition { } /// Creates a typed task definition for DTO args that already expose - /// `toJson()` and `Type.fromJson(...)`. + /// `toJson()`. factory TaskDefinition.json({ required String name, - required TArgs Function(Map payload) decodeArgs, TaskMetaBuilder? encodeMeta, TaskOptions defaultOptions = const TaskOptions(), TaskMetadata metadata = const TaskMetadata(), TResult Function(Map payload)? decodeResultJson, String? argsTypeName, String? resultTypeName, - }) { + }) { final resultCodec = decodeResultJson == null ? null : PayloadCodec.json( decode: decodeResultJson, typeName: resultTypeName ?? '$TResult', ); - return TaskDefinition.codec( + return TaskDefinition( name: name, - argsCodec: PayloadCodec.json( - decode: decodeArgs, - typeName: argsTypeName ?? '$TArgs', - ), + encodeArgs: (args) => _encodeJsonArgs(args, argsTypeName ?? '$TArgs'), encodeMeta: encodeMeta, defaultOptions: defaultOptions, - metadata: metadata, - resultCodec: resultCodec, + metadata: _metadataWithResultCodec(name, metadata, resultCodec), + decodeResult: resultCodec?.decode, ); } @@ -2247,6 +2243,14 @@ class TaskDefinition { ); } + static Map _encodeJsonArgs(T args, String typeName) { + final payload = PayloadCodec.encodeJsonMap( + args, + typeName: typeName, + ); + return Map.from(payload); + } + static TaskMetadata _metadataWithResultCodec( String taskName, TaskMetadata metadata, diff --git a/packages/stem/lib/src/core/payload_codec.dart b/packages/stem/lib/src/core/payload_codec.dart index 766e8485..199f3b7c 100644 --- a/packages/stem/lib/src/core/payload_codec.dart +++ b/packages/stem/lib/src/core/payload_codec.dart @@ -57,6 +57,16 @@ class PayloadCodec { final T Function(Map payload)? _decodeMap; final String? _typeName; + /// Encodes a DTO to the string-keyed map shape required by task/workflow + /// argument payloads. + static Map encodeJsonMap( + T value, { + String? typeName, + }) { + final payload = _encodeJsonPayload(value); + return _payloadJsonMap(payload, typeName ?? value.runtimeType.toString()); + } + /// Converts a typed value into a durable payload representation. Object? encode(T value) => _encode(value); diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index e980040f..fc0c23a4 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -92,15 +92,13 @@ class Flow { } /// Builds a typed [WorkflowRef] for DTO params that already expose - /// `toJson()` and `Type.fromJson(...)`. + /// `toJson()`. WorkflowRef refJson({ - required TParams Function(Map payload) decodeParams, T Function(Map payload)? decodeResultJson, String? paramsTypeName, String? resultTypeName, }) { return definition.refJson( - decodeParams: decodeParams, decodeResultJson: decodeResultJson, paramsTypeName: paramsTypeName, resultTypeName: resultTypeName, diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index 60166d26..9506fa10 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -433,16 +433,14 @@ class WorkflowDefinition { } /// Builds a typed [WorkflowRef] for DTO params that already expose - /// `toJson()` and `Type.fromJson(...)`. + /// `toJson()`. WorkflowRef refJson({ - required TParams Function(Map payload) decodeParams, T Function(Map payload)? decodeResultJson, String? paramsTypeName, String? resultTypeName, }) { return WorkflowRef.json( name: name, - decodeParams: decodeParams, decodeResultJson: decodeResultJson, decodeResult: (payload) => decodeResult(payload) as T, paramsTypeName: paramsTypeName, diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index c5d076ef..6705d123 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -30,10 +30,9 @@ class WorkflowRef { } /// Creates a typed workflow reference for DTO params that already expose - /// `toJson()` and `Type.fromJson(...)`. + /// `toJson()`. factory WorkflowRef.json({ required String name, - required TParams Function(Map payload) decodeParams, TResult Function(Map payload)? decodeResultJson, TResult Function(Object? payload)? decodeResult, String? paramsTypeName, @@ -45,14 +44,11 @@ class WorkflowRef { decode: decodeResultJson, typeName: resultTypeName ?? '$TResult', ); - return WorkflowRef.codec( + return WorkflowRef( name: name, - paramsCodec: PayloadCodec.json( - decode: decodeParams, - typeName: paramsTypeName ?? '$TParams', - ), - resultCodec: resultCodec, - decodeResult: decodeResult, + encodeParams: (params) => + _encodeJsonParams(params, paramsTypeName ?? '$TParams'), + decodeResult: decodeResult ?? resultCodec?.decode, ); } @@ -94,6 +90,14 @@ class WorkflowRef { ); } + static Map _encodeJsonParams(T params, String typeName) { + final payload = PayloadCodec.encodeJsonMap( + params, + typeName: typeName, + ); + return Map.from(payload); + } + /// Builds a workflow start call from typed arguments. WorkflowStartCall call( TParams params, { @@ -404,7 +408,6 @@ extension WorkflowStartCallExtension ); }); } - } /// Convenience helpers for dispatching [WorkflowStartBuilder] instances. @@ -428,7 +431,6 @@ extension WorkflowStartBuilderExtension timeout: timeout, ); } - } /// Caller-bound fluent workflow start builder. @@ -512,7 +514,6 @@ extension WorkflowCallerBuilderExtension on WorkflowCaller { builder: definition.prepareStart(), ); } - } /// Convenience helpers for waiting on typed workflow refs using a generic diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index 603a1e07..5ddc1c59 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -100,15 +100,13 @@ class WorkflowScript { } /// Builds a typed [WorkflowRef] for DTO params that already expose - /// `toJson()` and `Type.fromJson(...)`. + /// `toJson()`. WorkflowRef refJson({ - required TParams Function(Map payload) decodeParams, T Function(Map payload)? decodeResultJson, String? paramsTypeName, String? resultTypeName, }) { return definition.refJson( - decodeParams: decodeParams, decodeResultJson: decodeResultJson, paramsTypeName: paramsTypeName, resultTypeName: resultTypeName, diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index 846c870c..b326e214 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -105,10 +105,10 @@ void main() { final backend = _RecordingBackend(); final stem = Stem(broker: broker, backend: backend); final definition = TaskDefinition<_CodecTaskArgs, Object?>.codec( - name: 'sample.codec.args', - argsCodec: _codecTaskArgsCodec, - defaultOptions: const TaskOptions(queue: 'typed'), - ); + name: 'sample.codec.args', + argsCodec: _codecTaskArgsCodec, + defaultOptions: const TaskOptions(queue: 'typed'), + ); final id = await stem.enqueueCall( definition.call(const _CodecTaskArgs('encoded')), @@ -128,7 +128,6 @@ void main() { final stem = Stem(broker: broker, backend: backend); final definition = TaskDefinition<_CodecTaskArgs, Object?>.json( name: 'sample.json.args', - decodeArgs: _CodecTaskArgs.fromJson, defaultOptions: const TaskOptions(queue: 'typed'), ); @@ -189,10 +188,10 @@ void main() { final backend = _RecordingBackend(); final stem = Stem(broker: broker, backend: backend); final definition = TaskDefinition<_CodecTaskArgs, _CodecReceipt>.codec( - name: 'sample.codec.result', - argsCodec: _codecTaskArgsCodec, - resultCodec: _codecReceiptCodec, - ); + name: 'sample.codec.result', + argsCodec: _codecTaskArgsCodec, + resultCodec: _codecReceiptCodec, + ); final id = await stem.enqueueCall( definition.call(const _CodecTaskArgs('encoded')), @@ -641,10 +640,6 @@ final _codecReceiptDefinition = class _CodecTaskArgs { const _CodecTaskArgs(this.value); - factory _CodecTaskArgs.fromJson(Map payload) { - return _CodecTaskArgs(payload['value']! as String); - } - final String value; Map toJson() => {'value': value}; diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index afe1088e..36dbba39 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -161,9 +161,7 @@ void main() { }); }, ); - final workflowRef = flow.refJson<_GreetingParams>( - decodeParams: _GreetingParams.fromJson, - ); + final workflowRef = flow.refJson<_GreetingParams>(); final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); try { @@ -194,7 +192,6 @@ void main() { }, ); final workflowRef = flow.refJson<_GreetingParams>( - decodeParams: _GreetingParams.fromJson, decodeResultJson: _GreetingResult.fromJson, ); @@ -337,43 +334,43 @@ void main() { test( 'raw workflow definitions expose direct json result helpers', () async { - final flow = WorkflowDefinition<_GreetingResult>.flowJson( - name: 'runtime.ref.definition.json.result.flow', - decodeResult: _GreetingResult.fromJson, - build: (builder) { - builder.step( - 'hello', - (ctx) async => - const _GreetingResult(message: 'hello definition flow json'), - ); - }, - ); - final script = WorkflowDefinition<_GreetingResult>.scriptJson( - name: 'runtime.ref.definition.json.result.script', - decodeResult: _GreetingResult.fromJson, - run: (context) async => - const _GreetingResult(message: 'hello definition script json'), - ); - - final workflowApp = await StemWorkflowApp.inMemory(); - try { - workflowApp.registerWorkflows([flow, script]); - await workflowApp.start(); - - final flowResult = await flow.ref0().startAndWait( - workflowApp.runtime, - timeout: const Duration(seconds: 2), + final flow = WorkflowDefinition<_GreetingResult>.flowJson( + name: 'runtime.ref.definition.json.result.flow', + decodeResult: _GreetingResult.fromJson, + build: (builder) { + builder.step( + 'hello', + (ctx) async => + const _GreetingResult(message: 'hello definition flow json'), + ); + }, ); - final scriptResult = await script.ref0().startAndWait( - workflowApp.runtime, - timeout: const Duration(seconds: 2), + final script = WorkflowDefinition<_GreetingResult>.scriptJson( + name: 'runtime.ref.definition.json.result.script', + decodeResult: _GreetingResult.fromJson, + run: (context) async => + const _GreetingResult(message: 'hello definition script json'), ); - expect(flowResult?.value?.message, 'hello definition flow json'); - expect(scriptResult?.value?.message, 'hello definition script json'); - } finally { - await workflowApp.shutdown(); - } + final workflowApp = await StemWorkflowApp.inMemory(); + try { + workflowApp.registerWorkflows([flow, script]); + await workflowApp.start(); + + final flowResult = await flow.ref0().startAndWait( + workflowApp.runtime, + timeout: const Duration(seconds: 2), + ); + final scriptResult = await script.ref0().startAndWait( + workflowApp.runtime, + timeout: const Duration(seconds: 2), + ); + + expect(flowResult?.value?.message, 'hello definition flow json'); + expect(scriptResult?.value?.message, 'hello definition script json'); + } finally { + await workflowApp.shutdown(); + } }, ); @@ -590,9 +587,9 @@ void main() { 'typed workflow event calls emit from the prebuilt call surface', () async { final flow = Flow( - name: 'runtime.ref.event.call.flow', - build: (builder) { - builder.step('wait', (ctx) async { + name: 'runtime.ref.event.call.flow', + build: (builder) { + builder.step('wait', (ctx) async { final payload = await _userUpdatedEvent.wait(ctx); return 'hello ${payload.name}'; }); @@ -626,12 +623,12 @@ void main() { test('workflow event emitters expose bound event calls', () async { final flow = Flow( name: 'runtime.ref.event.bound.flow', - build: (builder) { - builder.step('wait', (ctx) async { - final payload = _userUpdatedEvent.waitValue(ctx); - if (payload == null) { - return null; - } + build: (builder) { + builder.step('wait', (ctx) async { + final payload = _userUpdatedEvent.waitValue(ctx); + if (payload == null) { + return null; + } return 'hello ${payload.name}'; }); }, From afc04797dbcb53bb84ae5aec50bf75793d2dd0da Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 07:35:37 -0500 Subject: [PATCH 179/302] Add shared workflow execution context --- .site/docs/core-concepts/stem-builder.md | 2 +- .site/docs/workflows/annotated-workflows.md | 7 +- .../workflows/context-and-serialization.md | 14 ++-- packages/stem/CHANGELOG.md | 3 + packages/stem/README.md | 18 ++--- .../example/annotated_workflows/README.md | 6 +- .../annotated_workflows/lib/definitions.dart | 6 +- .../lib/definitions.stem.g.dart | 2 +- .../lib/src/workflow/core/flow_context.dart | 15 +++- .../core/workflow_execution_context.dart | 41 ++++++++++ .../core/workflow_script_context.dart | 18 ++++- packages/stem/lib/src/workflow/workflow.dart | 1 + .../unit/workflow/workflow_resume_test.dart | 5 +- packages/stem_builder/README.md | 12 +-- .../lib/src/stem_registry_builder.dart | 64 ++++++++++++--- .../test/stem_registry_builder_test.dart | 81 ++++++++++++++++++- 16 files changed, 241 insertions(+), 54 deletions(-) create mode 100644 packages/stem/lib/src/workflow/core/workflow_execution_context.dart diff --git a/.site/docs/core-concepts/stem-builder.md b/.site/docs/core-concepts/stem-builder.md index 02c77aee..86da9c0f 100644 --- a/.site/docs/core-concepts/stem-builder.md +++ b/.site/docs/core-concepts/stem-builder.md @@ -220,7 +220,7 @@ app is creating the worker itself. - When you need runtime metadata, add an optional named injected context parameter: - `WorkflowScriptContext? context` on `run(...)` - - `WorkflowScriptStepContext? context` on the checkpoint method + - `WorkflowExecutionContext? context` on flow steps or checkpoint methods - DTO classes are supported when they provide: - a string-keyed `toJson()` map (typically `Map`) - `factory Type.fromJson(Map json)` or an equivalent named diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index 1763760b..167adbfa 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -131,9 +131,9 @@ This keeps one authoring model: When a workflow needs to start another workflow, do it from a durable boundary: -- `FlowContext` and `WorkflowScriptStepContext` both implement - `WorkflowCaller`, so prefer `ref.startAndWait(context, params: value)` inside - flow steps and checkpoint methods +- `WorkflowExecutionContext` implements `WorkflowCaller`, so prefer + `ref.startAndWait(context, params: value)` inside flow steps and checkpoint + methods - pass `ttl:`, `parentRunId:`, or `cancellationPolicy:` directly to `ref.start(...)` / `ref.startAndWait(...)` for the normal override cases - keep `context.prepareStart(...)` for the rarer incremental-call @@ -147,6 +147,7 @@ Use `packages/stem/example/annotated_workflows` when you want a verified example that demonstrates: - `FlowContext` +- `WorkflowExecutionContext` - direct-call script checkpoints - nested annotated checkpoint calls - `WorkflowScriptContext` diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index 5e68e2c3..a004fb81 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -7,9 +7,10 @@ Everything else that crosses a durable boundary must be serializable. ## Supported context injection points -- flow steps: `FlowContext` +- flow steps: `FlowContext` or `WorkflowExecutionContext` - script runs: `WorkflowScriptContext` -- script checkpoints: `WorkflowScriptStepContext` +- script checkpoints: `WorkflowScriptStepContext` or + `WorkflowExecutionContext` - tasks: `TaskInvocationContext` Those context objects are not part of the persisted payload shape. They are @@ -19,8 +20,8 @@ For annotated workflows/tasks, the preferred shape is an optional named context parameter: - `Future run(String email, {WorkflowScriptContext? context})` -- `Future checkpoint(String email, {WorkflowScriptStepContext? context})` -- `Future step({FlowContext? context})` +- `Future checkpoint(String email, {WorkflowExecutionContext? context})` +- `Future step({WorkflowExecutionContext? context})` - `Future task(String id, {TaskInvocationContext? context})` ## What context gives you @@ -43,9 +44,8 @@ Depending on the context type, you can access: - direct child-workflow start helpers such as `ref.start(context, params: value)` and `ref.startAndWait(context, params: value)` -- direct task enqueue APIs because `FlowContext`, - `WorkflowScriptStepContext`, and `TaskInvocationContext` all implement - `TaskEnqueuer` +- direct task enqueue APIs because `WorkflowExecutionContext` and + `TaskInvocationContext` both implement `TaskEnqueuer` - task metadata like `id`, `attempt`, `meta` Child workflow starts belong in durable boundaries: diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 2eb9e8da..74029b1f 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Added `WorkflowExecutionContext` as the shared typed execution context for + flow steps and script checkpoints, and taught `stem_builder` to accept that + shared context type directly in annotated workflow methods. - Simplified the manual JSON helper path so `TaskDefinition.json(...)` and `WorkflowRef.json(...)` no longer require unused producer-side `decodeArgs`/`decodeParams` callbacks just to publish DTO payloads. diff --git a/packages/stem/README.md b/packages/stem/README.md index e5826be8..01229034 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -626,27 +626,27 @@ Script workflows use one authoring model: - start with a plain `run(String email, ...)` method - add an optional named injected context when you need runtime metadata: - `Future run(String email, {WorkflowScriptContext? context})` - - `Future capture(String email, {WorkflowScriptStepContext? context})` + - `Future capture(String email, {WorkflowExecutionContext? context})` - direct checkpoint method calls still stay the default happy path Context injection works at every runtime layer: -- flow steps can take `FlowContext` +- flow steps can take `FlowContext` or `WorkflowExecutionContext` - script runs can take `WorkflowScriptContext` -- script checkpoints can take `WorkflowScriptStepContext` +- script checkpoints can take `WorkflowScriptStepContext` or + `WorkflowExecutionContext` - tasks can take `TaskInvocationContext` -Durable workflow contexts enqueue tasks directly: +Durable workflow execution contexts enqueue tasks directly: -- `FlowContext.enqueue(...)` -- `WorkflowScriptStepContext.enqueue(...)` +- `WorkflowExecutionContext.enqueue(...)` - typed task definitions can target those contexts via `enqueue(...)` Child workflows belong in durable execution boundaries: -- `FlowContext` and `WorkflowScriptStepContext` both implement - `WorkflowCaller`, so prefer `ref.startAndWait(context, params: value)` inside - flow steps and script checkpoints +- `WorkflowExecutionContext` implements `WorkflowCaller`, so prefer + `ref.startAndWait(context, params: value)` inside flow steps and script + checkpoints - pass `ttl:`, `parentRunId:`, or `cancellationPolicy:` directly to `ref.start(...)` / `ref.startAndWait(...)` for normal override cases - keep `context.prepareStart(...)` for the rarer incremental-call diff --git a/packages/stem/example/annotated_workflows/README.md b/packages/stem/example/annotated_workflows/README.md index d2a0f6a6..e3649a16 100644 --- a/packages/stem/example/annotated_workflows/README.md +++ b/packages/stem/example/annotated_workflows/README.md @@ -4,7 +4,7 @@ This example shows how to use `@WorkflowDefn`, `@WorkflowStep`, and `@TaskDefn` with the `stem_builder` bundle generator. It now demonstrates the generated script-proxy behavior explicitly: -- a flow step using `FlowContext` +- a flow step using `WorkflowExecutionContext` - a flow step starting and waiting on a child workflow through `StemWorkflowDefinitions.*.startAndWait(context, params: value)` - `run(WelcomeRequest request)` calls annotated checkpoint methods directly @@ -12,7 +12,7 @@ It now demonstrates the generated script-proxy behavior explicitly: - `deliverWelcome(...)` calls another annotated checkpoint from inside an checkpoint - a second script workflow uses optional named context injection - (`WorkflowScriptContext? context` / `WorkflowScriptStepContext? context`) to + (`WorkflowScriptContext? context` / `WorkflowExecutionContext? context`) to expose `runId`, `workflow`, `stepName`, `stepIndex`, and idempotency keys while still calling its annotated checkpoint directly from `run(...)` - a script checkpoint starting and waiting on a child workflow through @@ -23,7 +23,7 @@ It now demonstrates the generated script-proxy behavior explicitly: plus codec-backed DTO input/output types When you run the example, it prints: -- the flow result with `FlowContext` metadata +- the flow result with `WorkflowExecutionContext` metadata - the flow child workflow result without a separate `waitFor(...)` call - the plain script result - the persisted checkpoint order for the plain script workflow diff --git a/packages/stem/example/annotated_workflows/lib/definitions.dart b/packages/stem/example/annotated_workflows/lib/definitions.dart index 0a6a8fc6..34713f65 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.dart @@ -189,7 +189,9 @@ class ContextCaptureResult { @WorkflowDefn(name: 'annotated.flow') class AnnotatedFlowWorkflow { @WorkflowStep() - Future?> start({FlowContext? context}) async { + Future?> start({ + WorkflowExecutionContext? context, + }) async { final ctx = context!; if (!ctx.sleepUntilResumed(const Duration(milliseconds: 50))) { return null; @@ -269,7 +271,7 @@ class AnnotatedContextScriptWorkflow { @WorkflowStep(name: 'capture-context') Future captureContext( WelcomeRequest request, { - WorkflowScriptStepContext? context, + WorkflowExecutionContext? context, }) async { final ctx = context!; final normalizedEmail = await normalizeEmail(request.email); diff --git a/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart b/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart index cb5e3206..94082532 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart @@ -101,7 +101,7 @@ class _StemScriptProxy1 extends AnnotatedContextScriptWorkflow { @override Future captureContext( WelcomeRequest request, { - WorkflowScriptStepContext? context, + WorkflowExecutionContext? context, }) { return _script.step( "capture-context", diff --git a/packages/stem/lib/src/workflow/core/flow_context.dart b/packages/stem/lib/src/workflow/core/flow_context.dart index 89c211d5..b647da73 100644 --- a/packages/stem/lib/src/workflow/core/flow_context.dart +++ b/packages/stem/lib/src/workflow/core/flow_context.dart @@ -2,9 +2,9 @@ import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/workflow/core/flow_step.dart'; import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; import 'package:stem/src/workflow/core/workflow_clock.dart'; +import 'package:stem/src/workflow/core/workflow_execution_context.dart'; import 'package:stem/src/workflow/core/workflow_ref.dart'; import 'package:stem/src/workflow/core/workflow_result.dart'; -import 'package:stem/src/workflow/core/workflow_resume_context.dart'; /// Context provided to each workflow step invocation. /// @@ -17,8 +17,7 @@ import 'package:stem/src/workflow/core/workflow_resume_context.dart'; /// [iteration] indicates how many times the step has already completed when /// `autoVersion` is enabled, allowing handlers to branch per loop iteration or /// derive unique identifiers. -class FlowContext - implements TaskEnqueuer, WorkflowCaller, WorkflowResumeContext { +class FlowContext implements WorkflowExecutionContext { /// Creates a workflow step context. FlowContext({ required this.workflow, @@ -36,30 +35,39 @@ class FlowContext _resumeData = resumeData; /// Name of the workflow. + @override final String workflow; /// Identifier of the workflow run. + @override final String runId; /// Name of the current step. + @override final String stepName; /// Parameters passed when the workflow was started. + @override final Map params; /// Result of the previous step, if any. + @override final Object? previousResult; /// Zero-based index of the current step. + @override final int stepIndex; /// Current iteration when auto-versioning is enabled. + @override final int iteration; /// Optional enqueuer for scheduling tasks with workflow metadata. + @override final TaskEnqueuer? enqueuer; /// Optional typed workflow caller for spawning child workflows. + @override final WorkflowCaller? workflows; final WorkflowClock _clock; @@ -163,6 +171,7 @@ class FlowContext /// Returns a stable idempotency key derived from the workflow, run, and /// [scope]. Defaults to the current [stepName] (including iteration suffix /// when [iteration] > 0) when no scope is provided. + @override String idempotencyKey([String? scope]) { final defaultScope = iteration > 0 ? '$stepName#$iteration' : stepName; final effectiveScope = (scope == null || scope.isEmpty) diff --git a/packages/stem/lib/src/workflow/core/workflow_execution_context.dart b/packages/stem/lib/src/workflow/core/workflow_execution_context.dart new file mode 100644 index 00000000..ea382800 --- /dev/null +++ b/packages/stem/lib/src/workflow/core/workflow_execution_context.dart @@ -0,0 +1,41 @@ +import 'package:stem/src/core/contracts.dart'; +import 'package:stem/src/workflow/core/workflow_ref.dart'; +import 'package:stem/src/workflow/core/workflow_resume_context.dart'; + +/// Shared execution context surface for flow steps and script checkpoints. +/// +/// This keeps the common workflow-authoring capabilities on one type: +/// metadata about the current step/checkpoint, task enqueueing, child-workflow +/// starts, and durable suspension helpers. +abstract interface class WorkflowExecutionContext + implements TaskEnqueuer, WorkflowCaller, WorkflowResumeContext { + /// Name of the workflow currently executing. + String get workflow; + + /// Identifier for the workflow run. + String get runId; + + /// Name of the current step or checkpoint. + String get stepName; + + /// Zero-based step or checkpoint index. + int get stepIndex; + + /// Iteration count for looped steps or checkpoints. + int get iteration; + + /// Parameters provided when the workflow started. + Map get params; + + /// Result of the previous step or checkpoint, if any. + Object? get previousResult; + + /// Returns a stable idempotency key derived from workflow/run/step state. + String idempotencyKey([String? scope]); + + /// Optional enqueuer for scheduling tasks with workflow metadata. + TaskEnqueuer? get enqueuer; + + /// Optional typed workflow caller for spawning child workflows. + WorkflowCaller? get workflows; +} diff --git a/packages/stem/lib/src/workflow/core/workflow_script_context.dart b/packages/stem/lib/src/workflow/core/workflow_script_context.dart index def9f4f3..418e6778 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script_context.dart @@ -3,14 +3,15 @@ import 'dart:async'; import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/workflow/core/flow_context.dart' show FlowContext; import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; +import 'package:stem/src/workflow/core/workflow_execution_context.dart'; import 'package:stem/src/workflow/core/workflow_ref.dart'; import 'package:stem/src/workflow/core/workflow_result.dart'; -import 'package:stem/src/workflow/core/workflow_resume_context.dart'; /// Runtime context exposed to workflow scripts. Implementations are provided by /// the workflow runtime so scripts can execute with durable semantics. abstract class WorkflowScriptContext { /// Name of the workflow currently executing. + @override String get workflow; /// Identifier for the run. Useful when emitting logs or constructing @@ -32,27 +33,32 @@ abstract class WorkflowScriptContext { /// Context provided to each script checkpoint invocation. Mirrors /// [FlowContext] but tailored for the facade helpers. -abstract class WorkflowScriptStepContext - implements TaskEnqueuer, WorkflowCaller, WorkflowResumeContext { +abstract class WorkflowScriptStepContext implements WorkflowExecutionContext { /// Name of the workflow currently executing. String get workflow; /// Identifier for the workflow run. + @override String get runId; /// Name of the current checkpoint. + @override String get stepName; /// Zero-based checkpoint index in the workflow definition. + @override int get stepIndex; /// Iteration count for looped checkpoints. + @override int get iteration; /// Parameters provided when the workflow started. + @override Map get params; /// Result of the previous checkpoint, if any. + @override Object? get previousResult; /// Schedules a wake-up after [duration]. The workflow suspends once the @@ -89,12 +95,15 @@ abstract class WorkflowScriptStepContext } /// Returns a stable idempotency key derived from workflow/run/checkpoint. + @override String idempotencyKey([String? scope]); /// Optional enqueuer for scheduling tasks with workflow metadata. + @override TaskEnqueuer? get enqueuer; /// Optional typed workflow caller for spawning child workflows. + @override WorkflowCaller? get workflows; @override @@ -114,6 +123,7 @@ abstract class WorkflowScriptStepContext TaskEnqueueOptions? enqueueOptions, }); + /// Starts a typed child workflow using this checkpoint context. @override Future startWorkflowRef( WorkflowRef definition, @@ -123,11 +133,13 @@ abstract class WorkflowScriptStepContext WorkflowCancellationPolicy? cancellationPolicy, }); + /// Starts a prebuilt child workflow call using this checkpoint context. @override Future startWorkflowCall( WorkflowStartCall call, ); + /// Waits for a typed child workflow using this checkpoint context. @override Future?> waitForWorkflowRef( diff --git a/packages/stem/lib/src/workflow/workflow.dart b/packages/stem/lib/src/workflow/workflow.dart index cdedaa1d..b1d1931a 100644 --- a/packages/stem/lib/src/workflow/workflow.dart +++ b/packages/stem/lib/src/workflow/workflow.dart @@ -11,6 +11,7 @@ export 'core/workflow_checkpoint.dart'; export 'core/workflow_clock.dart'; export 'core/workflow_definition.dart'; export 'core/workflow_event_ref.dart'; +export 'core/workflow_execution_context.dart'; export 'core/workflow_ref.dart'; export 'core/workflow_result.dart'; export 'core/workflow_resume.dart'; diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart index d61a7aab..de863556 100644 --- a/packages/stem/test/unit/workflow/workflow_resume_test.dart +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -4,6 +4,7 @@ import 'package:stem/src/workflow/core/flow_context.dart'; import 'package:stem/src/workflow/core/flow_step.dart'; import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; import 'package:stem/src/workflow/core/workflow_event_ref.dart'; +import 'package:stem/src/workflow/core/workflow_execution_context.dart'; import 'package:stem/src/workflow/core/workflow_ref.dart'; import 'package:stem/src/workflow/core/workflow_result.dart'; import 'package:stem/src/workflow/core/workflow_resume.dart'; @@ -523,7 +524,7 @@ void main() { }, ); - test('flow and script step contexts share the resume-context surface', () { + test('flow and script step contexts share the execution-context surface', () { final flowContext = FlowContext( workflow: 'demo', runId: 'run-1', @@ -536,6 +537,8 @@ void main() { expect(flowContext, isA()); expect(scriptContext, isA()); + expect(flowContext, isA()); + expect(scriptContext, isA()); }); test( diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index 7050b1b9..d6940017 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -93,20 +93,20 @@ Script workflows use one entry model: - start with a plain direct-call `run(String email, ...)` - add an optional named injected context when you need runtime metadata - `Future run(String email, {WorkflowScriptContext? context})` - - `Future checkpoint(String email, {WorkflowScriptStepContext? context})` + - `Future checkpoint(String email, {WorkflowExecutionContext? context})` - direct annotated checkpoint calls stay the default path Supported context injection points: -- flow steps: `FlowContext` +- flow steps: `FlowContext` or `WorkflowExecutionContext` - script runs: `WorkflowScriptContext` -- script checkpoints: `WorkflowScriptStepContext` +- script checkpoints: `WorkflowScriptStepContext` or + `WorkflowExecutionContext` - tasks: `TaskInvocationContext` -Durable workflow contexts enqueue tasks directly: +Durable workflow execution contexts enqueue tasks directly: -- `FlowContext.enqueue(...)` -- `WorkflowScriptStepContext.enqueue(...)` +- `WorkflowExecutionContext.enqueue(...)` - typed task definitions can target those contexts via `enqueue(...)` Child workflows should be started from durable boundaries: diff --git a/packages/stem_builder/lib/src/stem_registry_builder.dart b/packages/stem_builder/lib/src/stem_registry_builder.dart index 12464ab4..e518eb1f 100644 --- a/packages/stem_builder/lib/src/stem_registry_builder.dart +++ b/packages/stem_builder/lib/src/stem_registry_builder.dart @@ -44,6 +44,10 @@ class StemRegistryBuilder implements Builder { FlowContext, inPackage: 'stem', ); + const workflowExecutionContextChecker = TypeChecker.typeNamed( + WorkflowExecutionContext, + inPackage: 'stem', + ); const scriptContextChecker = TypeChecker.typeNamed( WorkflowScriptContext, inPackage: 'stem', @@ -177,6 +181,7 @@ class StemRegistryBuilder implements Builder { final stepBinding = _validateScriptStepMethod( method, scriptStepContextChecker, + workflowExecutionContextChecker, ); final stepAnnotation = workflowStepChecker.firstAnnotationOfExact( method, @@ -202,8 +207,10 @@ class StemRegistryBuilder implements Builder { method: method.displayName, flowContextParameterName: null, flowContextIsNamed: false, + flowContextTypeCode: null, scriptStepContextParameterName: stepBinding.contextParameterName, scriptStepContextIsNamed: stepBinding.contextIsNamed, + scriptStepContextTypeCode: stepBinding.contextTypeCode, valueParameters: stepBinding.valueParameters, returnTypeCode: stepBinding.returnTypeCode, stepValueTypeCode: stepBinding.stepValueTypeCode, @@ -268,6 +275,7 @@ class StemRegistryBuilder implements Builder { final stepBinding = _validateFlowStepMethod( method, flowContextChecker, + workflowExecutionContextChecker, ); final stepAnnotation = workflowStepChecker.firstAnnotationOfExact( method, @@ -293,8 +301,10 @@ class StemRegistryBuilder implements Builder { method: method.displayName, flowContextParameterName: stepBinding.contextParameterName, flowContextIsNamed: stepBinding.contextIsNamed, + flowContextTypeCode: stepBinding.contextTypeCode, scriptStepContextParameterName: null, scriptStepContextIsNamed: false, + scriptStepContextTypeCode: null, valueParameters: stepBinding.valueParameters, returnTypeCode: null, stepValueTypeCode: stepBinding.stepValueTypeCode, @@ -442,7 +452,7 @@ class StemRegistryBuilder implements Builder { final parameters = method.formalParameters; final contextParameter = _extractInjectedContextParameter( parameters, - scriptContextChecker, + [scriptContextChecker], method, annotationLabel: '@workflow.run method', contextTypeLabel: 'WorkflowScriptContext', @@ -483,6 +493,7 @@ class StemRegistryBuilder implements Builder { static _FlowStepBinding _validateFlowStepMethod( MethodElement method, TypeChecker flowContextChecker, + TypeChecker workflowExecutionContextChecker, ) { if (method.isPrivate) { throw InvalidGenerationSourceError( @@ -493,10 +504,10 @@ class StemRegistryBuilder implements Builder { final parameters = method.formalParameters; final contextParameter = _extractInjectedContextParameter( parameters, - flowContextChecker, + [flowContextChecker, workflowExecutionContextChecker], method, annotationLabel: '@workflow.step method', - contextTypeLabel: 'FlowContext', + contextTypeLabel: 'FlowContext or WorkflowExecutionContext', ); final valueParameters = <_ValueParameterInfo>[]; @@ -506,7 +517,7 @@ class StemRegistryBuilder implements Builder { } if (!parameter.isRequiredPositional) { throw InvalidGenerationSourceError( - '@workflow.step method ${method.displayName} only supports required positional serializable or codec-backed parameters after FlowContext.', + '@workflow.step method ${method.displayName} only supports required positional serializable or codec-backed parameters after FlowContext or WorkflowExecutionContext.', element: method, ); } @@ -523,6 +534,7 @@ class StemRegistryBuilder implements Builder { return _FlowStepBinding( contextParameterName: contextParameter?.name, contextIsNamed: contextParameter?.isNamed ?? false, + contextTypeCode: contextParameter?.typeCode, valueParameters: valueParameters, stepValueTypeCode: _workflowResultTypeCode(method.returnType), stepValuePayloadCodecTypeCode: _workflowResultPayloadCodecTypeCode( @@ -534,6 +546,7 @@ class StemRegistryBuilder implements Builder { static _ScriptStepBinding _validateScriptStepMethod( MethodElement method, TypeChecker scriptStepContextChecker, + TypeChecker workflowExecutionContextChecker, ) { if (method.isPrivate) { throw InvalidGenerationSourceError( @@ -555,10 +568,10 @@ class StemRegistryBuilder implements Builder { final parameters = method.formalParameters; final contextParameter = _extractInjectedContextParameter( parameters, - scriptStepContextChecker, + [scriptStepContextChecker, workflowExecutionContextChecker], method, annotationLabel: '@workflow.step method', - contextTypeLabel: 'WorkflowScriptStepContext', + contextTypeLabel: 'WorkflowScriptStepContext or WorkflowExecutionContext', ); final valueParameters = <_ValueParameterInfo>[]; @@ -568,7 +581,7 @@ class StemRegistryBuilder implements Builder { } if (!parameter.isRequiredPositional) { throw InvalidGenerationSourceError( - '@workflow.step method ${method.displayName} only supports required positional serializable or codec-backed parameters after WorkflowScriptStepContext.', + '@workflow.step method ${method.displayName} only supports required positional serializable or codec-backed parameters after WorkflowScriptStepContext or WorkflowExecutionContext.', element: method, ); } @@ -585,6 +598,7 @@ class StemRegistryBuilder implements Builder { return _ScriptStepBinding( contextParameterName: contextParameter?.name, contextIsNamed: contextParameter?.isNamed ?? false, + contextTypeCode: contextParameter?.typeCode, valueParameters: valueParameters, returnTypeCode: _typeCode(returnType), stepValueTypeCode: _typeCode(stepValueType), @@ -600,7 +614,7 @@ class StemRegistryBuilder implements Builder { final parameters = function.formalParameters; final contextParameter = _extractInjectedContextParameter( parameters, - taskContextChecker, + [taskContextChecker], function, annotationLabel: '@TaskDefn function', contextTypeLabel: 'TaskInvocationContext', @@ -677,7 +691,7 @@ class StemRegistryBuilder implements Builder { static _InjectedContextParameter? _extractInjectedContextParameter( List parameters, - TypeChecker checker, + List checkers, Element element, { required String annotationLabel, required String contextTypeLabel, @@ -685,18 +699,19 @@ class StemRegistryBuilder implements Builder { _InjectedContextParameter? contextParameter; if (parameters.isNotEmpty && parameters.first.isRequiredPositional && - checker.isAssignableFromType(parameters.first.type)) { + _matchesAnyContextType(checkers, parameters.first.type)) { contextParameter = _InjectedContextParameter( parameter: parameters.first, name: parameters.first.displayName, isNamed: false, + typeCode: _typeCode(parameters.first.type), ); } for (final parameter in parameters.skip( contextParameter == null ? 0 : 1, )) { - if (!checker.isAssignableFromType(parameter.type)) { + if (!_matchesAnyContextType(checkers, parameter.type)) { continue; } if (contextParameter != null) { @@ -718,12 +733,25 @@ class StemRegistryBuilder implements Builder { parameter: parameter, name: parameter.displayName, isNamed: true, + typeCode: _typeCode(parameter.type), ); } return contextParameter; } + static bool _matchesAnyContextType( + List checkers, + DartType type, + ) { + for (final checker in checkers) { + if (checker.isAssignableFromType(type)) { + return true; + } + } + return false; + } + static String _taskResultTypeCode(DartType returnType) { final valueType = _extractAsyncValueType(returnType); if (valueType is VoidType || valueType is NeverType) { @@ -946,8 +974,10 @@ class _WorkflowStepInfo { required this.method, required this.flowContextParameterName, required this.flowContextIsNamed, + required this.flowContextTypeCode, required this.scriptStepContextParameterName, required this.scriptStepContextIsNamed, + required this.scriptStepContextTypeCode, required this.valueParameters, required this.returnTypeCode, required this.stepValueTypeCode, @@ -963,8 +993,10 @@ class _WorkflowStepInfo { final String method; final String? flowContextParameterName; final bool flowContextIsNamed; + final String? flowContextTypeCode; final String? scriptStepContextParameterName; final bool scriptStepContextIsNamed; + final String? scriptStepContextTypeCode; final List<_ValueParameterInfo> valueParameters; final String? returnTypeCode; final String? stepValueTypeCode; @@ -1166,6 +1198,7 @@ class _FlowStepBinding { const _FlowStepBinding({ required this.contextParameterName, required this.contextIsNamed, + required this.contextTypeCode, required this.valueParameters, required this.stepValueTypeCode, required this.stepValuePayloadCodecTypeCode, @@ -1173,6 +1206,7 @@ class _FlowStepBinding { final String? contextParameterName; final bool contextIsNamed; + final String? contextTypeCode; final List<_ValueParameterInfo> valueParameters; final String stepValueTypeCode; final String? stepValuePayloadCodecTypeCode; @@ -1198,6 +1232,7 @@ class _ScriptStepBinding { const _ScriptStepBinding({ required this.contextParameterName, required this.contextIsNamed, + required this.contextTypeCode, required this.valueParameters, required this.returnTypeCode, required this.stepValueTypeCode, @@ -1206,6 +1241,7 @@ class _ScriptStepBinding { final String? contextParameterName; final bool contextIsNamed; + final String? contextTypeCode; final List<_ValueParameterInfo> valueParameters; final String returnTypeCode; final String stepValueTypeCode; @@ -1247,11 +1283,13 @@ class _InjectedContextParameter { required this.parameter, required this.name, required this.isNamed, + required this.typeCode, }); final FormalParameterElement parameter; final String name; final bool isNamed; + final String typeCode; } class _RegistryEmitter { @@ -1467,14 +1505,14 @@ class _RegistryEmitter { final signature = _methodSignature( positional: [ if (step.acceptsScriptStepContext && !step.scriptStepContextIsNamed) - 'WorkflowScriptStepContext ${step.scriptStepContextParameterName!}', + '${step.scriptStepContextTypeCode!} ${step.scriptStepContextParameterName!}', ...step.valueParameters.map( (parameter) => '${parameter.typeCode} ${parameter.name}', ), ], named: [ if (step.acceptsScriptStepContext && step.scriptStepContextIsNamed) - 'WorkflowScriptStepContext? ${step.scriptStepContextParameterName!}', + '${step.scriptStepContextTypeCode!} ${step.scriptStepContextParameterName!}', ], ); final invocationArgs = _invocationArgs( diff --git a/packages/stem_builder/test/stem_registry_builder_test.dart b/packages/stem_builder/test/stem_registry_builder_test.dart index bc687e92..74d17a96 100644 --- a/packages/stem_builder/test/stem_registry_builder_test.dart +++ b/packages/stem_builder/test/stem_registry_builder_test.dart @@ -6,7 +6,8 @@ import 'package:test/test.dart'; const stubStem = ''' library stem; -class FlowContext {} +class WorkflowExecutionContext {} +class FlowContext implements WorkflowExecutionContext {} typedef _FlowStepHandler = Future Function(FlowContext context); enum WorkflowStepKind { task, choice, parallel, wait, custom } @@ -77,7 +78,7 @@ class WorkflowScriptContext { bool autoVersion = false, }) async => throw UnimplementedError(); } -class WorkflowScriptStepContext {} +class WorkflowScriptStepContext implements WorkflowExecutionContext {} class TaskInvocationContext {} class NoArgsTaskDefinition { @@ -995,6 +996,49 @@ class SignupWorkflow { }, ); + test( + 'supports optional named WorkflowExecutionContext injection in script checkpoints', + () async { + const input = ''' +import 'package:stem/stem.dart'; + +part 'workflows.stem.g.dart'; + +@WorkflowDefn(kind: WorkflowKind.script) +class SignupWorkflow { + Future run(String email) async => sendWelcomeEmail(email); + + @WorkflowStep() + Future sendWelcomeEmail( + String email, { + WorkflowExecutionContext? context, + }) async => email; +} +'''; + + await testBuilder( + stemRegistryBuilder(BuilderOptions.empty), + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + outputs: { + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( + allOf([ + contains( + '(context) => super.sendWelcomeEmail(email, context: context)', + ), + contains('WorkflowExecutionContext? context'), + ]), + ), + }, + ); + }, + ); + test('supports optional named FlowContext injection', () async { const input = ''' import 'package:stem/stem.dart'; @@ -1025,6 +1069,39 @@ class HelloWorkflow { ); }); + test( + 'supports optional named WorkflowExecutionContext injection in flow steps', + () async { + const input = ''' +import 'package:stem/stem.dart'; + +part 'workflows.stem.g.dart'; + +@WorkflowDefn(name: 'hello.flow') +class HelloWorkflow { + @WorkflowStep(name: 'step-1') + Future stepOne({WorkflowExecutionContext? context}) async => 'ok'; +} +'''; + + await testBuilder( + stemRegistryBuilder(BuilderOptions.empty), + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + outputs: { + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( + contains('(ctx) => impl.stepOne(context: ctx)'), + ), + }, + ); + }, + ); + test('supports optional named TaskInvocationContext injection', () async { const input = ''' import 'package:stem/stem.dart'; From 004848c3dd912395ae068378e09fda22af985619 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 07:40:37 -0500 Subject: [PATCH 180/302] Add typed payload map readers --- .site/docs/core-concepts/tasks.md | 8 +++ .../workflows/context-and-serialization.md | 11 ++++ packages/stem/CHANGELOG.md | 4 ++ packages/stem/README.md | 24 +++++-- .../stem/example/docs_snippets/lib/tasks.dart | 4 +- .../example/docs_snippets/lib/workflows.dart | 16 ++--- packages/stem/example/durable_watchers.dart | 10 +-- packages/stem/lib/src/core/payload_map.dart | 30 +++++++++ packages/stem/lib/stem.dart | 4 +- .../stem/test/unit/core/payload_map_test.dart | 64 +++++++++++++++++++ 10 files changed, 153 insertions(+), 22 deletions(-) create mode 100644 packages/stem/lib/src/core/payload_map.dart create mode 100644 packages/stem/test/unit/core/payload_map_test.dart diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 2c54bb69..36069f8c 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -59,6 +59,14 @@ need a custom (typically `Map`) because they are published as JSON-shaped data. +For manual handlers, prefer the typed payload readers on the argument map +instead of repeating raw casts: + +```dart +final customerId = args.requiredValue('customerId'); +final tenant = args.valueOr('tenant', 'global'); +``` + `TaskEnqueueBuilder` also supports `enqueueAndWait(...)`, and typed task definitions can now create a fluent builder directly through `definition.prepareEnqueue(...)`. `TaskEnqueuer.prepareEnqueue(...)` remains diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index a004fb81..60c3d71d 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -115,6 +115,17 @@ For normal DTOs that expose `toJson()` and `Type.fromJson(...)`, prefer `PayloadCodec.json(...)`. Drop down to `PayloadCodec.map(...)` when you need a custom map encoder or a nonstandard decode function. +For manual flows and scripts, prefer the typed payload readers on +`ctx.params` before dropping to raw map casts: + +```dart +final userId = ctx.params.requiredValue('userId'); +final draft = ctx.params.requiredValue( + 'draft', + codec: approvalDraftCodec, +); +``` + ## Practical rule When you need context metadata, add the appropriate optional named context diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 74029b1f..fb0d3725 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.1 +- Added typed payload-map readers like `args.requiredValue(...)`, + `args.valueOr(...)`, and `ctx.params.requiredValue(...)` so manual tasks and + workflows can decode scalars and codec-backed DTOs without repeating raw map + casts. - Added `WorkflowExecutionContext` as the shared typed execution context for flow steps and script checkpoints, and taught `stem_builder` to accept that shared context type directly in annotated workflow methods. diff --git a/packages/stem/README.md b/packages/stem/README.md index 01229034..4eb0efb1 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -130,7 +130,7 @@ class HelloTask implements TaskHandler { @override Future call(TaskContext context, Map args) async { - final who = args['name'] as String? ?? 'world'; + final who = args.valueOr('name', 'world'); print('Hello $who (attempt ${context.attempt})'); } } @@ -176,7 +176,7 @@ class HelloTask implements TaskHandler { @override Future call(TaskContext context, Map args) async { - final who = args['name'] as String? ?? 'world'; + final who = args.valueOr('name', 'world'); print('Hello $who (attempt ${context.attempt})'); } } @@ -225,6 +225,18 @@ need a custom (typically `Map`) because they are published as JSON-shaped data. +For manual handlers and workflows, use the typed payload readers on the map +itself instead of repeating raw casts: + +```dart +final customerId = args.requiredValue('customerId'); +final tenant = args.valueOr('tenant', 'global'); +final draft = ctx.params.requiredValue( + 'draft', + codec: approvalDraftCodec, +); +``` + For typed task calls, the definition and call objects now expose the common producer operations directly. Prefer `enqueueAndWait(...)` when you only need the final typed result: @@ -391,7 +403,9 @@ final app = await StemWorkflowApp.inMemory( name: 'orders.workflow', run: (script) async { final checkout = await script.step('checkout', (step) async { - return await chargeCustomer(step.params['userId'] as String); + return await chargeCustomer( + step.params.requiredValue('userId'), + ); }); await script.step('poll-shipment', (step) async { @@ -465,8 +479,8 @@ final approvalsFlow = Flow( name: 'approvals.flow', build: (flow) { flow.step('draft', (ctx) async { - final payload = ctx.params['draft'] as Map; - return payload['documentId']; + final payload = ctx.params.requiredValue>('draft'); + return payload.requiredValue('documentId'); }); flow.step('manager-review', (ctx) async { diff --git a/packages/stem/example/docs_snippets/lib/tasks.dart b/packages/stem/example/docs_snippets/lib/tasks.dart index 712fa2c4..a8f68fec 100644 --- a/packages/stem/example/docs_snippets/lib/tasks.dart +++ b/packages/stem/example/docs_snippets/lib/tasks.dart @@ -15,7 +15,7 @@ class EmailTask extends TaskHandler { @override Future call(TaskContext context, Map args) async { - final to = args['to'] as String? ?? 'anonymous'; + final to = args.valueOr('to', 'anonymous'); print('Emailing $to (attempt ${context.attempt})'); } } @@ -75,7 +75,7 @@ class PublishInvoiceTask extends TaskHandler { @override Future call(TaskContext context, Map args) async { - final invoiceId = args['invoiceId'] as String; + final invoiceId = args.requiredValue('invoiceId'); await publishInvoice(invoiceId); } } diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index 9ee2e7fd..6ea452a6 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -55,8 +55,8 @@ class ApprovalsFlow { name: 'approvals.flow', build: (flow) { flow.step('draft', (ctx) async { - final payload = ctx.params['draft'] as Map; - return payload['documentId']; + final payload = ctx.params.requiredValue>('draft'); + return payload.requiredValue('documentId'); }); flow.step('manager-review', (ctx) async { @@ -66,7 +66,7 @@ class ApprovalsFlow { if (resume == null) { return null; } - return resume['approvedBy'] as String?; + return resume.value('approvedBy'); }); flow.step('finalize', (ctx) async { @@ -99,7 +99,7 @@ final retryScript = WorkflowScript( if (resume == null) { return 'pending'; } - return resume['chargeId'] as String; + return resume.requiredValue('chargeId'); }); final receipt = await script.step('confirm', (ctx) async { @@ -167,8 +167,8 @@ class ApprovalsAnnotatedWorkflow { @WorkflowStep() Future draft({FlowContext? context}) async { final ctx = context!; - final payload = ctx.params['draft'] as Map; - return payload['documentId'] as String; + final payload = ctx.params.requiredValue>('draft'); + return payload.requiredValue('documentId'); } @WorkflowStep(name: 'manager-review') @@ -180,7 +180,7 @@ class ApprovalsAnnotatedWorkflow { if (resume == null) { return null; } - return resume['approvedBy'] as String?; + return resume.value('approvedBy'); } @WorkflowStep() @@ -202,7 +202,7 @@ class BillingRetryAnnotatedWorkflow { if (resume == null) { return 'pending'; } - return resume['chargeId'] as String; + return resume.requiredValue('chargeId'); }); return script.step('confirm', (ctx) async { diff --git a/packages/stem/example/durable_watchers.dart b/packages/stem/example/durable_watchers.dart index 14ac656b..dffde485 100644 --- a/packages/stem/example/durable_watchers.dart +++ b/packages/stem/example/durable_watchers.dart @@ -13,7 +13,7 @@ Future main() async { name: 'shipment.workflow', run: (script) async { await script.step('prepare', (step) async { - final orderId = step.params['orderId']; + final orderId = step.params.requiredValue('orderId'); return 'prepared-$orderId'; }); @@ -67,11 +67,11 @@ Future main() async { class _ShipmentReadyEvent { const _ShipmentReadyEvent({required this.trackingId}); + factory _ShipmentReadyEvent.fromJson(Map json) { + return _ShipmentReadyEvent(trackingId: json['trackingId'] as String); + } + final String trackingId; Map toJson() => {'trackingId': trackingId}; - - static _ShipmentReadyEvent fromJson(Map json) { - return _ShipmentReadyEvent(trackingId: json['trackingId'] as String); - } } diff --git a/packages/stem/lib/src/core/payload_map.dart b/packages/stem/lib/src/core/payload_map.dart new file mode 100644 index 00000000..dc049c7f --- /dev/null +++ b/packages/stem/lib/src/core/payload_map.dart @@ -0,0 +1,30 @@ +import 'package:stem/src/core/payload_codec.dart'; + +/// Typed read helpers for durable task-argument and workflow-parameter maps. +extension PayloadMapX on Map { + /// Returns the decoded value for [key], or `null` when the payload is absent. + /// + /// When [codec] is supplied, the stored durable payload is decoded through + /// that codec before being returned. + T? value(String key, {PayloadCodec? codec}) { + final payload = this[key]; + if (payload == null) return null; + if (codec != null) { + return codec.decode(payload); + } + return payload as T; + } + + /// Returns the decoded value for [key], or [fallback] when it is absent. + T valueOr(String key, T fallback, {PayloadCodec? codec}) { + return value(key, codec: codec) ?? fallback; + } + + /// Returns the decoded value for [key], throwing when it is missing. + T requiredValue(String key, {PayloadCodec? codec}) { + if (!containsKey(key) || this[key] == null) { + throw StateError("Missing required payload key '$key'."); + } + return value(key, codec: codec) as T; + } +} diff --git a/packages/stem/lib/stem.dart b/packages/stem/lib/stem.dart index 5dc81813..b0cd219c 100644 --- a/packages/stem/lib/stem.dart +++ b/packages/stem/lib/stem.dart @@ -67,13 +67,12 @@ /// ``` library; -export 'package:contextual/contextual.dart' show Context, Level, Logger; - import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/core/stem.dart'; import 'package:stem/src/scheduler/beat.dart'; import 'package:stem/src/worker/worker.dart'; +export 'package:contextual/contextual.dart' show Context, Level, Logger; export 'package:stem_memory/stem_memory.dart' show InMemoryBroker, @@ -100,6 +99,7 @@ export 'src/core/encoder_keys.dart'; export 'src/core/envelope.dart'; export 'src/core/function_task_handler.dart'; export 'src/core/payload_codec.dart'; +export 'src/core/payload_map.dart'; export 'src/core/queue_events.dart'; export 'src/core/retry.dart'; export 'src/core/stem.dart'; diff --git a/packages/stem/test/unit/core/payload_map_test.dart b/packages/stem/test/unit/core/payload_map_test.dart new file mode 100644 index 00000000..952ea832 --- /dev/null +++ b/packages/stem/test/unit/core/payload_map_test.dart @@ -0,0 +1,64 @@ +import 'package:stem/stem.dart'; +import 'package:test/test.dart'; + +void main() { + group('PayloadMapX', () { + test('value reads typed scalar values', () { + const payload = {'name': 'Stem'}; + + expect(payload.value('name'), 'Stem'); + expect(payload.value('missing'), isNull); + }); + + test('valueOr returns fallback for missing values', () { + const payload = {'name': 'Stem'}; + + expect(payload.valueOr('name', 'fallback'), 'Stem'); + expect(payload.valueOr('tenant', 'global'), 'global'); + }); + + test('requiredValue throws for missing payload keys', () { + const payload = {'name': 'Stem'}; + + expect( + () => payload.requiredValue('tenant'), + throwsA( + isA().having( + (error) => error.message, + 'message', + "Missing required payload key 'tenant'.", + ), + ), + ); + }); + + test('requiredValue decodes codec-backed DTO values', () { + final payload = { + 'draft': const {'documentId': 'doc-42'}, + }; + + final draft = payload.requiredValue<_ApprovalDraft>( + 'draft', + codec: _approvalDraftCodec, + ); + + expect(draft.documentId, 'doc-42'); + }); + }); +} + +const _approvalDraftCodec = PayloadCodec<_ApprovalDraft>.json( + decode: _ApprovalDraft.fromJson, +); + +class _ApprovalDraft { + const _ApprovalDraft({required this.documentId}); + + factory _ApprovalDraft.fromJson(Map json) { + return _ApprovalDraft(documentId: json['documentId'] as String); + } + + final String documentId; + + Map toJson() => {'documentId': documentId}; +} From 9d4cd1e614073ca7736000ad617fabb728f915a2 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 07:48:53 -0500 Subject: [PATCH 181/302] Add typed workflow context value helpers --- .../workflows/context-and-serialization.md | 12 +- .site/docs/workflows/starting-and-waiting.md | 5 + packages/stem/CHANGELOG.md | 8 ++ packages/stem/README.md | 12 +- .../example/docs_snippets/lib/workflows.dart | 8 +- .../lib/src/workflows/checkout_flow.dart | 54 +++------ .../core/workflow_execution_context.dart | 46 ++++++++ .../core/workflow_script_context.dart | 22 +++- .../unit/workflow/workflow_resume_test.dart | 111 +++++++++++++++++- 9 files changed, 224 insertions(+), 54 deletions(-) diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index 60c3d71d..5ac4b541 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -34,6 +34,10 @@ Depending on the context type, you can access: - `stepIndex` - `iteration` - workflow params and previous results +- `param()` / `requiredParam()` for typed access to workflow start + params +- `previousValue()` / `requiredPreviousValue()` for typed access to the + prior step or checkpoint result - `sleepUntilResumed(...)` for common sleep/retry loops - `waitForEventValue(...)` for common event waits - `event.awaitOn(step)` when a flow deliberately wants the lower-level @@ -115,12 +119,12 @@ For normal DTOs that expose `toJson()` and `Type.fromJson(...)`, prefer `PayloadCodec.json(...)`. Drop down to `PayloadCodec.map(...)` when you need a custom map encoder or a nonstandard decode function. -For manual flows and scripts, prefer the typed payload readers on -`ctx.params` before dropping to raw map casts: +For manual flows and scripts, prefer the typed workflow param helpers before +dropping to raw map casts: ```dart -final userId = ctx.params.requiredValue('userId'); -final draft = ctx.params.requiredValue( +final userId = ctx.requiredParam('userId'); +final draft = ctx.requiredParam( 'draft', codec: approvalDraftCodec, ); diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 9306a073..3ac60cf8 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -64,6 +64,11 @@ decoder. Use `refCodec(...)` when you need a custom `PayloadCodec`. Workflow params still need to encode to a string-keyed map (typically `Map`) because they are stored as JSON-shaped data. +Inside manual flow steps and script checkpoints, prefer +`ctx.param()` / `ctx.requiredParam()` for workflow start params and +`ctx.previousValue()` / `ctx.requiredPreviousValue()` over repeating raw +`previousResult as ...` casts. + If a manual flow or script returns a DTO, prefer `Flow.json(...)` or `WorkflowScript.json(...)` in the common `toJson()` / `Type.fromJson(...)` case. Use `Flow.codec(...)` or `WorkflowScript.codec(...)` when the result diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index fb0d3725..2e85fefc 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,14 @@ ## 0.1.1 +- Added `param()`, `paramOr()`, and `requiredParam()` on + `WorkflowExecutionContext` and `WorkflowScriptContext` so manual flows, + checkpoints, and script run methods can read typed workflow start params + without repeating `ctx.params[...]` lookups. +- Added `previousValue()` and `requiredPreviousValue()` on + `WorkflowExecutionContext` so manual flow steps and script checkpoints can + read prior persisted values without repeating raw `previousResult as ...` + casts. - Added typed payload-map readers like `args.requiredValue(...)`, `args.valueOr(...)`, and `ctx.params.requiredValue(...)` so manual tasks and workflows can decode scalars and codec-backed DTOs without repeating raw map diff --git a/packages/stem/README.md b/packages/stem/README.md index 4eb0efb1..6fda48e6 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -231,7 +231,7 @@ itself instead of repeating raw casts: ```dart final customerId = args.requiredValue('customerId'); final tenant = args.valueOr('tenant', 'global'); -final draft = ctx.params.requiredValue( +final draft = ctx.requiredParam( 'draft', codec: approvalDraftCodec, ); @@ -433,6 +433,10 @@ final app = await StemWorkflowApp.inMemory( Inside a script checkpoint you can access the same metadata as `FlowContext`: - `step.previousResult` contains the prior step’s persisted value. +- `step.param()` / `step.requiredParam()` read workflow params without + repeating raw map lookups. +- `step.previousValue()` reads the prior persisted value without repeating + manual casts. - `step.iteration` tracks the current auto-version suffix when `autoVersion: true` is set. - `step.idempotencyKey('scope')` builds stable outbound identifiers. @@ -479,7 +483,7 @@ final approvalsFlow = Flow( name: 'approvals.flow', build: (flow) { flow.step('draft', (ctx) async { - final payload = ctx.params.requiredValue>('draft'); + final payload = ctx.requiredParam>('draft'); return payload.requiredValue('documentId'); }); @@ -494,7 +498,7 @@ final approvalsFlow = Flow( }); flow.step('finalize', (ctx) async { - final approvedBy = ctx.previousResult as String?; + final approvedBy = ctx.previousValue(); return 'approved-by:$approvedBy'; }); }, @@ -826,7 +830,7 @@ for side effects: ```dart flow.step('emit-side-effects', (ctx) async { - final order = ctx.previousResult as Map; + final order = ctx.requiredPreviousValue>(); await ctx.enqueue( 'ecommerce.audit.log', diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index 6ea452a6..b5542a9a 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -55,7 +55,7 @@ class ApprovalsFlow { name: 'approvals.flow', build: (flow) { flow.step('draft', (ctx) async { - final payload = ctx.params.requiredValue>('draft'); + final payload = ctx.requiredParam>('draft'); return payload.requiredValue('documentId'); }); @@ -70,7 +70,7 @@ class ApprovalsFlow { }); flow.step('finalize', (ctx) async { - final approvedBy = ctx.previousResult as String?; + final approvedBy = ctx.previousValue(); return 'approved-by:$approvedBy'; }); }, @@ -167,7 +167,7 @@ class ApprovalsAnnotatedWorkflow { @WorkflowStep() Future draft({FlowContext? context}) async { final ctx = context!; - final payload = ctx.params.requiredValue>('draft'); + final payload = ctx.requiredParam>('draft'); return payload.requiredValue('documentId'); } @@ -186,7 +186,7 @@ class ApprovalsAnnotatedWorkflow { @WorkflowStep() Future finalize({FlowContext? context}) async { final ctx = context!; - final approvedBy = ctx.previousResult as String?; + final approvedBy = ctx.previousValue(); return 'approved-by:$approvedBy'; } } diff --git a/packages/stem/example/ecommerce/lib/src/workflows/checkout_flow.dart b/packages/stem/example/ecommerce/lib/src/workflows/checkout_flow.dart index f4546b41..cee8c225 100644 --- a/packages/stem/example/ecommerce/lib/src/workflows/checkout_flow.dart +++ b/packages/stem/example/ecommerce/lib/src/workflows/checkout_flow.dart @@ -19,10 +19,7 @@ Flow> buildCheckoutFlow(EcommerceRepository repository) { metadata: const {'domain': 'commerce', 'surface': 'checkout'}, build: (flow) { flow.step('load-cart', (ctx) async { - final cartId = ctx.params['cartId']?.toString() ?? ''; - if (cartId.isEmpty) { - throw ArgumentError('Missing required cartId parameter.'); - } + final cartId = ctx.requiredParam('cartId'); final cart = await repository.getCart(cartId); if (cart == null) { @@ -32,24 +29,24 @@ Flow> buildCheckoutFlow(EcommerceRepository repository) { }); flow.step('capture-payment', (ctx) async { - if ( - !ctx.sleepUntilResumed( - const Duration(milliseconds: 100), - data: { - 'phase': 'payment-authorization', - 'cartId': ctx.params['cartId'], - }, - )) { + if (!ctx.sleepUntilResumed( + const Duration(milliseconds: 100), + data: { + 'phase': 'payment-authorization', + 'cartId': ctx.requiredParam('cartId'), + }, + )) { return null; } - final cartId = ctx.params['cartId']?.toString() ?? 'unknown-cart'; + final cartId = ctx.requiredParam('cartId'); return {'paymentReference': 'pay-$cartId'}; }); flow.step('create-order', (ctx) async { - final cartId = ctx.params['cartId']?.toString() ?? ''; - final paymentPayload = _mapFromDynamic(ctx.previousResult); + final cartId = ctx.requiredParam('cartId'); + final paymentPayload = ctx + .requiredPreviousValue>(); final paymentReference = paymentPayload['paymentReference']?.toString() ?? 'pay-$cartId'; @@ -61,12 +58,7 @@ Flow> buildCheckoutFlow(EcommerceRepository repository) { }); flow.step('emit-side-effects', (ctx) async { - final order = _mapFromDynamic(ctx.previousResult); - if (order.isEmpty) { - throw StateError( - 'create-order step did not return an order payload.', - ); - } + final order = ctx.requiredPreviousValue>(); final orderId = order['id']?.toString() ?? ''; final cartId = order['cartId']?.toString() ?? ''; @@ -79,20 +71,14 @@ Flow> buildCheckoutFlow(EcommerceRepository repository) { 'detail': 'cart=$cartId', }, options: const TaskOptions(queue: 'default'), - meta: { - 'workflow': checkoutWorkflowName, - 'step': 'emit-side-effects', - }, + meta: {'workflow': checkoutWorkflowName, 'step': 'emit-side-effects'}, ); await ctx.enqueue( 'ecommerce.shipping.reserve', args: {'orderId': orderId, 'carrier': 'acme-post'}, options: const TaskOptions(queue: 'default'), - meta: { - 'workflow': checkoutWorkflowName, - 'step': 'emit-side-effects', - }, + meta: {'workflow': checkoutWorkflowName, 'step': 'emit-side-effects'}, ); return order; @@ -100,13 +86,3 @@ Flow> buildCheckoutFlow(EcommerceRepository repository) { }, ); } - -Map _mapFromDynamic(Object? value) { - if (value is Map) { - return value; - } - if (value is Map) { - return value.cast(); - } - return {}; -} diff --git a/packages/stem/lib/src/workflow/core/workflow_execution_context.dart b/packages/stem/lib/src/workflow/core/workflow_execution_context.dart index ea382800..4e2ee32b 100644 --- a/packages/stem/lib/src/workflow/core/workflow_execution_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_execution_context.dart @@ -1,4 +1,6 @@ import 'package:stem/src/core/contracts.dart'; +import 'package:stem/src/core/payload_codec.dart'; +import 'package:stem/src/core/payload_map.dart'; import 'package:stem/src/workflow/core/workflow_ref.dart'; import 'package:stem/src/workflow/core/workflow_resume_context.dart'; @@ -39,3 +41,47 @@ abstract interface class WorkflowExecutionContext /// Optional typed workflow caller for spawning child workflows. WorkflowCaller? get workflows; } + +/// Typed read helpers for workflow start parameters. +extension WorkflowExecutionContextParams on WorkflowExecutionContext { + /// Returns the decoded workflow parameter for [key], or `null`. + T? param(String key, {PayloadCodec? codec}) { + return params.value(key, codec: codec); + } + + /// Returns the decoded workflow parameter for [key], or [fallback]. + T paramOr(String key, T fallback, {PayloadCodec? codec}) { + return params.valueOr(key, fallback, codec: codec); + } + + /// Returns the decoded workflow parameter for [key], throwing when absent. + T requiredParam(String key, {PayloadCodec? codec}) { + return params.requiredValue(key, codec: codec); + } +} + +/// Typed read helpers for prior workflow step and checkpoint values. +extension WorkflowExecutionContextValues on WorkflowExecutionContext { + /// Returns the decoded prior step/checkpoint value as [T], or `null`. + /// + /// When [codec] is supplied, a non-`T` durable payload is decoded through + /// that codec before being returned. + T? previousValue({PayloadCodec? codec}) { + final value = previousResult; + if (value == null) return null; + if (codec != null && value is! T) { + return codec.decodeDynamic(value) as T; + } + return value as T; + } + + /// Returns the decoded prior step/checkpoint value as [T], throwing when the + /// workflow does not yet have a previous result. + T requiredPreviousValue({PayloadCodec? codec}) { + final value = previousValue(codec: codec); + if (value == null) { + throw StateError('WorkflowExecutionContext.previousResult is null.'); + } + return value; + } +} diff --git a/packages/stem/lib/src/workflow/core/workflow_script_context.dart b/packages/stem/lib/src/workflow/core/workflow_script_context.dart index 418e6778..7e13e601 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script_context.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:stem/src/core/contracts.dart'; +import 'package:stem/src/core/payload_codec.dart'; +import 'package:stem/src/core/payload_map.dart'; import 'package:stem/src/workflow/core/flow_context.dart' show FlowContext; import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; import 'package:stem/src/workflow/core/workflow_execution_context.dart'; @@ -11,7 +13,6 @@ import 'package:stem/src/workflow/core/workflow_result.dart'; /// the workflow runtime so scripts can execute with durable semantics. abstract class WorkflowScriptContext { /// Name of the workflow currently executing. - @override String get workflow; /// Identifier for the run. Useful when emitting logs or constructing @@ -31,10 +32,29 @@ abstract class WorkflowScriptContext { }); } +/// Typed read helpers for workflow start parameters in script run methods. +extension WorkflowScriptContextParams on WorkflowScriptContext { + /// Returns the decoded workflow parameter for [key], or `null`. + T? param(String key, {PayloadCodec? codec}) { + return params.value(key, codec: codec); + } + + /// Returns the decoded workflow parameter for [key], or [fallback]. + T paramOr(String key, T fallback, {PayloadCodec? codec}) { + return params.valueOr(key, fallback, codec: codec); + } + + /// Returns the decoded workflow parameter for [key], throwing when absent. + T requiredParam(String key, {PayloadCodec? codec}) { + return params.requiredValue(key, codec: codec); + } +} + /// Context provided to each script checkpoint invocation. Mirrors /// [FlowContext] but tailored for the facade helpers. abstract class WorkflowScriptStepContext implements WorkflowExecutionContext { /// Name of the workflow currently executing. + @override String get workflow; /// Identifier for the workflow run. diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart index de863556..edfeb232 100644 --- a/packages/stem/test/unit/workflow/workflow_resume_test.dart +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/workflow/core/flow_context.dart'; @@ -48,6 +50,83 @@ void main() { expect(context.takeResumeValue>(), isNull); }); + test( + 'WorkflowExecutionContext.previousValue reads typed previous results', + () { + final flowContext = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'tail', + params: const {}, + previousResult: 'approved', + stepIndex: 1, + ); + final scriptContext = _FakeWorkflowScriptStepContext( + previousResult: 'emailed', + ); + + expect(flowContext.previousValue(), 'approved'); + expect(scriptContext.previousValue(), 'emailed'); + }, + ); + + test( + 'WorkflowExecutionContext.requiredParam reads typed workflow params', + () { + final flowContext = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'draft', + params: const {'documentId': 'doc-42'}, + previousResult: null, + stepIndex: 0, + ); + final scriptContext = _FakeWorkflowScriptStepContext( + params: const {'documentId': 'doc-43'}, + ); + + expect(flowContext.requiredParam('documentId'), 'doc-42'); + expect(scriptContext.requiredParam('documentId'), 'doc-43'); + }, + ); + + test( + 'WorkflowScriptContext.requiredParam decodes codec-backed workflow params', + () { + final context = _FakeWorkflowScriptContext( + params: const {'payload': {'message': 'approved'}}, + ); + + final value = context.requiredParam<_ResumePayload>( + 'payload', + codec: _resumePayloadCodec, + ); + + expect(value.message, 'approved'); + }, + ); + + test( + 'WorkflowExecutionContext.requiredPreviousValue ' + 'decodes codec-backed values', + () { + final flowContext = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'tail', + params: const {}, + previousResult: const {'message': 'approved'}, + stepIndex: 1, + ); + + final value = flowContext.requiredPreviousValue<_ResumePayload>( + codec: _resumePayloadCodec, + ); + + expect(value.message, 'approved'); + }, + ); + test('FlowContext.sleepUntilResumed suspends once then resumes', () { final firstContext = FlowContext( workflow: 'demo', @@ -624,13 +703,19 @@ _ResumePayload _decodeResumePayload(Object? payload) { class _FakeWorkflowScriptStepContext implements WorkflowScriptStepContext { _FakeWorkflowScriptStepContext({ Object? resumeData, + Object? previousResult, + Map params = const {}, TaskEnqueuer? enqueuer, WorkflowCaller? workflows, }) : _resumeData = resumeData, + _previousResult = previousResult, + _params = params, _enqueuer = enqueuer, _workflows = workflows; Object? _resumeData; + final Object? _previousResult; + final Map _params; final TaskEnqueuer? _enqueuer; final WorkflowCaller? _workflows; final List awaitedTopics = []; @@ -648,10 +733,10 @@ class _FakeWorkflowScriptStepContext implements WorkflowScriptStepContext { int get iteration => 0; @override - Map get params => const {}; + Map get params => _params; @override - Object? get previousResult => null; + Object? get previousResult => _previousResult; @override String get runId => 'run-1'; @@ -805,6 +890,28 @@ class _FakeWorkflowScriptStepContext implements WorkflowScriptStepContext { } } +class _FakeWorkflowScriptContext implements WorkflowScriptContext { + _FakeWorkflowScriptContext({required this.params}); + + @override + final Map params; + + @override + String get runId => 'run-1'; + + @override + String get workflow => 'demo.workflow'; + + @override + Future step( + String name, + FutureOr Function(WorkflowScriptStepContext context) handler, { + bool autoVersion = false, + }) { + return Future.error(UnimplementedError()); + } +} + class _RecordingTaskEnqueuer implements TaskEnqueuer { String? lastName; Map? lastArgs; From 74472480c4a14a1d5b5eced09d6881f176469608 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 07:53:05 -0500 Subject: [PATCH 182/302] Add typed task context arg helpers --- .site/docs/core-concepts/tasks.md | 8 +++++ packages/stem/CHANGELOG.md | 4 +++ packages/stem/README.md | 9 ++--- .../stem/example/docs_snippets/lib/tasks.dart | 4 +-- .../stem/example/task_usage_patterns.dart | 2 +- packages/stem/lib/src/core/contracts.dart | 36 ++++++++++++++++++- .../lib/src/core/function_task_handler.dart | 1 + .../stem/lib/src/core/task_invocation.dart | 14 +++++++- .../stem/lib/src/worker/isolate_messages.dart | 1 + packages/stem/lib/src/worker/worker.dart | 1 + .../unit/core/function_task_handler_test.dart | 5 ++- .../unit/core/task_context_enqueue_test.dart | 16 +++++++++ .../test/unit/core/task_invocation_test.dart | 16 +++++++++ 13 files changed, 107 insertions(+), 10 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 36069f8c..41542bcc 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -116,6 +116,14 @@ every retry signal and shows how the strategy interacts with broker timings. Use the context to build idempotent handlers. Re-enqueue work, cancel jobs, or store audit details in `context.meta`. +For handler inputs, prefer the typed arg helpers on the task context when +available: + +```dart +final customerId = context.requiredArg('customerId'); +final tenant = context.argOr('tenant', 'global'); +``` + See the `packages/stem/example/task_context_mixed` demo for a runnable sample that exercises inline + isolate enqueue, TaskRetryPolicy overrides, and enqueue options. The `packages/stem/example/task_usage_patterns.dart` sample shows in-memory TaskContext and diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 2e85fefc..f06d1404 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.1 +- Added `arg()`, `argOr()`, and `requiredArg()` on `TaskContext` and + `TaskInvocationContext`, and taught both contexts to retain the current + task args so manual handlers and isolate entrypoints can read typed inputs + without threading raw `args[...]` lookups through their logic. - Added `param()`, `paramOr()`, and `requiredParam()` on `WorkflowExecutionContext` and `WorkflowScriptContext` so manual flows, checkpoints, and script run methods can read typed workflow start params diff --git a/packages/stem/README.md b/packages/stem/README.md index 6fda48e6..64cd667d 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -225,12 +225,13 @@ need a custom (typically `Map`) because they are published as JSON-shaped data. -For manual handlers and workflows, use the typed payload readers on the map -itself instead of repeating raw casts: +For manual handlers, use the context arg helpers or the typed payload readers +on the raw map instead of repeating casts. For workflows, use the context +param/result helpers: ```dart -final customerId = args.requiredValue('customerId'); -final tenant = args.valueOr('tenant', 'global'); +final customerId = context.requiredArg('customerId'); +final tenant = context.argOr('tenant', 'global'); final draft = ctx.requiredParam( 'draft', codec: approvalDraftCodec, diff --git a/packages/stem/example/docs_snippets/lib/tasks.dart b/packages/stem/example/docs_snippets/lib/tasks.dart index a8f68fec..c1b52816 100644 --- a/packages/stem/example/docs_snippets/lib/tasks.dart +++ b/packages/stem/example/docs_snippets/lib/tasks.dart @@ -15,7 +15,7 @@ class EmailTask extends TaskHandler { @override Future call(TaskContext context, Map args) async { - final to = args.valueOr('to', 'anonymous'); + final to = context.argOr('to', 'anonymous'); print('Emailing $to (attempt ${context.attempt})'); } } @@ -75,7 +75,7 @@ class PublishInvoiceTask extends TaskHandler { @override Future call(TaskContext context, Map args) async { - final invoiceId = args.requiredValue('invoiceId'); + final invoiceId = context.requiredArg('invoiceId'); await publishInvoice(invoiceId); } } diff --git a/packages/stem/example/task_usage_patterns.dart b/packages/stem/example/task_usage_patterns.dart index 0daa9e89..820ec676 100644 --- a/packages/stem/example/task_usage_patterns.dart +++ b/packages/stem/example/task_usage_patterns.dart @@ -54,7 +54,7 @@ FutureOr childEntrypoint( TaskInvocationContext context, Map args, ) { - final value = args['value'] as String? ?? 'unknown'; + final value = context.argOr('value', 'unknown'); // Example output keeps the script runnable without adding logging setup. // ignore: avoid_print print('[child] value=$value attempt=${context.attempt}'); diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index f674896b..fbc60ab5 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -36,6 +36,7 @@ import 'dart:collection'; import 'package:stem/src/core/clock.dart'; import 'package:stem/src/core/envelope.dart'; import 'package:stem/src/core/payload_codec.dart'; +import 'package:stem/src/core/payload_map.dart'; import 'package:stem/src/core/task_invocation.dart'; import 'package:stem/src/core/task_payload_encoder.dart'; import 'package:stem/src/observability/heartbeat.dart'; @@ -1686,9 +1687,38 @@ class TaskEnqueueScope { } } +/// Shared input surface for task execution contexts that retain invocation +/// args. +abstract interface class TaskInputContext { + /// Arguments supplied to the current task invocation. + Map get args; +} + +/// Typed read helpers for task invocation args. +extension TaskInputContextArgs on TaskInputContext { + /// Returns the decoded task arg for [key], or `null`. + T? arg(String key, {PayloadCodec? codec}) { + return args.value(key, codec: codec); + } + + /// Returns the decoded task arg for [key], or [fallback]. + T argOr(String key, T fallback, {PayloadCodec? codec}) { + return args.valueOr(key, fallback, codec: codec); + } + + /// Returns the decoded task arg for [key], throwing when absent. + T requiredArg(String key, {PayloadCodec? codec}) { + return args.requiredValue(key, codec: codec); + } +} + /// Context passed to handler implementations during execution. class TaskContext - implements TaskEnqueuer, WorkflowCaller, WorkflowEventEmitter { + implements + TaskEnqueuer, + WorkflowCaller, + WorkflowEventEmitter, + TaskInputContext { /// Creates a task execution context for a handler invocation. TaskContext({ required this.id, @@ -1698,6 +1728,7 @@ class TaskContext required this.heartbeat, required this.extendLease, required this.progress, + this.args = const {}, this.enqueuer, this.workflows, this.workflowEvents, @@ -1706,6 +1737,9 @@ class TaskContext /// The unique identifier of the task. final String id; + @override + final Map args; + /// The current attempt number. final int attempt; diff --git a/packages/stem/lib/src/core/function_task_handler.dart b/packages/stem/lib/src/core/function_task_handler.dart index e642cf45..d47c90d0 100644 --- a/packages/stem/lib/src/core/function_task_handler.dart +++ b/packages/stem/lib/src/core/function_task_handler.dart @@ -54,6 +54,7 @@ class FunctionTaskHandler implements TaskHandler { Future call(TaskContext context, Map args) async { final invocationContext = TaskInvocationContext.local( id: context.id, + args: args, headers: context.headers, meta: context.meta, attempt: context.attempt, diff --git a/packages/stem/lib/src/core/task_invocation.dart b/packages/stem/lib/src/core/task_invocation.dart index 5a35a911..9f26c00c 100644 --- a/packages/stem/lib/src/core/task_invocation.dart +++ b/packages/stem/lib/src/core/task_invocation.dart @@ -277,7 +277,11 @@ class EmitWorkflowEventResponse { /// Context exposed to task entrypoints regardless of execution environment. class TaskInvocationContext - implements TaskEnqueuer, WorkflowCaller, WorkflowEventEmitter { + implements + TaskEnqueuer, + WorkflowCaller, + WorkflowEventEmitter, + TaskInputContext { /// Context implementation used when executing locally in the same isolate. factory TaskInvocationContext.local({ required String id, @@ -291,11 +295,13 @@ class TaskInvocationContext Map? data, }) progress, + Map args = const {}, TaskEnqueuer? enqueuer, WorkflowCaller? workflows, WorkflowEventEmitter? workflowEvents, }) => TaskInvocationContext._( id: id, + args: args, headers: headers, meta: meta, attempt: attempt, @@ -314,8 +320,10 @@ class TaskInvocationContext required Map headers, required Map meta, required int attempt, + Map args = const {}, }) => TaskInvocationContext._( id: id, + args: args, headers: headers, meta: meta, attempt: attempt, @@ -331,6 +339,7 @@ class TaskInvocationContext /// Internal constructor shared by local and isolate contexts. TaskInvocationContext._({ required this.id, + required this.args, required this.headers, required this.meta, required this.attempt, @@ -354,6 +363,9 @@ class TaskInvocationContext /// The unique identifier of the task. final String id; + @override + final Map args; + /// Headers passed to the task invocation. final Map headers; diff --git a/packages/stem/lib/src/worker/isolate_messages.dart b/packages/stem/lib/src/worker/isolate_messages.dart index b10d4c5f..b0a027e7 100644 --- a/packages/stem/lib/src/worker/isolate_messages.dart +++ b/packages/stem/lib/src/worker/isolate_messages.dart @@ -179,6 +179,7 @@ void taskWorkerIsolate(SendPort handshakePort) { if (message is TaskRunRequest) { final invocationContext = TaskInvocationContext.remote( id: message.id, + args: message.args, controlPort: message.controlPort, headers: message.headers, meta: message.meta, diff --git a/packages/stem/lib/src/worker/worker.dart b/packages/stem/lib/src/worker/worker.dart index 1bfa4a25..9a3d207a 100644 --- a/packages/stem/lib/src/worker/worker.dart +++ b/packages/stem/lib/src/worker/worker.dart @@ -968,6 +968,7 @@ class Worker { final context = TaskContext( id: envelope.id, + args: envelope.args, attempt: envelope.attempt, headers: envelope.headers, meta: envelope.meta, diff --git a/packages/stem/test/unit/core/function_task_handler_test.dart b/packages/stem/test/unit/core/function_task_handler_test.dart index 2ddeeb79..f62db6ef 100644 --- a/packages/stem/test/unit/core/function_task_handler_test.dart +++ b/packages/stem/test/unit/core/function_task_handler_test.dart @@ -9,6 +9,7 @@ void main() { Duration? extended; double? progressValue; Map? progressData; + String? argValue; final handler = FunctionTaskHandler( name: 'math.add', @@ -16,6 +17,7 @@ void main() { invocation.heartbeat(); await invocation.extendLease(const Duration(seconds: 3)); await invocation.progress(0.5, data: {'stage': 'halfway'}); + argValue = invocation.requiredArg('name'); final a = args['a']! as int; final b = args['b']! as int; return a + b; @@ -39,10 +41,11 @@ void main() { progressData = data; }, ), - const {'a': 2, 'b': 3}, + const {'a': 2, 'b': 3, 'name': 'stem'}, ); expect(result, equals(5)); + expect(argValue, equals('stem')); expect(handler.isolateEntrypoint, isNotNull); expect(heartbeats, equals(1)); expect(extended, equals(const Duration(seconds: 3))); diff --git a/packages/stem/test/unit/core/task_context_enqueue_test.dart b/packages/stem/test/unit/core/task_context_enqueue_test.dart index 46537be1..2c0f5f9d 100644 --- a/packages/stem/test/unit/core/task_context_enqueue_test.dart +++ b/packages/stem/test/unit/core/task_context_enqueue_test.dart @@ -9,6 +9,22 @@ const _parentAttemptKey = 'stem.parentAttempt'; void main() { group('TaskContext.enqueue', () { + test('exposes typed arg readers on the context', () async { + final context = TaskContext( + id: 'parent-0', + args: const {'invoiceId': 'inv-42'}, + attempt: 0, + headers: const {}, + meta: const {}, + heartbeat: () {}, + extendLease: (_) async {}, + progress: (_, {data}) async {}, + ); + + expect(context.requiredArg('invoiceId'), equals('inv-42')); + expect(context.argOr('tenant', 'global'), equals('global')); + }); + test('propagates headers/meta and lineage by default', () async { final enqueuer = _RecordingEnqueuer(); final context = TaskContext( diff --git a/packages/stem/test/unit/core/task_invocation_test.dart b/packages/stem/test/unit/core/task_invocation_test.dart index 236f39da..784a888f 100644 --- a/packages/stem/test/unit/core/task_invocation_test.dart +++ b/packages/stem/test/unit/core/task_invocation_test.dart @@ -129,6 +129,22 @@ class _CapturingWorkflowEventEmitter implements WorkflowEventEmitter { } void main() { + test('TaskInvocationContext.local exposes typed arg readers', () { + final context = TaskInvocationContext.local( + id: 'task-1', + args: const {'customerId': 'cus-42'}, + headers: const {}, + meta: const {}, + attempt: 0, + heartbeat: () {}, + extendLease: (_) async {}, + progress: (_, {Map? data}) async {}, + ); + + expect(context.requiredArg('customerId'), equals('cus-42')); + expect(context.argOr('tenant', 'global'), equals('global')); + }); + test('TaskInvocationContext.local merges headers/meta and lineage', () async { final enqueuer = _CapturingEnqueuer('task-1'); final context = TaskInvocationContext.local( From c17be103f1b364e4b22e15ec058e26670309ea06 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 07:57:21 -0500 Subject: [PATCH 183/302] Add typed task and workflow result helpers --- .site/docs/core-concepts/tasks.md | 2 + .site/docs/workflows/starting-and-waiting.md | 2 + packages/stem/CHANGELOG.md | 6 ++ packages/stem/README.md | 10 ++-- .../example/docs_snippets/lib/workflows.dart | 2 +- packages/stem/lib/src/core/contracts.dart | 26 ++++++++ packages/stem/lib/src/core/task_result.dart | 14 +++++ .../src/workflow/core/workflow_result.dart | 14 +++++ .../stem/test/unit/core/contracts_test.dart | 60 +++++++++++++++++++ .../stem/test/unit/core/task_result_test.dart | 40 +++++++++++++ .../unit/workflow/workflow_result_test.dart | 51 ++++++++++++++++ 11 files changed, 221 insertions(+), 6 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 41542bcc..7e40db78 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -51,6 +51,8 @@ Typed results flow through `TaskResult` when you call `Stem.waitForTask`, `Canvas.group`, `Canvas.chain`, or `Canvas.chord`. Supplying a custom `decode` callback on the task signature lets you deserialize complex objects before they reach application code. +Use `result.requiredValue()` when a completed task must have a decoded value +and you want a fail-fast read instead of manual nullable handling. If your manual task args are DTOs, prefer `TaskDefinition.json(...)` when the type already has `toJson()`. Use `TaskDefinition.codec(...)` when you diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 3ac60cf8..b19a21db 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -90,6 +90,8 @@ polls the store until the run finishes or the caller times out. Use the returned `WorkflowResult` when you need: - `value` for a completed run +- `requiredValue()` when completion is already guaranteed and you want a + fail-fast typed read - `status` for partial progress - `timedOut` to decide whether to keep polling diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index f06d1404..92a65ed7 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,12 @@ ## 0.1.1 +- Added typed task/workflow result readers: + `TaskStatus.payloadValue(...)`, `payloadValueOr(...)`, + `requiredPayloadValue(...)`, `TaskResult.valueOr(...)`, + `TaskResult.requiredValue()`, `WorkflowResult.valueOr(...)`, and + `WorkflowResult.requiredValue()` so low-level status reads and typed waits no + longer need manual nullable handling or raw payload casts. - Added `arg()`, `argOr()`, and `requiredArg()` on `TaskContext` and `TaskInvocationContext`, and taught both contexts to retain the current task args so manual handlers and isolate entrypoints can read typed inputs diff --git a/packages/stem/README.md b/packages/stem/README.md index 64cd667d..94375161 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -862,7 +862,7 @@ their direct helpers or typed refs: ```dart final result = await ordersWorkflow.startAndWait(app); -print(result.value?.total); +print(result.requiredValue().total); ``` `StemWorkflowApp.waitForCompletion` is the low-level completion API for @@ -876,7 +876,7 @@ final result = await app.waitForCompletion( decode: (payload) => OrderReceipt.fromJson(payload! as Map), ); if (result?.isCompleted == true) { - print(result!.value?.total); + print(result!.requiredValue().total); } else if (result?.timedOut == true) { inspectSuspension(result?.state); } @@ -909,7 +909,7 @@ final charge = await ChargeCustomer.definition.enqueueAndWait( ChargeArgs(orderId: '123'), ); if (charge?.isSucceeded == true) { - print('Captured ${charge!.value!.total}'); + print('Captured ${charge!.requiredValue().total}'); } else if (charge?.isFailed == true) { log.severe('Charge failed: ${charge!.status.error}'); } @@ -930,7 +930,7 @@ final receipt = await StemTaskDefinitions.sendEmailTyped.enqueueAndWait( tags: ['welcome'], ), ); -print(receipt?.value?.deliveryId); +print(receipt?.requiredValue().deliveryId); ``` ### Typed canvas helpers @@ -954,7 +954,7 @@ final dispatch = await canvas.group([ dispatch.results.listen((result) { if (result.isSucceeded) { - dashboard.update(result.value!); + dashboard.update(result.requiredValue()); } }); diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index b5542a9a..15b1e455 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -136,7 +136,7 @@ Future runWorkflow(StemWorkflowApp workflowApp) async { ); if (result?.isCompleted == true) { - print('Workflow finished with ${result!.value}'); + print('Workflow finished with ${result!.requiredValue()}'); } else { print('Workflow state: ${result?.status}'); } diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index fbc60ab5..fb0c3000 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -257,6 +257,32 @@ class TaskStatus { /// The payload associated with this task, if any. final Object? payload; + /// Returns the decoded payload value, or `null` when no payload is present. + /// + /// When [codec] is supplied, the stored durable payload is decoded through + /// that codec before being returned. + T? payloadValue({PayloadCodec? codec}) { + final stored = payload; + if (stored == null) return null; + if (codec != null) { + return codec.decode(stored); + } + return stored as T; + } + + /// Returns the decoded payload value, or [fallback] when it is absent. + T payloadValueOr(T fallback, {PayloadCodec? codec}) { + return payloadValue(codec: codec) ?? fallback; + } + + /// Returns the decoded payload value, throwing when it is absent. + T requiredPayloadValue({PayloadCodec? codec}) { + if (payload == null) { + throw StateError("Task '$id' does not have a payload."); + } + return payloadValue(codec: codec) as T; + } + /// The error that occurred during task execution, if any. final TaskError? error; diff --git a/packages/stem/lib/src/core/task_result.dart b/packages/stem/lib/src/core/task_result.dart index c0efbb62..411c0daf 100644 --- a/packages/stem/lib/src/core/task_result.dart +++ b/packages/stem/lib/src/core/task_result.dart @@ -23,6 +23,20 @@ class TaskResult { /// Decoded payload when the task succeeded. final T? value; + /// Returns [value] or [fallback] when the task has no decoded result. + T valueOr(T fallback) => value ?? fallback; + + /// Returns the decoded value, throwing when it is absent. + T requiredValue() { + final resolved = value; + if (resolved == null) { + throw StateError( + "Task '$taskId' does not have a decoded result value.", + ); + } + return resolved; + } + /// Raw payload stored by the backend (useful for debugging or manual casts). final Object? rawPayload; diff --git a/packages/stem/lib/src/workflow/core/workflow_result.dart b/packages/stem/lib/src/workflow/core/workflow_result.dart index 486d2928..425f23b7 100644 --- a/packages/stem/lib/src/workflow/core/workflow_result.dart +++ b/packages/stem/lib/src/workflow/core/workflow_result.dart @@ -46,6 +46,20 @@ class WorkflowResult { /// run completed successfully. final T? value; + /// Returns [value] or [fallback] when the workflow has no decoded result. + T valueOr(T fallback) => value ?? fallback; + + /// Returns the decoded value, throwing when it is absent. + T requiredValue() { + final resolved = value; + if (resolved == null) { + throw StateError( + "Workflow run '$runId' does not have a decoded result value.", + ); + } + return resolved; + } + /// Untyped payload stored by the workflow, useful for legacy consumers or /// debugging scenarios. final Object? rawResult; diff --git a/packages/stem/test/unit/core/contracts_test.dart b/packages/stem/test/unit/core/contracts_test.dart index 3e6c6a3a..7d0a849b 100644 --- a/packages/stem/test/unit/core/contracts_test.dart +++ b/packages/stem/test/unit/core/contracts_test.dart @@ -1,5 +1,6 @@ import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/core/envelope.dart'; +import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/scheduler/schedule_spec.dart'; import 'package:test/test.dart'; @@ -151,6 +152,65 @@ void main() { expect(status.workflowSerializationVersion, equals('1')); expect(status.workflowStreamId, equals('invoice_run-123')); }); + + test('payload helpers decode stored values', () { + final status = TaskStatus( + id: 'task-4', + state: TaskState.succeeded, + attempt: 0, + payload: const {'id': 'receipt-1'}, + ); + final codec = PayloadCodec>.map( + encode: (value) => value, + decode: (json) => json, + typeName: 'ReceiptMap', + ); + + expect( + status.payloadValue>(), + equals(const {'id': 'receipt-1'}), + ); + expect( + status.payloadValue>(codec: codec), + equals(const {'id': 'receipt-1'}), + ); + expect( + status.payloadValueOr>( + const {'id': 'fallback'}, + codec: codec, + ), + equals(const {'id': 'receipt-1'}), + ); + expect( + status.requiredPayloadValue>(codec: codec), + equals(const {'id': 'receipt-1'}), + ); + }); + + test('requiredPayloadValue throws when payload is absent', () { + final status = TaskStatus( + id: 'task-5', + state: TaskState.failed, + attempt: 1, + ); + + expect( + status.requiredPayloadValue>, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('task-5'), + ), + ), + ); + expect( + status.payloadValueOr>( + const {'id': 'fallback'}, + ), + equals(const {'id': 'fallback'}), + ); + }); }); group('DeadLetterEntry', () { diff --git a/packages/stem/test/unit/core/task_result_test.dart b/packages/stem/test/unit/core/task_result_test.dart index 6dafa8f5..d4b600a9 100644 --- a/packages/stem/test/unit/core/task_result_test.dart +++ b/packages/stem/test/unit/core/task_result_test.dart @@ -37,4 +37,44 @@ void main() { expect(cancelled.isCancelled, isTrue); }); + + test('TaskResult exposes typed value helpers', () { + final result = TaskResult( + taskId: 'task-1', + status: TaskStatus( + id: 'task-1', + state: TaskState.succeeded, + attempt: 0, + payload: 42, + ), + value: 42, + rawPayload: 42, + ); + + expect(result.valueOr(7), 42); + expect(result.requiredValue(), 42); + }); + + test('TaskResult.requiredValue throws when value is absent', () { + final result = TaskResult( + taskId: 'task-1', + status: TaskStatus( + id: 'task-1', + state: TaskState.failed, + attempt: 1, + ), + ); + + expect( + result.requiredValue, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('task-1'), + ), + ), + ); + expect(result.valueOr(7), 7); + }); } diff --git a/packages/stem/test/unit/workflow/workflow_result_test.dart b/packages/stem/test/unit/workflow/workflow_result_test.dart index ac12143b..13afd9e2 100644 --- a/packages/stem/test/unit/workflow/workflow_result_test.dart +++ b/packages/stem/test/unit/workflow/workflow_result_test.dart @@ -25,4 +25,55 @@ void main() { expect(result.isCompleted, isTrue); expect(result.isFailed, isFalse); }); + + test('WorkflowResult exposes typed value helpers', () { + final state = RunState( + id: 'run-1', + workflow: 'demo', + status: WorkflowStatus.completed, + cursor: 0, + params: const {}, + createdAt: DateTime.utc(2025), + updatedAt: DateTime.utc(2025), + ); + final result = WorkflowResult( + runId: 'run-1', + status: WorkflowStatus.completed, + state: state, + value: 42, + rawResult: 42, + ); + + expect(result.valueOr(7), 42); + expect(result.requiredValue(), 42); + }); + + test('WorkflowResult.requiredValue throws when value is absent', () { + final state = RunState( + id: 'run-1', + workflow: 'demo', + status: WorkflowStatus.failed, + cursor: 0, + params: const {}, + createdAt: DateTime.utc(2025), + updatedAt: DateTime.utc(2025), + ); + final result = WorkflowResult( + runId: 'run-1', + status: WorkflowStatus.failed, + state: state, + ); + + expect( + result.requiredValue, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('run-1'), + ), + ), + ); + expect(result.valueOr(7), 7); + }); } From a116c097d20ffa60327646edfd7237fce3063bd2 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 08:02:31 -0500 Subject: [PATCH 184/302] Add json decoding to low-level waits --- .site/docs/core-concepts/tasks.md | 2 ++ .site/docs/workflows/starting-and-waiting.md | 3 +- packages/stem/CHANGELOG.md | 5 +++ packages/stem/README.md | 8 +++-- packages/stem/lib/src/bootstrap/stem_app.dart | 8 ++++- .../stem/lib/src/bootstrap/stem_client.dart | 8 ++++- .../stem/lib/src/bootstrap/workflow_app.dart | 36 ++++++++++++++++--- packages/stem/lib/src/core/payload_codec.dart | 9 +++++ packages/stem/lib/src/core/stem.dart | 17 +++++++-- .../workflow/runtime/workflow_runtime.dart | 28 +++++++++++++-- .../stem/test/bootstrap/stem_app_test.dart | 3 +- .../stem/test/unit/core/stem_core_test.dart | 22 ++++++++++++ 12 files changed, 132 insertions(+), 17 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 7e40db78..8c88a75a 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -53,6 +53,8 @@ Typed results flow through `TaskResult` when you call lets you deserialize complex objects before they reach application code. Use `result.requiredValue()` when a completed task must have a decoded value and you want a fail-fast read instead of manual nullable handling. +For low-level DTO waits through `Stem.waitForTask`, prefer +`decodeJson:` over a manual raw-payload cast. If your manual task args are DTOs, prefer `TaskDefinition.json(...)` when the type already has `toJson()`. Use `TaskDefinition.codec(...)` when you diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index b19a21db..ca2ba59e 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -85,7 +85,8 @@ like `ordersFlow.startAndWait(...)` and `StemWorkflowDefinitions.orders.startAndWait(...)`. `waitForCompletion` is the low-level completion API for name-based runs. It -polls the store until the run finishes or the caller times out. +polls the store until the run finishes or the caller times out. For DTO +results, prefer `decodeJson:` over a manual raw-payload cast. Use the returned `WorkflowResult` when you need: diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 92a65ed7..ae8c8a0b 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,11 @@ ## 0.1.1 +- Added `decodeJson:` shortcuts to the low-level + `Stem.waitForTask` and `StemWorkflowApp.waitForCompletion` wait APIs, + and propagated the same task wait shortcut through `StemApp` and + `StemClient`, so DTO waits no longer need manual + `payload as Map` closures. - Added typed task/workflow result readers: `TaskStatus.payloadValue(...)`, `payloadValueOr(...)`, `requiredPayloadValue(...)`, `TaskResult.valueOr(...)`, diff --git a/packages/stem/README.md b/packages/stem/README.md index 94375161..25cf0033 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -866,14 +866,15 @@ print(result.requiredValue().total); ``` `StemWorkflowApp.waitForCompletion` is the low-level completion API for -name-based runs. It exposes the decoded value along with the raw `RunState`, +name-based runs. It accepts either `decode:` or the shorter `decodeJson:` +shortcut for DTOs and exposes the decoded value along with the raw `RunState`, letting you work with domain models without manual casts: ```dart final runId = await app.startWorkflow('orders.workflow'); final result = await app.waitForCompletion( runId, - decode: (payload) => OrderReceipt.fromJson(payload! as Map), + decodeJson: OrderReceipt.fromJson, ); if (result?.isCompleted == true) { print(result!.requiredValue().total); @@ -901,7 +902,8 @@ Producers can now wait for individual task results using either `TaskDefinition.enqueueAndWait(...)`, `TaskDefinition.waitFor(...)`, or `Stem.waitForTask` with optional decoders. These helpers return a `TaskResult` containing the underlying `TaskStatus`, decoded payload, and a -timeout flag: +timeout flag. For low-level DTO waits, `Stem.waitForTask` also accepts +`decodeJson:`: ```dart final charge = await ChargeCustomer.definition.enqueueAndWait( diff --git a/packages/stem/lib/src/bootstrap/stem_app.dart b/packages/stem/lib/src/bootstrap/stem_app.dart index ec55e60b..2994c1b7 100644 --- a/packages/stem/lib/src/bootstrap/stem_app.dart +++ b/packages/stem/lib/src/bootstrap/stem_app.dart @@ -142,9 +142,15 @@ class StemApp implements StemTaskApp { String taskId, { Duration? timeout, TResult Function(Object? payload)? decode, + TResult Function(Map payload)? decodeJson, }) async { await _ensureStarted(); - return stem.waitForTask(taskId, timeout: timeout, decode: decode); + return stem.waitForTask( + taskId, + timeout: timeout, + decode: decode, + decodeJson: decodeJson, + ); } @override diff --git a/packages/stem/lib/src/bootstrap/stem_client.dart b/packages/stem/lib/src/bootstrap/stem_client.dart index 5ae328c4..6ffbd343 100644 --- a/packages/stem/lib/src/bootstrap/stem_client.dart +++ b/packages/stem/lib/src/bootstrap/stem_client.dart @@ -200,8 +200,14 @@ abstract class StemClient implements TaskResultCaller { String taskId, { Duration? timeout, TResult Function(Object? payload)? decode, + TResult Function(Map payload)? decodeJson, }) { - return stem.waitForTask(taskId, timeout: timeout, decode: decode); + return stem.waitForTask( + taskId, + timeout: timeout, + decode: decode, + decodeJson: decodeJson, + ); } /// Waits for a task result using a typed [definition] for decoding. diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index c8c7b4a1..8f5f53f2 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -131,8 +131,14 @@ class StemWorkflowApp String taskId, { Duration? timeout, TResult Function(Object? payload)? decode, + TResult Function(Map payload)? decodeJson, }) { - return app.waitForTask(taskId, timeout: timeout, decode: decode); + return app.waitForTask( + taskId, + timeout: timeout, + decode: decode, + decodeJson: decodeJson, + ); } @override @@ -404,7 +410,12 @@ class StemWorkflowApp Duration pollInterval = const Duration(milliseconds: 100), Duration? timeout, T Function(Object? payload)? decode, + T Function(Map payload)? decodeJson, }) async { + assert( + decode == null || decodeJson == null, + 'Specify either decode or decodeJson, not both.', + ); final startedAt = stemNow(); while (true) { final state = await store.get(runId); @@ -412,10 +423,20 @@ class StemWorkflowApp return null; } if (state.isTerminal) { - return _buildResult(state, decode, timedOut: false); + return _buildResult( + state, + decode, + decodeJson: decodeJson, + timedOut: false, + ); } if (timeout != null && stemNow().difference(startedAt) >= timeout) { - return _buildResult(state, decode, timedOut: true); + return _buildResult( + state, + decode, + decodeJson: decodeJson, + timedOut: true, + ); } await Future.delayed(pollInterval); } @@ -442,9 +463,10 @@ class StemWorkflowApp RunState state, T Function(Object? payload)? decode, { required bool timedOut, + T Function(Map payload)? decodeJson, }) { final value = state.status == WorkflowStatus.completed - ? _decodeResult(state.result, decode) + ? _decodeResult(state.result, decode, decodeJson) : null; return WorkflowResult( runId: state.id, @@ -459,10 +481,16 @@ class StemWorkflowApp T? _decodeResult( Object? payload, T Function(Object? payload)? decode, + T Function(Map payload)? decodeJson, ) { if (decode != null) { return decode(payload); } + if (decodeJson != null) { + return decodeJson( + PayloadCodec.decodeJsonMap(payload, typeName: 'workflow result'), + ); + } return payload as T?; } diff --git a/packages/stem/lib/src/core/payload_codec.dart b/packages/stem/lib/src/core/payload_codec.dart index 199f3b7c..33fb8331 100644 --- a/packages/stem/lib/src/core/payload_codec.dart +++ b/packages/stem/lib/src/core/payload_codec.dart @@ -67,6 +67,15 @@ class PayloadCodec { return _payloadJsonMap(payload, typeName ?? value.runtimeType.toString()); } + /// Normalizes a durable payload into the string-keyed JSON map shape used by + /// DTO-style decoders. + static Map decodeJsonMap( + Object? payload, { + String typeName = 'payload', + }) { + return _payloadJsonMap(payload, typeName); + } + /// Converts a typed value into a durable payload representation. Object? encode(T value) => _encode(value); diff --git a/packages/stem/lib/src/core/stem.dart b/packages/stem/lib/src/core/stem.dart index 2626abfa..995a1576 100644 --- a/packages/stem/lib/src/core/stem.dart +++ b/packages/stem/lib/src/core/stem.dart @@ -62,6 +62,7 @@ import 'package:stem/src/core/clock.dart'; import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/core/encoder_keys.dart'; import 'package:stem/src/core/envelope.dart'; +import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/core/retry.dart'; import 'package:stem/src/core/task_payload_encoder.dart'; import 'package:stem/src/core/task_result.dart'; @@ -87,6 +88,7 @@ abstract interface class TaskResultCaller implements TaskEnqueuer { String taskId, { Duration? timeout, TResult Function(Object? payload)? decode, + TResult Function(Map payload)? decodeJson, }); /// Waits for [taskId] using a typed [definition] for result decoding. @@ -519,7 +521,12 @@ class Stem implements TaskResultCaller { String taskId, { Duration? timeout, T Function(Object? payload)? decode, + T Function(Map payload)? decodeJson, }) async { + assert( + decode == null || decodeJson == null, + 'Specify either decode or decodeJson, not both.', + ); final resultBackend = backend; if (resultBackend == null) { throw StateError( @@ -532,7 +539,7 @@ class Stem implements TaskResultCaller { taskId: taskId, status: lastStatus, value: lastStatus.state == TaskState.succeeded - ? _decodeTaskPayload(lastStatus.payload, decode) + ? _decodeTaskPayload(lastStatus.payload, decode, decodeJson) : null, rawPayload: lastStatus.payload, ); @@ -555,7 +562,7 @@ class Stem implements TaskResultCaller { taskId: taskId, status: status, value: status.state == TaskState.succeeded - ? _decodeTaskPayload(status.payload, decode) + ? _decodeTaskPayload(status.payload, decode, decodeJson) : null, rawPayload: status.payload, timedOut: timedOut && !status.state.isTerminal, @@ -1079,11 +1086,17 @@ class Stem implements TaskResultCaller { T? _decodeTaskPayload( Object? payload, T Function(Object? payload)? decode, + T Function(Map payload)? decodeJson, ) { if (payload == null) return null; if (decode != null) { return decode(payload); } + if (decodeJson != null) { + return decodeJson( + PayloadCodec.decodeJsonMap(payload, typeName: 'task result'), + ); + } return payload as T?; } } diff --git a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart index f5259d1e..774892eb 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart @@ -252,7 +252,12 @@ class WorkflowRuntime implements WorkflowCaller, WorkflowEventEmitter { Duration pollInterval = const Duration(milliseconds: 100), Duration? timeout, T Function(Object? payload)? decode, + T Function(Map payload)? decodeJson, }) async { + assert( + decode == null || decodeJson == null, + 'Specify either decode or decodeJson, not both.', + ); final startedAt = _clock.now(); while (true) { final state = await _store.get(runId); @@ -260,10 +265,20 @@ class WorkflowRuntime implements WorkflowCaller, WorkflowEventEmitter { return null; } if (state.isTerminal) { - return _buildResult(state, decode, timedOut: false); + return _buildResult( + state, + decode, + decodeJson: decodeJson, + timedOut: false, + ); } if (timeout != null && _clock.now().difference(startedAt) >= timeout) { - return _buildResult(state, decode, timedOut: true); + return _buildResult( + state, + decode, + decodeJson: decodeJson, + timedOut: true, + ); } await Future.delayed(pollInterval); } @@ -327,9 +342,10 @@ class WorkflowRuntime implements WorkflowCaller, WorkflowEventEmitter { RunState state, T Function(Object? payload)? decode, { required bool timedOut, + T Function(Map payload)? decodeJson, }) { final value = state.status == WorkflowStatus.completed - ? _decodeResult(state.result, decode) + ? _decodeResult(state.result, decode, decodeJson) : null; return WorkflowResult( runId: state.id, @@ -344,10 +360,16 @@ class WorkflowRuntime implements WorkflowCaller, WorkflowEventEmitter { T? _decodeResult( Object? payload, T Function(Object? payload)? decode, + T Function(Map payload)? decodeJson, ) { if (decode != null) { return decode(payload); } + if (decodeJson != null) { + return decodeJson( + PayloadCodec.decodeJsonMap(payload, typeName: 'workflow result'), + ); + } return payload as T?; } diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index 042af3bf..35383251 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -466,8 +466,7 @@ void main() { final runId = await workflowApp.startWorkflow('workflow.typed'); final run = await workflowApp.waitForCompletion<_DemoPayload>( runId, - decode: (payload) => - _DemoPayload.fromJson(payload! as Map), + decodeJson: _DemoPayload.fromJson, ); expect(run, isNotNull); diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index b326e214..c78d7459 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -585,6 +585,28 @@ void main() { expect(result?.rawPayload, isA<_CodecReceipt>()); }); }); + + group('Stem.waitForTask', () { + test('supports decodeJson for low-level DTO waits', () async { + final backend = InMemoryResultBackend(); + final stem = Stem(broker: _RecordingBroker(), backend: backend); + + await backend.set( + 'task-json-wait', + TaskState.succeeded, + payload: const {'id': 'receipt-json'}, + ); + + final result = await stem.waitForTask<_CodecReceipt>( + 'task-json-wait', + decodeJson: _CodecReceipt.fromJson, + ); + + expect(result?.isSucceeded, isTrue); + expect(result?.requiredValue().id, 'receipt-json'); + expect(result?.rawPayload, const {'id': 'receipt-json'}); + }); + }); } ResultBackend _codecAwareBackend() { From 6f09287e46a40210271ecd3e854351d726b81e91 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 08:07:30 -0500 Subject: [PATCH 185/302] Add low-level json enqueue and start helpers --- .site/docs/core-concepts/producer.md | 4 ++- .site/docs/workflows/getting-started.md | 12 +++---- .site/docs/workflows/starting-and-waiting.md | 2 +- packages/stem/CHANGELOG.md | 4 +++ packages/stem/README.md | 11 +++++-- .../stem/lib/src/bootstrap/workflow_app.dart | 31 +++++++++++++++++++ packages/stem/lib/src/core/contracts.dart | 27 ++++++++++++++++ .../workflow/runtime/workflow_runtime.dart | 23 ++++++++++++++ .../stem/test/bootstrap/stem_app_test.dart | 31 +++++++++++++++++++ .../stem/test/unit/core/stem_core_test.dart | 20 ++++++++++++ 10 files changed, 154 insertions(+), 11 deletions(-) diff --git a/.site/docs/core-concepts/producer.md b/.site/docs/core-concepts/producer.md index 392cbce9..ef1bc54b 100644 --- a/.site/docs/core-concepts/producer.md +++ b/.site/docs/core-concepts/producer.md @@ -57,7 +57,9 @@ caller-bound `prepareEnqueue(...)` when you want the enqueue target baked in. Raw task-name strings still work, but they are the lower-level interop path. Reach for them when the task name is truly dynamic or you are crossing a -boundary that does not have the generated/manual `TaskDefinition`. +boundary that does not have the generated/manual `TaskDefinition`. When those +calls already have DTO args, prefer `enqueuer.enqueueJson(...)` over +hand-building an `args` map. ## Enqueue options diff --git a/.site/docs/workflows/getting-started.md b/.site/docs/workflows/getting-started.md index cc9b9fe7..9000bbc5 100644 --- a/.site/docs/workflows/getting-started.md +++ b/.site/docs/workflows/getting-started.md @@ -29,12 +29,12 @@ the worker subscription automatically. The managed worker subscribes to the workflow orchestration queue, so you do not need to manually register the internal `stem.workflow.run` task. -If you prefer a minimal example, `startWorkflow(...)` also lazy-starts the -runtime and managed worker on first use. Explicit `start()` is still the better -choice when you want deterministic application lifecycle control. Use that -name-based API when workflow names come from config or external input. For -workflows you define in code, prefer direct workflow helpers or generated -workflow refs. +If you prefer a minimal example, `startWorkflow(...)` and +`startWorkflowJson(...)` also lazy-start the runtime and managed worker on +first use. Explicit `start()` is still the better choice when you want +deterministic application lifecycle control. Use those name-based APIs when +workflow names come from config or external input. For workflows you define in +code, prefer direct workflow helpers or generated workflow refs. ## 3. Start a run and wait for the result diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index ca2ba59e..6b7d29d2 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -139,7 +139,7 @@ assemble a start request incrementally before dispatch. ## Parent runs and TTL -`WorkflowRuntime.startWorkflow(...)` also supports: +`WorkflowRuntime.startWorkflow(...)` and `startWorkflowJson(...)` also support: - `parentRunId` when one workflow needs to track provenance from another run - `ttl` when you want run metadata to expire after a bounded retention period diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index ae8c8a0b..a48b839f 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.1 +- Added low-level DTO shortcuts for name-based dispatch: + `TaskEnqueuer.enqueueJson(...)`, `WorkflowRuntime.startWorkflowJson(...)`, + and `StemWorkflowApp.startWorkflowJson(...)`, so dynamic task/workflow names + can still use `toJson()` inputs without hand-built maps. - Added `decodeJson:` shortcuts to the low-level `Stem.waitForTask` and `StemWorkflowApp.waitForCompletion` wait APIs, and propagated the same task wait shortcut through `StemApp` and diff --git a/packages/stem/README.md b/packages/stem/README.md index 25cf0033..edda0507 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -147,7 +147,7 @@ Future main() async { final worker = await client.createWorker(); unawaited(worker.start()); - await client.enqueue('demo.hello', args: {'name': 'Stem'}); + await client.enqueueJson('demo.hello', const HelloArgs(name: 'Stem')); await Future.delayed(const Duration(seconds: 1)); await worker.shutdown(); await client.close(); @@ -469,8 +469,9 @@ The runtime shape is the same in every case: - bootstrap a `StemWorkflowApp` - pass `flows:`, `scripts:`, and `tasks:` directly - start runs with direct workflow helpers or generated workflow refs -- use `startWorkflow(...)` / `waitForCompletion(...)` when names come from - config, CLI input, or other dynamic sources +- use `startWorkflow(...)` / `startWorkflowJson(...)` / + `waitForCompletion(...)` when names come from config, CLI input, or other + dynamic sources You do not need to build task registries manually for normal workflow usage. @@ -1186,6 +1187,10 @@ final runId = await workflowApp.startWorkflow( ); ``` +When those low-level name-based paths already have DTO inputs, prefer +`client.enqueueJson(...)` and `workflowApp.startWorkflowJson(...)` over +hand-built map payloads. + Adapter packages expose typed factories (e.g. `redisBrokerFactory`, `postgresResultBackendFactory`, `sqliteWorkflowStoreFactory`) so you can replace drivers by importing the adapter you need. diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index 8f5f53f2..f8e86de8 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -194,6 +194,37 @@ class StemWorkflowApp ); } + /// Starts a workflow from a DTO that already exposes `toJson()`. + Future startWorkflowJson( + String name, + T paramsJson, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + String? typeName, + }) { + if (!_started) { + return start().then( + (_) => runtime.startWorkflowJson( + name, + paramsJson, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + typeName: typeName, + ), + ); + } + return runtime.startWorkflowJson( + name, + paramsJson, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + typeName: typeName, + ); + } + /// Schedules a workflow run from a typed [WorkflowRef]. @override Future startWorkflowRef( diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index fb0c3000..727ce836 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2720,6 +2720,33 @@ class BoundTaskEnqueueBuilder { /// Convenience helpers for building typed enqueue requests directly from a task /// enqueuer. extension TaskEnqueuerBuilderExtension on TaskEnqueuer { + /// Enqueues a name-based task from a DTO that already exposes `toJson()`. + Future enqueueJson( + String name, + T argsJson, { + Map headers = const {}, + TaskOptions options = const TaskOptions(), + DateTime? notBefore, + Map meta = const {}, + TaskEnqueueOptions? enqueueOptions, + String? typeName, + }) { + return enqueue( + name, + args: Map.from( + PayloadCodec.encodeJsonMap( + argsJson, + typeName: typeName ?? '$T', + ), + ), + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ); + } + /// Creates a caller-bound fluent builder for a typed task definition. BoundTaskEnqueueBuilder prepareEnqueue({ required TaskDefinition definition, diff --git a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart index 774892eb..b62214ae 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart @@ -214,6 +214,29 @@ class WorkflowRuntime implements WorkflowCaller, WorkflowEventEmitter { return runId; } + /// Persists a new workflow run from a DTO that already exposes `toJson()`. + Future startWorkflowJson( + String name, + T paramsJson, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + String? typeName, + }) { + return startWorkflow( + name, + params: Map.from( + PayloadCodec.encodeJsonMap( + paramsJson, + typeName: typeName ?? '$T', + ), + ), + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + /// Starts a workflow from a typed [WorkflowRef]. @override Future startWorkflowRef( diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index 35383251..4fa81c58 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -478,6 +478,35 @@ void main() { } }); + test('startWorkflowJson encodes DTO params without a manual map', () async { + final flow = Flow( + name: 'workflow.json.start', + build: (builder) { + builder.step( + 'payload', + (ctx) async => ctx.requiredParam('foo'), + ); + }, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + final runId = await workflowApp.startWorkflowJson( + 'workflow.json.start', + const _DemoPayload('bar'), + ); + final run = await workflowApp.waitForCompletion( + runId, + timeout: const Duration(seconds: 2), + ); + + expect(runId, isNotEmpty); + expect(run?.requiredValue(), 'bar'); + } finally { + await workflowApp.shutdown(); + } + }); + test( 'waitForCompletion does not decode when workflow is cancelled', () async { @@ -1321,6 +1350,8 @@ class _DemoPayload { _DemoPayload(json['foo']! as String); final String foo; + + Map toJson() => {'foo': foo}; } const _demoPayloadCodec = PayloadCodec<_DemoPayload>( diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index c78d7459..08711f8c 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -143,6 +143,26 @@ void main() { expect(backend.records.single.state, TaskState.queued); }); + test('enqueueJson publishes DTO args without a manual map', () async { + final broker = _RecordingBroker(); + final backend = _RecordingBackend(); + final stem = Stem( + broker: broker, + backend: backend, + tasks: [const _StubTaskHandler()], + ); + + final id = await stem.enqueueJson( + 'sample.task', + const _CodecTaskArgs('encoded'), + ); + + expect(id, isNotEmpty); + expect(broker.published.single.envelope.args, {'value': 'encoded'}); + expect(backend.records.single.id, id); + expect(backend.records.single.state, TaskState.queued); + }); + test( 'enqueueCall uses definition encoder metadata on producer-only paths', () async { From 5507076debd383efb5d0d271501bef188a16d8fa Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 08:10:50 -0500 Subject: [PATCH 186/302] Add low-level json workflow event helpers --- .../docs/workflows/suspensions-and-events.md | 12 +++-- packages/stem/CHANGELOG.md | 6 ++- packages/stem/README.md | 5 +- .../stem/lib/src/bootstrap/workflow_app.dart | 13 +++++ .../workflow/runtime/workflow_runtime.dart | 17 +++++++ .../stem/test/bootstrap/stem_app_test.dart | 48 +++++++++++++++++++ .../test/workflow/workflow_runtime_test.dart | 47 ++++++++++++++++++ 7 files changed, 139 insertions(+), 9 deletions(-) diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index 9440af8e..38ed39a6 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -45,19 +45,21 @@ final payload = await ctx.waitForEvent>( ## Emit resume events -Use `WorkflowRuntime.emit(...)` / `WorkflowRuntime.emitValue(...)` (or the app -wrapper `workflowApp.emitValue(...)`) instead of hand-editing store state: +Use `WorkflowRuntime.emit(...)` / `WorkflowRuntime.emitJson(...)` / +`WorkflowRuntime.emitValue(...)` (or the app wrappers +`workflowApp.emitJson(...)` / `workflowApp.emitValue(...)`) instead of +hand-editing store state: ```dart -await workflowApp.emitValue( +await workflowApp.emitJson( 'orders.payment.confirmed', const PaymentConfirmed(paymentId: 'pay_42', approvedBy: 'gateway'), - codec: paymentConfirmedCodec, ); ``` Typed event payloads still serialize to a string-keyed JSON-like map. -`emitValue(...)` is a DTO/codec convenience layer, not a new transport shape. +`emitJson(...)` and `emitValue(...)` are DTO/codec convenience layers, not a +new transport shape. When the topic and codec travel together in your codebase, prefer `WorkflowEventRef.json(...)` for normal DTO payloads and keep diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index a48b839f..b693f9ae 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -4,8 +4,10 @@ - Added low-level DTO shortcuts for name-based dispatch: `TaskEnqueuer.enqueueJson(...)`, `WorkflowRuntime.startWorkflowJson(...)`, - and `StemWorkflowApp.startWorkflowJson(...)`, so dynamic task/workflow names - can still use `toJson()` inputs without hand-built maps. + `StemWorkflowApp.startWorkflowJson(...)`, `WorkflowRuntime.emitJson(...)`, + and `StemWorkflowApp.emitJson(...)`, so dynamic task, workflow, and + workflow-event names can still use `toJson()` inputs without hand-built + maps. - Added `decodeJson:` shortcuts to the low-level `Stem.waitForTask` and `StemWorkflowApp.waitForCompletion` wait APIs, and propagated the same task wait shortcut through `StemApp` and diff --git a/packages/stem/README.md b/packages/stem/README.md index edda0507..b6677073 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -469,7 +469,7 @@ The runtime shape is the same in every case: - bootstrap a `StemWorkflowApp` - pass `flows:`, `scripts:`, and `tasks:` directly - start runs with direct workflow helpers or generated workflow refs -- use `startWorkflow(...)` / `startWorkflowJson(...)` / +- use `startWorkflow(...)` / `startWorkflowJson(...)` / `emitJson(...)` / `waitForCompletion(...)` when names come from config, CLI input, or other dynamic sources @@ -1146,7 +1146,8 @@ backend metadata under `stem.unique.duplicates`. - Awaited events behave the same way: the emitted payload is delivered via `takeResumeData()` / `takeResumeValue(codec: ...)` when the run resumes. -- When you have a DTO event, emit it through `workflowApp.emitValue(...)` (or +- When you have a DTO event, emit it through `workflowApp.emitJson(...)` / + `workflowApp.emitValue(...)` (or `runtime.emitJson(...)` / `runtime.emitValue(...)` when you are intentionally using the low-level runtime) with a `PayloadCodec`, or use `WorkflowEventRef.json(...)` as the shortest typed event form and call `event.emit(emitter, dto)` as the diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index f8e86de8..f6b06dd1 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -254,6 +254,19 @@ class StemWorkflowApp ); } + /// Emits a DTO-backed external event without requiring a manual payload map. + Future emitJson( + String topic, + T payloadJson, { + String? typeName, + }) { + return runtime.emitJson( + topic, + payloadJson, + typeName: typeName, + ); + } + /// Schedules a workflow run from a prebuilt [WorkflowStartCall]. @override Future startWorkflowCall( diff --git a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart index b62214ae..c8bf34bd 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart @@ -361,6 +361,23 @@ class WorkflowRuntime implements WorkflowCaller, WorkflowEventEmitter { } } + /// Emits a DTO-backed external event without requiring a manual payload map. + Future emitJson( + String topic, + T payloadJson, { + String? typeName, + }) { + return emit( + topic, + Map.from( + PayloadCodec.encodeJsonMap( + payloadJson, + typeName: typeName ?? '$T', + ), + ), + ); + } + WorkflowResult _buildResult( RunState state, T Function(Object? payload)? decode, { diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index 4fa81c58..f1f48795 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -507,6 +507,54 @@ void main() { } }); + test( + 'emitJson resumes runs with DTO payloads without a manual map', + () async { + const demoPayloadCodec = PayloadCodec<_DemoPayload>.json( + decode: _DemoPayload.fromJson, + ); + final flow = Flow( + name: 'workflow.json.emit', + build: (builder) { + builder.step( + 'wait', + (ctx) async { + final resume = ctx.takeResumeValue<_DemoPayload>( + codec: demoPayloadCodec, + ); + if (resume == null) { + ctx.awaitEvent('workflow.json.emit.topic'); + return null; + } + return resume.foo; + }, + ); + }, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + final runId = await workflowApp.startWorkflow('workflow.json.emit'); + await workflowApp.executeRun(runId); + + await workflowApp.emitJson( + 'workflow.json.emit.topic', + const _DemoPayload('baz'), + ); + + final run = await workflowApp.waitForCompletion( + runId, + timeout: const Duration(seconds: 2), + ); + + expect(runId, isNotEmpty); + expect(run?.requiredValue(), 'baz'); + } finally { + await workflowApp.shutdown(); + } + }, + ); + test( 'waitForCompletion does not decode when workflow is cancelled', () async { diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index 02fe040b..07c97edd 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -673,6 +673,53 @@ void main() { expect(completed?.result, 'user-typed-1'); }); + test( + 'emitJson resumes flows with DTO payloads without a manual map', + () async { + _UserUpdatedEvent? observedPayload; + + runtime.registerWorkflow( + Flow( + name: 'event.json.workflow', + build: (flow) { + flow.step( + 'wait', + (context) async { + final resume = context.takeResumeValue<_UserUpdatedEvent>( + codec: _userUpdatedEventCodec, + ); + if (resume == null) { + context.awaitEvent('user.updated.json'); + return null; + } + observedPayload = resume; + return resume.id; + }, + ); + }, + ).definition, + ); + + final runId = await runtime.startWorkflow('event.json.workflow'); + await runtime.executeRun(runId); + + final suspended = await store.get(runId); + expect(suspended?.status, WorkflowStatus.suspended); + expect(suspended?.waitTopic, 'user.updated.json'); + + await runtime.emitJson( + 'user.updated.json', + const _UserUpdatedEvent(id: 'user-json-1'), + ); + await runtime.executeRun(runId); + + final completed = await store.get(runId); + expect(completed?.status, WorkflowStatus.completed); + expect(observedPayload?.id, 'user-json-1'); + expect(completed?.result, 'user-json-1'); + }, + ); + test('emitEvent resumes flows with typed workflow event refs', () async { final event = WorkflowEventRef<_UserUpdatedEvent>.codec( topic: 'user.updated.ref', From 170490607a1a67582379125139e79691d6ff31fb Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 08:11:58 -0500 Subject: [PATCH 187/302] Add json queue event helpers --- .site/docs/core-concepts/queue-events.md | 3 ++ packages/stem/CHANGELOG.md | 2 + .../docs_snippets/lib/queue_events.dart | 28 ++++++++++-- packages/stem/lib/src/core/queue_events.dart | 24 +++++++++++ .../test/unit/core/queue_events_test.dart | 43 +++++++++++++++++++ 5 files changed, 96 insertions(+), 4 deletions(-) diff --git a/.site/docs/core-concepts/queue-events.md b/.site/docs/core-concepts/queue-events.md index 6fa6b2cd..9cf46065 100644 --- a/.site/docs/core-concepts/queue-events.md +++ b/.site/docs/core-concepts/queue-events.md @@ -14,6 +14,7 @@ Use this when you need lightweight event streams for domain notifications ## API Surface - `QueueEventsProducer.emit(queue, eventName, payload, headers, meta)` +- `QueueEventsProducer.emitJson(queue, eventName, dto, headers, meta)` - `QueueEvents.start()` / `QueueEvents.close()` - `QueueEvents.events` stream (all events for that queue) - `QueueEvents.on(eventName)` stream (filtered by name) @@ -38,6 +39,8 @@ Multiple listeners on the same queue receive each emitted event. - Events are queue-scoped: listeners receive only events for their configured queue. +- `emitJson(...)` is the DTO convenience path when the payload already exposes + `toJson()`. - `on(eventName)` matches exact event names. - `headers` and `meta` round-trip to listeners. - Event names and queue names must be non-empty. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index b693f9ae..6e61a45c 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -8,6 +8,8 @@ and `StemWorkflowApp.emitJson(...)`, so dynamic task, workflow, and workflow-event names can still use `toJson()` inputs without hand-built maps. +- Added `QueueEventsProducer.emitJson(...)` so queue-scoped custom events can + publish DTO payloads without hand-built maps. - Added `decodeJson:` shortcuts to the low-level `Stem.waitForTask` and `StemWorkflowApp.waitForCompletion` wait APIs, and propagated the same task wait shortcut through `StemApp` and diff --git a/packages/stem/example/docs_snippets/lib/queue_events.dart b/packages/stem/example/docs_snippets/lib/queue_events.dart index 8b59f72c..7e2b1d17 100644 --- a/packages/stem/example/docs_snippets/lib/queue_events.dart +++ b/packages/stem/example/docs_snippets/lib/queue_events.dart @@ -1,5 +1,5 @@ // Queue custom event examples for documentation. -// ignore_for_file: unused_local_variable, unused_import, dead_code, avoid_print +// ignore_for_file: avoid_print import 'dart:async'; @@ -20,10 +20,10 @@ Future queueEventsProducerListener(Broker broker) async { print('Trace id: ${event.headers['x-trace-id']}'); }); - await producer.emit( + await producer.emitJson( 'orders', 'order.created', - payload: const {'orderId': 'ord-1001'}, + const _OrderCreatedEvent(orderId: 'ord-1001'), headers: const {'x-trace-id': 'trace-123'}, meta: const {'tenant': 'acme'}, ); @@ -57,7 +57,11 @@ Future queueEventsFanout(Broker broker) async { print('B saw ${event.name}'); }); - await producer.emit('orders', 'order.updated', payload: const {'id': 'o-1'}); + await producer.emitJson( + 'orders', + 'order.updated', + const _OrderUpdatedEvent(id: 'o-1'), + ); await Future.delayed(const Duration(milliseconds: 200)); await subscriptionA.cancel(); @@ -67,3 +71,19 @@ Future queueEventsFanout(Broker broker) async { } // #endregion queue-events-fanout + +class _OrderCreatedEvent { + const _OrderCreatedEvent({required this.orderId}); + + final String orderId; + + Map toJson() => {'orderId': orderId}; +} + +class _OrderUpdatedEvent { + const _OrderUpdatedEvent({required this.id}); + + final String id; + + Map toJson() => {'id': id}; +} diff --git a/packages/stem/lib/src/core/queue_events.dart b/packages/stem/lib/src/core/queue_events.dart index f40e0816..230705f6 100644 --- a/packages/stem/lib/src/core/queue_events.dart +++ b/packages/stem/lib/src/core/queue_events.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:stem/src/core/clock.dart'; import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/core/envelope.dart'; +import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/core/stem_event.dart'; const String _queueEventEnvelopeName = '__stem.queue.event__'; @@ -122,6 +123,29 @@ class QueueEventsProducer { ); return envelope.id; } + + /// Emits [eventName] using a DTO payload that exposes `toJson()`. + Future emitJson( + String queue, + String eventName, + T payloadJson, { + Map headers = const {}, + Map meta = const {}, + String? typeName, + }) { + return emit( + queue, + eventName, + payload: Map.from( + PayloadCodec.encodeJsonMap( + payloadJson, + typeName: typeName ?? '$T', + ), + ), + headers: headers, + meta: meta, + ); + } } /// Listens for queue-scoped custom events emitted by [QueueEventsProducer]. diff --git a/packages/stem/test/unit/core/queue_events_test.dart b/packages/stem/test/unit/core/queue_events_test.dart index 29e739ae..53eb62af 100644 --- a/packages/stem/test/unit/core/queue_events_test.dart +++ b/packages/stem/test/unit/core/queue_events_test.dart @@ -116,6 +116,34 @@ void main() { expect(results[1].payload['status'], 'paid'); }); + test('emitJson publishes DTO payloads without a manual map', () async { + final listener = QueueEvents( + broker: broker, + queue: 'orders', + consumerName: 'orders-listener', + ); + await listener.start(); + addTearDown(listener.close); + + final received = listener + .on('order.shipped') + .first + .timeout(const Duration(seconds: 5)); + + final eventId = await producer.emitJson( + 'orders', + 'order.shipped', + const _QueueEventPayload(orderId: 'o-2', status: 'shipped'), + ); + + final event = await received; + expect(event.id, eventId); + expect(event.payload, { + 'orderId': 'o-2', + 'status': 'shipped', + }); + }); + test('validates queue and event names', () async { expect( () => producer.emit('', 'evt'), @@ -134,3 +162,18 @@ void main() { }); }); } + +class _QueueEventPayload { + const _QueueEventPayload({ + required this.orderId, + required this.status, + }); + + final String orderId; + final String status; + + Map toJson() => { + 'orderId': orderId, + 'status': status, + }; +} From a8b3b3fcf81b3ec158d7590d88d2891aed0aa483 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 08:12:54 -0500 Subject: [PATCH 188/302] Add typed queue event payload readers --- .site/docs/core-concepts/queue-events.md | 2 ++ packages/stem/CHANGELOG.md | 4 ++++ .../example/docs_snippets/lib/queue_events.dart | 2 +- packages/stem/lib/src/core/queue_events.dart | 16 ++++++++++++++++ .../stem/test/unit/core/queue_events_test.dart | 12 +++++------- 5 files changed, 28 insertions(+), 8 deletions(-) diff --git a/.site/docs/core-concepts/queue-events.md b/.site/docs/core-concepts/queue-events.md index 9cf46065..d2d9038c 100644 --- a/.site/docs/core-concepts/queue-events.md +++ b/.site/docs/core-concepts/queue-events.md @@ -20,6 +20,8 @@ Use this when you need lightweight event streams for domain notifications - `QueueEvents.on(eventName)` stream (filtered by name) All events are delivered as `QueueCustomEvent`, which implements `StemEvent`. +Use `event.payloadValue(...)` / `event.requiredPayloadValue(...)` to read typed +payload fields instead of repeating raw `payload['key']` casts. ## Producer + Listener diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 6e61a45c..432d66bf 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -10,6 +10,10 @@ maps. - Added `QueueEventsProducer.emitJson(...)` so queue-scoped custom events can publish DTO payloads without hand-built maps. +- Added `QueueCustomEvent.payloadValue(...)`, + `payloadValueOr(...)`, and `requiredPayloadValue(...)` so queue-event + consumers can decode typed payload fields without raw `payload['key']` + casts. - Added `decodeJson:` shortcuts to the low-level `Stem.waitForTask` and `StemWorkflowApp.waitForCompletion` wait APIs, and propagated the same task wait shortcut through `StemApp` and diff --git a/packages/stem/example/docs_snippets/lib/queue_events.dart b/packages/stem/example/docs_snippets/lib/queue_events.dart index 7e2b1d17..14fee44f 100644 --- a/packages/stem/example/docs_snippets/lib/queue_events.dart +++ b/packages/stem/example/docs_snippets/lib/queue_events.dart @@ -16,7 +16,7 @@ Future queueEventsProducerListener(Broker broker) async { await listener.start(); final subscription = listener.on('order.created').listen((event) { - print('Order created: ${event.payload['orderId']}'); + print('Order created: ${event.requiredPayloadValue('orderId')}'); print('Trace id: ${event.headers['x-trace-id']}'); }); diff --git a/packages/stem/lib/src/core/queue_events.dart b/packages/stem/lib/src/core/queue_events.dart index 230705f6..f3dd8781 100644 --- a/packages/stem/lib/src/core/queue_events.dart +++ b/packages/stem/lib/src/core/queue_events.dart @@ -4,6 +4,7 @@ import 'package:stem/src/core/clock.dart'; import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/core/envelope.dart'; import 'package:stem/src/core/payload_codec.dart'; +import 'package:stem/src/core/payload_map.dart'; import 'package:stem/src/core/stem_event.dart'; const String _queueEventEnvelopeName = '__stem.queue.event__'; @@ -45,6 +46,21 @@ class QueueCustomEvent implements StemEvent { /// Additional metadata supplied by the publisher. final Map meta; + /// Returns the decoded payload value for [key], or `null` when it is absent. + T? payloadValue(String key, {PayloadCodec? codec}) { + return payload.value(key, codec: codec); + } + + /// Returns the decoded payload value for [key], or [fallback] when absent. + T payloadValueOr(String key, T fallback, {PayloadCodec? codec}) { + return payload.valueOr(key, fallback, codec: codec); + } + + /// Returns the decoded payload value for [key], throwing when it is absent. + T requiredPayloadValue(String key, {PayloadCodec? codec}) { + return payload.requiredValue(key, codec: codec); + } + @override String get eventName => name; diff --git a/packages/stem/test/unit/core/queue_events_test.dart b/packages/stem/test/unit/core/queue_events_test.dart index 53eb62af..aec450c8 100644 --- a/packages/stem/test/unit/core/queue_events_test.dart +++ b/packages/stem/test/unit/core/queue_events_test.dart @@ -47,7 +47,7 @@ void main() { expect(event.id, eventId); expect(event.queue, 'orders'); expect(event.name, 'order.created'); - expect(event.payload['orderId'], 'o-1'); + expect(event.requiredPayloadValue('orderId'), 'o-1'); expect(event.headers['x-source'], 'test'); expect(event.meta['tenant'], 'acme'); }); @@ -112,8 +112,8 @@ void main() { final results = await Future.wait([firstA, firstB]); expect(results, hasLength(2)); - expect(results[0].payload['status'], 'paid'); - expect(results[1].payload['status'], 'paid'); + expect(results[0].requiredPayloadValue('status'), 'paid'); + expect(results[1].requiredPayloadValue('status'), 'paid'); }); test('emitJson publishes DTO payloads without a manual map', () async { @@ -138,10 +138,8 @@ void main() { final event = await received; expect(event.id, eventId); - expect(event.payload, { - 'orderId': 'o-2', - 'status': 'shipped', - }); + expect(event.requiredPayloadValue('orderId'), 'o-2'); + expect(event.payloadValueOr('status', 'pending'), 'shipped'); }); test('validates queue and event names', () async { From a779b8eeef7b064d2b3d077fa872bfba425dc8ae Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 08:15:57 -0500 Subject: [PATCH 189/302] Add queue event DTO decode helpers --- .site/docs/core-concepts/queue-events.md | 2 ++ packages/stem/CHANGELOG.md | 3 +++ .../docs_snippets/lib/queue_events.dart | 23 ++++++++++++++++--- packages/stem/lib/src/core/queue_events.dart | 16 +++++++++++++ .../test/unit/core/queue_events_test.dart | 15 ++++++++++++ 5 files changed, 56 insertions(+), 3 deletions(-) diff --git a/.site/docs/core-concepts/queue-events.md b/.site/docs/core-concepts/queue-events.md index d2d9038c..0ff162da 100644 --- a/.site/docs/core-concepts/queue-events.md +++ b/.site/docs/core-concepts/queue-events.md @@ -22,6 +22,8 @@ Use this when you need lightweight event streams for domain notifications All events are delivered as `QueueCustomEvent`, which implements `StemEvent`. Use `event.payloadValue(...)` / `event.requiredPayloadValue(...)` to read typed payload fields instead of repeating raw `payload['key']` casts. +If one queue event maps to one DTO, use `event.payloadJson(...)` or +`event.payloadAs(codec: ...)` to decode the whole payload in one step. ## Producer + Listener diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 432d66bf..7eadc3e9 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -14,6 +14,9 @@ `payloadValueOr(...)`, and `requiredPayloadValue(...)` so queue-event consumers can decode typed payload fields without raw `payload['key']` casts. +- Added `QueueCustomEvent.payloadJson(...)` and `payloadAs(codec: ...)` so + queue-event consumers can decode whole DTO payloads without rebuilding them + field by field. - Added `decodeJson:` shortcuts to the low-level `Stem.waitForTask` and `StemWorkflowApp.waitForCompletion` wait APIs, and propagated the same task wait shortcut through `StemApp` and diff --git a/packages/stem/example/docs_snippets/lib/queue_events.dart b/packages/stem/example/docs_snippets/lib/queue_events.dart index 14fee44f..31c2f3d2 100644 --- a/packages/stem/example/docs_snippets/lib/queue_events.dart +++ b/packages/stem/example/docs_snippets/lib/queue_events.dart @@ -16,7 +16,10 @@ Future queueEventsProducerListener(Broker broker) async { await listener.start(); final subscription = listener.on('order.created').listen((event) { - print('Order created: ${event.requiredPayloadValue('orderId')}'); + final created = event.payloadJson<_OrderCreatedEvent>( + decode: _OrderCreatedEvent.fromJson, + ); + print('Order created: ${created.orderId}'); print('Trace id: ${event.headers['x-trace-id']}'); }); @@ -51,10 +54,16 @@ Future queueEventsFanout(Broker broker) async { await listenerB.start(); final subscriptionA = listenerA.events.listen((event) { - print('A saw ${event.name}'); + final updated = event.payloadJson<_OrderUpdatedEvent>( + decode: _OrderUpdatedEvent.fromJson, + ); + print('A saw ${event.name} for ${updated.id}'); }); final subscriptionB = listenerB.events.listen((event) { - print('B saw ${event.name}'); + final updated = event.payloadJson<_OrderUpdatedEvent>( + decode: _OrderUpdatedEvent.fromJson, + ); + print('B saw ${event.name} for ${updated.id}'); }); await producer.emitJson( @@ -75,6 +84,10 @@ Future queueEventsFanout(Broker broker) async { class _OrderCreatedEvent { const _OrderCreatedEvent({required this.orderId}); + factory _OrderCreatedEvent.fromJson(Map json) { + return _OrderCreatedEvent(orderId: json['orderId'] as String); + } + final String orderId; Map toJson() => {'orderId': orderId}; @@ -83,6 +96,10 @@ class _OrderCreatedEvent { class _OrderUpdatedEvent { const _OrderUpdatedEvent({required this.id}); + factory _OrderUpdatedEvent.fromJson(Map json) { + return _OrderUpdatedEvent(id: json['id'] as String); + } + final String id; Map toJson() => {'id': id}; diff --git a/packages/stem/lib/src/core/queue_events.dart b/packages/stem/lib/src/core/queue_events.dart index f3dd8781..568225b8 100644 --- a/packages/stem/lib/src/core/queue_events.dart +++ b/packages/stem/lib/src/core/queue_events.dart @@ -51,6 +51,22 @@ class QueueCustomEvent implements StemEvent { return payload.value(key, codec: codec); } + /// Decodes the entire payload as a typed DTO with [codec]. + T payloadAs({required PayloadCodec codec}) { + return codec.decode(payload); + } + + /// Decodes the entire payload as a typed DTO with a JSON decoder. + T payloadJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(payload); + } + /// Returns the decoded payload value for [key], or [fallback] when absent. T payloadValueOr(String key, T fallback, {PayloadCodec? codec}) { return payload.valueOr(key, fallback, codec: codec); diff --git a/packages/stem/test/unit/core/queue_events_test.dart b/packages/stem/test/unit/core/queue_events_test.dart index aec450c8..ef41c6de 100644 --- a/packages/stem/test/unit/core/queue_events_test.dart +++ b/packages/stem/test/unit/core/queue_events_test.dart @@ -140,6 +140,14 @@ void main() { expect(event.id, eventId); expect(event.requiredPayloadValue('orderId'), 'o-2'); expect(event.payloadValueOr('status', 'pending'), 'shipped'); + expect( + event.payloadJson<_QueueEventPayload>( + decode: _QueueEventPayload.fromJson, + ), + isA<_QueueEventPayload>() + .having((value) => value.orderId, 'orderId', 'o-2') + .having((value) => value.status, 'status', 'shipped'), + ); }); test('validates queue and event names', () async { @@ -167,6 +175,13 @@ class _QueueEventPayload { required this.status, }); + factory _QueueEventPayload.fromJson(Map json) { + return _QueueEventPayload( + orderId: json['orderId'] as String, + status: json['status'] as String, + ); + } + final String orderId; final String status; From dec49701b0f6c637e62f6cd4cd2a13df00642264 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 08:17:50 -0500 Subject: [PATCH 190/302] Add task status DTO decode helpers --- .site/docs/core-concepts/tasks.md | 3 ++ packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 3 ++ packages/stem/lib/src/core/contracts.dart | 20 +++++++++++ .../stem/test/unit/core/contracts_test.dart | 36 +++++++++++++++++++ 5 files changed, 65 insertions(+) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 8c88a75a..fb46af60 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -55,6 +55,9 @@ Use `result.requiredValue()` when a completed task must have a decoded value and you want a fail-fast read instead of manual nullable handling. For low-level DTO waits through `Stem.waitForTask`, prefer `decodeJson:` over a manual raw-payload cast. +If you already have a raw `TaskStatus`, use `status.payloadJson(...)` or +`status.payloadAs(codec: ...)` to decode the whole payload DTO without a +separate cast/closure. If your manual task args are DTOs, prefer `TaskDefinition.json(...)` when the type already has `toJson()`. Use `TaskDefinition.codec(...)` when you diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 7eadc3e9..4e640885 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -28,6 +28,9 @@ `TaskResult.requiredValue()`, `WorkflowResult.valueOr(...)`, and `WorkflowResult.requiredValue()` so low-level status reads and typed waits no longer need manual nullable handling or raw payload casts. +- Added `TaskStatus.payloadJson(...)` and `payloadAs(codec: ...)` so existing + raw task-status reads can decode whole DTO payloads without another + cast/closure. - Added `arg()`, `argOr()`, and `requiredArg()` on `TaskContext` and `TaskInvocationContext`, and taught both contexts to retain the current task args so manual handlers and isolate entrypoints can read typed inputs diff --git a/packages/stem/README.md b/packages/stem/README.md index b6677073..b93801d7 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -920,6 +920,9 @@ if (charge?.isSucceeded == true) { Use `waitFor(...)` when you need to keep the task id for inspection or pass it through another boundary before waiting. +If you already have a raw `TaskStatus`, use `status.payloadJson(...)` or +`status.payloadAs(codec: ...)` to decode the whole payload DTO without another +cast/closure. Generated annotated tasks use the same surface: diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 727ce836..8e939c6c 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -270,6 +270,26 @@ class TaskStatus { return stored as T; } + /// Decodes the entire payload as a typed DTO with [codec]. + T? payloadAs({required PayloadCodec codec}) { + final stored = payload; + if (stored == null) return null; + return codec.decode(stored); + } + + /// Decodes the entire payload as a typed DTO with a JSON decoder. + T? payloadJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + final stored = payload; + if (stored == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(stored); + } + /// Returns the decoded payload value, or [fallback] when it is absent. T payloadValueOr(T fallback, {PayloadCodec? codec}) { return payloadValue(codec: codec) ?? fallback; diff --git a/packages/stem/test/unit/core/contracts_test.dart b/packages/stem/test/unit/core/contracts_test.dart index 7d0a849b..69a53ac2 100644 --- a/packages/stem/test/unit/core/contracts_test.dart +++ b/packages/stem/test/unit/core/contracts_test.dart @@ -185,6 +185,16 @@ void main() { status.requiredPayloadValue>(codec: codec), equals(const {'id': 'receipt-1'}), ); + expect( + status.payloadAs>(codec: codec), + equals(const {'id': 'receipt-1'}), + ); + expect( + status.payloadJson<_ReceiptPayload>( + decode: _ReceiptPayload.fromJson, + ), + isA<_ReceiptPayload>().having((value) => value.id, 'id', 'receipt-1'), + ); }); test('requiredPayloadValue throws when payload is absent', () { @@ -210,6 +220,22 @@ void main() { ), equals(const {'id': 'fallback'}), ); + expect( + status.payloadAs>( + codec: PayloadCodec>.map( + encode: (value) => value, + decode: (json) => json, + typeName: 'ReceiptMap', + ), + ), + isNull, + ); + expect( + status.payloadJson<_ReceiptPayload>( + decode: _ReceiptPayload.fromJson, + ), + isNull, + ); }); }); @@ -374,3 +400,13 @@ void main() { expect(error.toString(), contains('actual: 2')); }); } + +class _ReceiptPayload { + const _ReceiptPayload({required this.id}); + + factory _ReceiptPayload.fromJson(Map json) { + return _ReceiptPayload(id: json['id'] as String); + } + + final String id; +} From 394090b0ff61b94a111eafdb4b4b2863e791201c Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 08:19:21 -0500 Subject: [PATCH 191/302] Add workflow result DTO decode helpers --- .site/docs/workflows/starting-and-waiting.md | 3 ++ packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 4 ++ .../src/workflow/core/workflow_result.dart | 21 ++++++++ .../unit/workflow/workflow_result_test.dart | 49 +++++++++++++++++-- 5 files changed, 77 insertions(+), 3 deletions(-) diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 6b7d29d2..1eb7d7d4 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -87,6 +87,9 @@ like `ordersFlow.startAndWait(...)` and `waitForCompletion` is the low-level completion API for name-based runs. It polls the store until the run finishes or the caller times out. For DTO results, prefer `decodeJson:` over a manual raw-payload cast. +If you already have a raw `WorkflowResult`, use +`result.payloadJson(...)` or `result.payloadAs(codec: ...)` to decode the +stored workflow result without another cast/closure. Use the returned `WorkflowResult` when you need: diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 4e640885..be8b9a6f 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -31,6 +31,9 @@ - Added `TaskStatus.payloadJson(...)` and `payloadAs(codec: ...)` so existing raw task-status reads can decode whole DTO payloads without another cast/closure. +- Added `WorkflowResult.payloadJson(...)` and `payloadAs(codec: ...)` so raw + workflow completion results can decode whole DTO payloads without another + cast/closure. - Added `arg()`, `argOr()`, and `requiredArg()` on `TaskContext` and `TaskInvocationContext`, and taught both contexts to retain the current task args so manual handlers and isolate entrypoints can read typed inputs diff --git a/packages/stem/README.md b/packages/stem/README.md index b93801d7..09c61a01 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -884,6 +884,10 @@ if (result?.isCompleted == true) { } ``` +If you already have a raw `WorkflowResult`, use +`result.payloadJson(...)` or `result.payloadAs(codec: ...)` to decode the +stored workflow result without another cast/closure. + In the example above, these calls inside `run(...)`: ```dart diff --git a/packages/stem/lib/src/workflow/core/workflow_result.dart b/packages/stem/lib/src/workflow/core/workflow_result.dart index 425f23b7..a394fd2b 100644 --- a/packages/stem/lib/src/workflow/core/workflow_result.dart +++ b/packages/stem/lib/src/workflow/core/workflow_result.dart @@ -1,4 +1,5 @@ import 'package:stem/src/bootstrap/workflow_app.dart' show StemWorkflowApp; +import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/workflow/core/run_state.dart'; import 'package:stem/src/workflow/core/workflow_status.dart'; import 'package:stem/stem.dart' show StemWorkflowApp; @@ -60,6 +61,26 @@ class WorkflowResult { return resolved; } + /// Decodes the raw persisted workflow result with [codec]. + TResult? payloadAs({required PayloadCodec codec}) { + final stored = rawResult; + if (stored == null) return null; + return codec.decode(stored); + } + + /// Decodes the raw persisted workflow result with a JSON decoder. + TResult? payloadJson({ + required TResult Function(Map payload) decode, + String? typeName, + }) { + final stored = rawResult; + if (stored == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(stored); + } + /// Untyped payload stored by the workflow, useful for legacy consumers or /// debugging scenarios. final Object? rawResult; diff --git a/packages/stem/test/unit/workflow/workflow_result_test.dart b/packages/stem/test/unit/workflow/workflow_result_test.dart index 13afd9e2..6debabc6 100644 --- a/packages/stem/test/unit/workflow/workflow_result_test.dart +++ b/packages/stem/test/unit/workflow/workflow_result_test.dart @@ -1,6 +1,4 @@ -import 'package:stem/src/workflow/core/run_state.dart'; -import 'package:stem/src/workflow/core/workflow_result.dart'; -import 'package:stem/src/workflow/core/workflow_status.dart'; +import 'package:stem/stem.dart'; import 'package:test/test.dart'; void main() { @@ -48,6 +46,41 @@ void main() { expect(result.requiredValue(), 42); }); + test('WorkflowResult exposes raw payload decode helpers', () { + final state = RunState( + id: 'run-1', + workflow: 'demo', + status: WorkflowStatus.completed, + cursor: 0, + params: const {}, + createdAt: DateTime.utc(2025), + updatedAt: DateTime.utc(2025), + ); + final codec = PayloadCodec>.map( + encode: (value) => value, + decode: (json) => json, + typeName: 'ReceiptMap', + ); + final result = WorkflowResult( + runId: 'run-1', + status: WorkflowStatus.completed, + state: state, + rawResult: const {'id': 'receipt-1'}, + ); + + expect( + result.payloadAs>(codec: codec), + equals(const {'id': 'receipt-1'}), + ); + expect( + result.payloadJson<_WorkflowReceipt>( + decode: _WorkflowReceipt.fromJson, + ), + isA<_WorkflowReceipt>() + .having((value) => value.id, 'id', 'receipt-1'), + ); + }); + test('WorkflowResult.requiredValue throws when value is absent', () { final state = RunState( id: 'run-1', @@ -77,3 +110,13 @@ void main() { expect(result.valueOr(7), 7); }); } + +class _WorkflowReceipt { + const _WorkflowReceipt({required this.id}); + + factory _WorkflowReceipt.fromJson(Map json) { + return _WorkflowReceipt(id: json['id'] as String); + } + + final String id; +} From bd0b206e06e6c65697612dd879c46097841a8e01 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 08:21:14 -0500 Subject: [PATCH 192/302] Add run state DTO decode helpers --- .site/docs/workflows/starting-and-waiting.md | 4 ++ packages/stem/CHANGELOG.md | 4 ++ packages/stem/README.md | 4 ++ .../stem/lib/src/workflow/core/run_state.dart | 43 ++++++++++++++++ .../workflow_metadata_views_test.dart | 49 ++++++++++++++++++- 5 files changed, 103 insertions(+), 1 deletion(-) diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 1eb7d7d4..4044891e 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -90,6 +90,10 @@ results, prefer `decodeJson:` over a manual raw-payload cast. If you already have a raw `WorkflowResult`, use `result.payloadJson(...)` or `result.payloadAs(codec: ...)` to decode the stored workflow result without another cast/closure. +If you are inspecting the underlying `RunState` directly, use +`state.resultJson(...)`, `state.resultAs(codec: ...)`, +`state.suspensionPayloadJson(...)`, or +`state.suspensionPayloadAs(codec: ...)` instead of manual raw-map casts. Use the returned `WorkflowResult` when you need: diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index be8b9a6f..f0a180ea 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -34,6 +34,10 @@ - Added `WorkflowResult.payloadJson(...)` and `payloadAs(codec: ...)` so raw workflow completion results can decode whole DTO payloads without another cast/closure. +- Added `RunState.resultJson(...)`, `resultAs(codec: ...)`, + `suspensionPayloadJson(...)`, and `suspensionPayloadAs(codec: ...)` so raw + workflow-store inspection paths can decode DTO payloads without manual + casts. - Added `arg()`, `argOr()`, and `requiredArg()` on `TaskContext` and `TaskInvocationContext`, and taught both contexts to retain the current task args so manual handlers and isolate entrypoints can read typed inputs diff --git a/packages/stem/README.md b/packages/stem/README.md index 09c61a01..acbbe752 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -887,6 +887,10 @@ if (result?.isCompleted == true) { If you already have a raw `WorkflowResult`, use `result.payloadJson(...)` or `result.payloadAs(codec: ...)` to decode the stored workflow result without another cast/closure. +If you are inspecting the underlying `RunState` directly, use +`state.resultJson(...)`, `state.resultAs(codec: ...)`, +`state.suspensionPayloadJson(...)`, or +`state.suspensionPayloadAs(codec: ...)` instead of manual raw-map casts. In the example above, these calls inside `run(...)`: diff --git a/packages/stem/lib/src/workflow/core/run_state.dart b/packages/stem/lib/src/workflow/core/run_state.dart index d1efd514..f135310d 100644 --- a/packages/stem/lib/src/workflow/core/run_state.dart +++ b/packages/stem/lib/src/workflow/core/run_state.dart @@ -1,4 +1,5 @@ import 'package:stem/src/core/clock.dart'; +import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; import 'package:stem/src/workflow/core/workflow_runtime_metadata.dart'; import 'package:stem/src/workflow/core/workflow_status.dart'; @@ -89,6 +90,26 @@ class RunState { /// Final result payload when the run completes. final Object? result; + /// Decodes the final result payload with [codec]. + TResult? resultAs({required PayloadCodec codec}) { + final stored = result; + if (stored == null) return null; + return codec.decode(stored); + } + + /// Decodes the final result payload with a JSON decoder. + TResult? resultJson({ + required TResult Function(Map payload) decode, + String? typeName, + }) { + final stored = result; + if (stored == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(stored); + } + /// Topic that the run is currently waiting on, if any. final String? waitTopic; @@ -158,6 +179,28 @@ class RunState { /// Resume payload delivered to the suspended run, when present. Object? get suspensionPayload => suspensionData?['payload']; + /// Decodes the suspension payload with [codec], when present. + TPayload? suspensionPayloadAs({ + required PayloadCodec codec, + }) { + final stored = suspensionPayload; + if (stored == null) return null; + return codec.decode(stored); + } + + /// Decodes the suspension payload with a JSON decoder, when present. + TPayload? suspensionPayloadJson({ + required TPayload Function(Map payload) decode, + String? typeName, + }) { + final stored = suspensionPayload; + if (stored == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(stored); + } + /// Timestamp when a matching event was delivered for this suspension. DateTime? get suspensionDeliveredAt => _dateFromJson(suspensionData?['deliveredAt']); diff --git a/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart b/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart index 2302703f..2a375b28 100644 --- a/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart +++ b/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart @@ -49,6 +49,16 @@ void main() { state.suspensionPayload, equals(const {'invoiceId': 'inv-1'}), ); + expect( + state.suspensionPayloadJson<_InvoicePayload>( + decode: _InvoicePayload.fromJson, + ), + isA<_InvoicePayload>().having( + (value) => value.invoiceId, + 'invoiceId', + 'inv-1', + ), + ); }); test('exposes runtime queue and serialization metadata', () { @@ -88,6 +98,29 @@ void main() { expect(state.encryptionEnabled, isTrue); expect(state.streamId, equals('invoice_run-2')); }); + + test('decodes raw result payloads as DTOs', () { + final state = RunState( + id: 'run-3', + workflow: 'invoice', + status: WorkflowStatus.completed, + cursor: 2, + params: const {'tenant': 'acme'}, + createdAt: DateTime.utc(2026, 2, 25), + result: const {'invoiceId': 'inv-2'}, + ); + + expect( + state.resultJson<_InvoicePayload>( + decode: _InvoicePayload.fromJson, + ), + isA<_InvoicePayload>().having( + (value) => value.invoiceId, + 'invoiceId', + 'inv-2', + ), + ); + }); }); group('Workflow watcher metadata getters', () { @@ -153,7 +186,11 @@ void main() { value: 'ok', position: 2, ); - const plain = WorkflowStepEntry(name: 'finalize', value: null, position: 3); + const plain = WorkflowStepEntry( + name: 'finalize', + value: null, + position: 3, + ); expect(step.baseName, equals('approval')); expect(step.iteration, equals(3)); @@ -162,3 +199,13 @@ void main() { }); }); } + +class _InvoicePayload { + const _InvoicePayload({required this.invoiceId}); + + factory _InvoicePayload.fromJson(Map json) { + return _InvoicePayload(invoiceId: json['invoiceId'] as String); + } + + final String invoiceId; +} From df5248d75b833037034aae54c5f4f47ec7f91b3b Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 08:25:37 -0500 Subject: [PATCH 193/302] Add workflow watcher and checkpoint DTO helpers --- .site/docs/workflows/starting-and-waiting.md | 2 + .../docs/workflows/suspensions-and-events.md | 2 + packages/stem/CHANGELOG.md | 5 +++ packages/stem/README.md | 6 ++- packages/stem/example/durable_watchers.dart | 6 +++ .../workflow/core/workflow_step_entry.dart | 22 ++++++++++ .../src/workflow/core/workflow_watcher.dart | 41 +++++++++++++++++++ .../workflow_metadata_views_test.dart | 32 ++++++++++++++- 8 files changed, 114 insertions(+), 2 deletions(-) diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 4044891e..3c22a6ab 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -94,6 +94,8 @@ If you are inspecting the underlying `RunState` directly, use `state.resultJson(...)`, `state.resultAs(codec: ...)`, `state.suspensionPayloadJson(...)`, or `state.suspensionPayloadAs(codec: ...)` instead of manual raw-map casts. +Checkpoint entries from `viewCheckpoints(...)` expose the same convenience +surface via `entry.valueJson(...)` and `entry.valueAs(codec: ...)`. Use the returned `WorkflowResult` when you need: diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index 38ed39a6..293b7d69 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -23,6 +23,8 @@ await ctx.sleepFor(duration: const Duration(milliseconds: 200)); `awaitEvent(topic, deadline: ...)` records a durable watcher. External code can resume those runs through the runtime API by emitting a payload for the topic. +When you inspect watcher entries directly, use `watcher.payloadJson(...)` or +`watcher.payloadAs(codec: ...)` instead of manual raw-map casts. Typical flow: diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index f0a180ea..7da85826 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -38,6 +38,11 @@ `suspensionPayloadJson(...)`, and `suspensionPayloadAs(codec: ...)` so raw workflow-store inspection paths can decode DTO payloads without manual casts. +- Added `WorkflowWatcher.payloadJson(...)`, `payloadAs(codec: ...)`, + `WorkflowWatcherResolution.payloadJson(...)`, `payloadAs(codec: ...)`, and + `WorkflowStepEntry.valueJson(...)` / `valueAs(codec: ...)` so raw watcher + and checkpoint inspection paths can decode DTO payloads without manual + casts. - Added `arg()`, `argOr()`, and `requiredArg()` on `TaskContext` and `TaskInvocationContext`, and taught both contexts to retain the current task args so manual handlers and isolate entrypoints can read typed inputs diff --git a/packages/stem/README.md b/packages/stem/README.md index acbbe752..87ceab34 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -891,6 +891,8 @@ If you are inspecting the underlying `RunState` directly, use `state.resultJson(...)`, `state.resultAs(codec: ...)`, `state.suspensionPayloadJson(...)`, or `state.suspensionPayloadAs(codec: ...)` instead of manual raw-map casts. +Checkpoint entries from `viewCheckpoints(...)` now expose the same convenience +surface via `entry.valueJson(...)` and `entry.valueAs(codec: ...)`. In the example above, these calls inside `run(...)`: @@ -1119,7 +1121,9 @@ backend metadata under `stem.unique.duplicates`. - Event waits are durable watchers. When a step calls `awaitEvent`, the runtime registers the run in the store so the next emitted payload is persisted atomically and delivered exactly once on resume. Operators can inspect - suspended runs via `WorkflowStore.listWatchers` or `runsWaitingOn`. + suspended runs via `WorkflowStore.listWatchers` or `runsWaitingOn`. When the + watcher payload is a DTO, prefer `watcher.payloadJson(...)` or + `watcher.payloadAs(codec: ...)`. - Checkpoints act as heartbeats. Every successful `saveStep` refreshes the run's `updatedAt` timestamp so operators (and future reclaim logic) can distinguish actively-owned runs from ones that need recovery. diff --git a/packages/stem/example/durable_watchers.dart b/packages/stem/example/durable_watchers.dart index dffde485..3af51694 100644 --- a/packages/stem/example/durable_watchers.dart +++ b/packages/stem/example/durable_watchers.dart @@ -48,7 +48,13 @@ Future main() async { print( 'Run ${watcher.runId} waiting on ${watcher.topic} (step ${watcher.stepName})', ); + final payload = watcher.payloadJson<_ShipmentReadyEvent>( + decode: _ShipmentReadyEvent.fromJson, + ); print('Watcher metadata: ${watcher.data}'); + if (payload != null) { + print('Watcher payload DTO: ${payload.trackingId}'); + } } await shipmentReadyEvent.emit( diff --git a/packages/stem/lib/src/workflow/core/workflow_step_entry.dart b/packages/stem/lib/src/workflow/core/workflow_step_entry.dart index 250bb3cb..71e3cf3e 100644 --- a/packages/stem/lib/src/workflow/core/workflow_step_entry.dart +++ b/packages/stem/lib/src/workflow/core/workflow_step_entry.dart @@ -1,3 +1,5 @@ +import 'package:stem/src/core/payload_codec.dart'; + /// Persisted step checkpoint metadata for a workflow run. class WorkflowStepEntry { /// Creates a workflow step entry snapshot. @@ -30,6 +32,26 @@ class WorkflowStepEntry { /// Optional timestamp when the checkpoint was recorded. final DateTime? completedAt; + /// Decodes the persisted checkpoint value with [codec], when present. + TValue? valueAs({required PayloadCodec codec}) { + final stored = value; + if (stored == null) return null; + return codec.decode(stored); + } + + /// Decodes the persisted checkpoint value with a JSON decoder, when present. + TValue? valueJson({ + required TValue Function(Map payload) decode, + String? typeName, + }) { + final stored = value; + if (stored == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(stored); + } + /// Base step name without any auto-version suffix. String get baseName { final hashIndex = name.indexOf('#'); diff --git a/packages/stem/lib/src/workflow/core/workflow_watcher.dart b/packages/stem/lib/src/workflow/core/workflow_watcher.dart index 9ec150b6..1ae5bb10 100644 --- a/packages/stem/lib/src/workflow/core/workflow_watcher.dart +++ b/packages/stem/lib/src/workflow/core/workflow_watcher.dart @@ -1,4 +1,5 @@ import 'package:stem/src/core/clock.dart'; +import 'package:stem/src/core/payload_codec.dart'; /// Describes a workflow event watcher registered by the runtime. class WorkflowWatcher { @@ -54,6 +55,26 @@ class WorkflowWatcher { /// Effective payload snapshot captured at suspension time. Object? get payload => data['payload']; + /// Decodes the captured watcher payload with [codec], when present. + TPayload? payloadAs({required PayloadCodec codec}) { + final stored = payload; + if (stored == null) return null; + return codec.decode(stored); + } + + /// Decodes the captured watcher payload with a JSON decoder, when present. + TPayload? payloadJson({ + required TPayload Function(Map payload) decode, + String? typeName, + }) { + final stored = payload; + if (stored == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(stored); + } + /// Timestamp when suspension was recorded. DateTime? get suspendedAt => _dateFromJson(data['suspendedAt']); @@ -121,6 +142,26 @@ class WorkflowWatcherResolution { /// Resume payload delivered to workflow step. Object? get payload => resumeData['payload']; + /// Decodes the resume payload with [codec], when present. + TPayload? payloadAs({required PayloadCodec codec}) { + final stored = payload; + if (stored == null) return null; + return codec.decode(stored); + } + + /// Decodes the resume payload with a JSON decoder, when present. + TPayload? payloadJson({ + required TPayload Function(Map payload) decode, + String? typeName, + }) { + final stored = payload; + if (stored == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(stored); + } + /// Timestamp when event delivery was recorded. DateTime? get deliveredAt => _dateFromJson(resumeData['deliveredAt']); diff --git a/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart b/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart index 2a375b28..7e4b1dbb 100644 --- a/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart +++ b/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart @@ -158,6 +158,16 @@ void main() { expect(watcher.iteration, equals(2)); expect(watcher.iterationStep, equals('approval#2')); expect(watcher.payload, equals(const {'invoiceId': 'inv-1'})); + expect( + watcher.payloadJson<_InvoicePayload>( + decode: _InvoicePayload.fromJson, + ), + isA<_InvoicePayload>().having( + (value) => value.invoiceId, + 'invoiceId', + 'inv-1', + ), + ); expect(watcher.suspendedAt, equals(DateTime.utc(2026, 2, 25, 0, 1))); expect( watcher.requestedResumeAt, @@ -172,6 +182,16 @@ void main() { expect(resolution.iteration, equals(2)); expect(resolution.iterationStep, equals('approval#2')); expect(resolution.payload, equals(const {'invoiceId': 'inv-1'})); + expect( + resolution.payloadJson<_InvoicePayload>( + decode: _InvoicePayload.fromJson, + ), + isA<_InvoicePayload>().having( + (value) => value.invoiceId, + 'invoiceId', + 'inv-1', + ), + ); expect( resolution.deliveredAt, equals(DateTime.utc(2026, 2, 25, 0, 1, 30)), @@ -183,7 +203,7 @@ void main() { test('parses base name and iteration suffix', () { const step = WorkflowStepEntry( name: 'approval#3', - value: 'ok', + value: {'invoiceId': 'inv-3'}, position: 2, ); const plain = WorkflowStepEntry( @@ -194,6 +214,16 @@ void main() { expect(step.baseName, equals('approval')); expect(step.iteration, equals(3)); + expect( + step.valueJson<_InvoicePayload>( + decode: _InvoicePayload.fromJson, + ), + isA<_InvoicePayload>().having( + (value) => value.invoiceId, + 'invoiceId', + 'inv-3', + ), + ); expect(plain.baseName, equals('finalize')); expect(plain.iteration, isNull); }); From b05913538160c5c2e4bcb92d404ca9ead99de9c4 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 08:28:08 -0500 Subject: [PATCH 194/302] Add task result DTO decode helpers --- .site/docs/core-concepts/tasks.md | 3 ++ packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 3 ++ packages/stem/lib/src/core/task_result.dart | 21 ++++++++++ .../stem/test/unit/core/task_result_test.dart | 40 +++++++++++++++++++ 5 files changed, 70 insertions(+) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index fb46af60..404935f1 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -58,6 +58,9 @@ For low-level DTO waits through `Stem.waitForTask`, prefer If you already have a raw `TaskStatus`, use `status.payloadJson(...)` or `status.payloadAs(codec: ...)` to decode the whole payload DTO without a separate cast/closure. +If you already have a raw `TaskResult`, use `result.payloadJson(...)` +or `result.payloadAs(codec: ...)` to decode the stored task result DTO +without another cast/closure. If your manual task args are DTOs, prefer `TaskDefinition.json(...)` when the type already has `toJson()`. Use `TaskDefinition.codec(...)` when you diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 7da85826..d73230b9 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -31,6 +31,9 @@ - Added `TaskStatus.payloadJson(...)` and `payloadAs(codec: ...)` so existing raw task-status reads can decode whole DTO payloads without another cast/closure. +- Added `TaskResult.payloadJson(...)` and `payloadAs(codec: ...)` so raw typed + task-wait results can decode whole DTO payloads without another + cast/closure. - Added `WorkflowResult.payloadJson(...)` and `payloadAs(codec: ...)` so raw workflow completion results can decode whole DTO payloads without another cast/closure. diff --git a/packages/stem/README.md b/packages/stem/README.md index 87ceab34..ab7d350e 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -933,6 +933,9 @@ through another boundary before waiting. If you already have a raw `TaskStatus`, use `status.payloadJson(...)` or `status.payloadAs(codec: ...)` to decode the whole payload DTO without another cast/closure. +If you already have a raw `TaskResult`, use `result.payloadJson(...)` +or `result.payloadAs(codec: ...)` to decode the stored task result DTO without +another cast/closure. Generated annotated tasks use the same surface: diff --git a/packages/stem/lib/src/core/task_result.dart b/packages/stem/lib/src/core/task_result.dart index 411c0daf..9a07443d 100644 --- a/packages/stem/lib/src/core/task_result.dart +++ b/packages/stem/lib/src/core/task_result.dart @@ -1,4 +1,5 @@ import 'package:stem/src/core/contracts.dart'; +import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/core/stem.dart' show Stem; import 'package:stem/stem.dart' show Stem; @@ -37,6 +38,26 @@ class TaskResult { return resolved; } + /// Decodes the raw persisted task payload with [codec]. + TResult? payloadAs({required PayloadCodec codec}) { + final stored = rawPayload; + if (stored == null) return null; + return codec.decode(stored); + } + + /// Decodes the raw persisted task payload with a JSON decoder. + TResult? payloadJson({ + required TResult Function(Map payload) decode, + String? typeName, + }) { + final stored = rawPayload; + if (stored == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(stored); + } + /// Raw payload stored by the backend (useful for debugging or manual casts). final Object? rawPayload; diff --git a/packages/stem/test/unit/core/task_result_test.dart b/packages/stem/test/unit/core/task_result_test.dart index d4b600a9..f160c64a 100644 --- a/packages/stem/test/unit/core/task_result_test.dart +++ b/packages/stem/test/unit/core/task_result_test.dart @@ -1,4 +1,5 @@ import 'package:stem/src/core/contracts.dart'; +import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/core/task_result.dart'; import 'package:test/test.dart'; @@ -55,6 +56,35 @@ void main() { expect(result.requiredValue(), 42); }); + test('TaskResult exposes raw payload decode helpers', () { + final codec = PayloadCodec>.map( + encode: (value) => value, + decode: (json) => json, + typeName: 'ReceiptMap', + ); + final result = TaskResult( + taskId: 'task-1', + status: TaskStatus( + id: 'task-1', + state: TaskState.succeeded, + attempt: 0, + payload: const {'id': 'receipt-1'}, + ), + rawPayload: const {'id': 'receipt-1'}, + ); + + expect( + result.payloadAs>(codec: codec), + equals(const {'id': 'receipt-1'}), + ); + expect( + result.payloadJson<_TaskReceipt>( + decode: _TaskReceipt.fromJson, + ), + isA<_TaskReceipt>().having((value) => value.id, 'id', 'receipt-1'), + ); + }); + test('TaskResult.requiredValue throws when value is absent', () { final result = TaskResult( taskId: 'task-1', @@ -78,3 +108,13 @@ void main() { expect(result.valueOr(7), 7); }); } + +class _TaskReceipt { + const _TaskReceipt({required this.id}); + + factory _TaskReceipt.fromJson(Map json) { + return _TaskReceipt(id: json['id'] as String); + } + + final String id; +} From 4e0dd445c410dca81aa28d0ef3e7924cb785a186 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 08:31:26 -0500 Subject: [PATCH 195/302] Add group status result decode helpers --- .site/docs/core-concepts/canvas.md | 5 +- packages/stem/CHANGELOG.md | 3 + packages/stem/README.md | 6 ++ .../docs_snippets/lib/canvas_group.dart | 6 +- packages/stem/lib/src/core/contracts.dart | 33 +++++++++ .../stem/test/unit/core/contracts_test.dart | 73 +++++++++++++++++++ 6 files changed, 121 insertions(+), 5 deletions(-) diff --git a/.site/docs/core-concepts/canvas.md b/.site/docs/core-concepts/canvas.md index d8f01c1e..ef8fce43 100644 --- a/.site/docs/core-concepts/canvas.md +++ b/.site/docs/core-concepts/canvas.md @@ -75,8 +75,9 @@ the runtime layer, read the raw backend directly. - `Canvas.chord` preserves the original signature order when building `chordResults`, so you can map results back to inputs deterministically. - `StemApp.getGroupStatus(...)` and `StemClient.getGroupStatus(...)` return the - latest status for each child task. Low-level integrations can still read the - raw backend directly. + latest status for each child task. Use `status.resultValues()` for scalar + child results or `status.resultJson(...)` / `status.resultAs(codec: ...)` for + DTO payloads before dropping down to raw backend reads. ## Removal semantics diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index d73230b9..0c50b98f 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -46,6 +46,9 @@ `WorkflowStepEntry.valueJson(...)` / `valueAs(codec: ...)` so raw watcher and checkpoint inspection paths can decode DTO payloads without manual casts. +- Added `GroupStatus.resultValues()`, `resultJson(...)`, and + `resultAs(codec: ...)` so canvas/group status inspection can decode typed + child results without manually mapping raw `TaskStatus.payload` values. - Added `arg()`, `argOr()`, and `requiredArg()` on `TaskContext` and `TaskInvocationContext`, and taught both contexts to retain the current task args so manual handlers and isolate entrypoints can read typed inputs diff --git a/packages/stem/README.md b/packages/stem/README.md index ab7d350e..f5ab62b3 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -993,6 +993,12 @@ final chordResult = await canvas.chord( print('Body results: ${chordResult.values}'); ``` +If you later inspect the aggregate backend record via +`StemApp.getGroupStatus(...)` or `StemClient.getGroupStatus(...)`, use +`status.resultValues()` for scalar child results or +`status.resultJson(...)` / `status.resultAs(codec: ...)` for DTO payloads +instead of manually mapping `status.results.values`. + ### Task payload encoders By default Stem stores handler arguments/results exactly as provided (JSON-friendly diff --git a/packages/stem/example/docs_snippets/lib/canvas_group.dart b/packages/stem/example/docs_snippets/lib/canvas_group.dart index 8143f495..c3de32af 100644 --- a/packages/stem/example/docs_snippets/lib/canvas_group.dart +++ b/packages/stem/example/docs_snippets/lib/canvas_group.dart @@ -1,5 +1,5 @@ // Canvas group example for documentation. -// ignore_for_file: unused_local_variable, unused_import, dead_code, avoid_print +// ignore_for_file: avoid_print import 'dart:async'; @@ -12,7 +12,7 @@ Future main() async { FunctionTaskHandler( name: 'square', entrypoint: (context, args) async { - final value = args['value'] as int; + final value = args.requiredValue('value'); await Future.delayed(const Duration(milliseconds: 50)); return value * value; }, @@ -38,7 +38,7 @@ Future main() async { }); final groupStatus = await app.getGroupStatus(dispatch.groupId); - final values = groupStatus?.results.values.map((s) => s.payload).toList(); + final values = groupStatus?.resultValues().values.toList(); print('Group results: $values'); await app.close(); diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 8e939c6c..1a13a54a 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2952,6 +2952,39 @@ class GroupStatus { /// Additional metadata for the group. final Map meta; + /// Returns the decoded payload value for each collected child result. + /// + /// When [codec] is supplied, each stored durable payload is decoded through + /// that codec before being returned. + Map resultValues({PayloadCodec? codec}) { + return Map.unmodifiable({ + for (final entry in results.entries) + entry.key: entry.value.payloadValue(codec: codec), + }); + } + + /// Decodes each collected child result as a typed DTO with [codec]. + Map resultAs({required PayloadCodec codec}) { + return Map.unmodifiable({ + for (final entry in results.entries) + entry.key: entry.value.payloadAs(codec: codec), + }); + } + + /// Decodes each collected child result as a typed DTO with a JSON decoder. + Map resultJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + return Map.unmodifiable({ + for (final entry in results.entries) + entry.key: entry.value.payloadJson( + decode: decode, + typeName: typeName, + ), + }); + } + /// The number of completed results. int get completed => results.length; diff --git a/packages/stem/test/unit/core/contracts_test.dart b/packages/stem/test/unit/core/contracts_test.dart index 69a53ac2..d0eb41f8 100644 --- a/packages/stem/test/unit/core/contracts_test.dart +++ b/packages/stem/test/unit/core/contracts_test.dart @@ -239,6 +239,69 @@ void main() { }); }); + group('GroupStatus', () { + test('exposes typed child-result decode helpers', () { + final codec = PayloadCodec>.map( + encode: (value) => value, + decode: (json) => json, + typeName: 'ReceiptMap', + ); + final scalarStatus = GroupStatus( + id: 'grp-1', + expected: 2, + results: { + 'task-1': TaskStatus( + id: 'task-1', + state: TaskState.succeeded, + attempt: 0, + payload: 7, + ), + 'task-2': TaskStatus( + id: 'task-2', + state: TaskState.succeeded, + attempt: 0, + payload: 9, + ), + }, + ); + final dtoStatus = GroupStatus( + id: 'grp-2', + expected: 1, + results: { + 'task-1': TaskStatus( + id: 'task-1', + state: TaskState.succeeded, + attempt: 0, + payload: const {'id': 'receipt-1'}, + ), + }, + ); + + expect( + scalarStatus.resultValues(), + equals({ + 'task-1': 7, + 'task-2': 9, + }), + ); + expect( + dtoStatus.resultAs>(codec: codec), + equals({ + 'task-1': const {'id': 'receipt-1'}, + }), + ); + expect( + dtoStatus.resultJson<_GroupReceipt>( + decode: _GroupReceipt.fromJson, + ), + { + 'task-1': isA<_GroupReceipt>() + .having((value) => value.id, 'id', 'receipt-1'), + }, + ); + }); + }); + group('DeadLetterEntry', () { test('round trips through json', () { final entry = DeadLetterEntry( @@ -401,6 +464,16 @@ void main() { }); } +class _GroupReceipt { + const _GroupReceipt({required this.id}); + + factory _GroupReceipt.fromJson(Map json) { + return _GroupReceipt(id: json['id'] as String); + } + + final String id; +} + class _ReceiptPayload { const _ReceiptPayload({required this.id}); From cc11f964056800a3939972d792fe2e45d4b974ca Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 08:35:06 -0500 Subject: [PATCH 196/302] Add typed payload list readers --- .site/docs/core-concepts/canvas.md | 9 ++-- packages/stem/CHANGELOG.md | 3 ++ .../canvas_patterns/chain_example.dart | 7 ++- .../canvas_patterns/chord_example.dart | 11 ++--- .../docs_snippets/lib/canvas_chain.dart | 13 +++-- .../docs_snippets/lib/canvas_chord.dart | 13 +++-- packages/stem/lib/src/core/payload_map.dart | 32 ++++++++++++ .../stem/test/unit/core/payload_map_test.dart | 49 +++++++++++++++++++ 8 files changed, 115 insertions(+), 22 deletions(-) diff --git a/.site/docs/core-concepts/canvas.md b/.site/docs/core-concepts/canvas.md index ef8fce43..18573185 100644 --- a/.site/docs/core-concepts/canvas.md +++ b/.site/docs/core-concepts/canvas.md @@ -16,7 +16,8 @@ explicit `await app.start()`. ## Chains Chains execute tasks serially. Each step receives the previous result via -`context.meta['chainPrevResult']`. +`context.meta`, so prefer typed reads like +`context.meta.valueOr('chainPrevResult', 'fallback')` over raw casts. ```dart file=/../packages/stem/example/docs_snippets/lib/canvas_chain.dart#canvas-chain @@ -48,8 +49,10 @@ state: ## Chords -Chords combine a group with a callback. Once all body tasks succeed, the callback -runs with `context.meta['chordResults']` populated. +Chords combine a group with a callback. Once all body tasks succeed, the +callback runs with `context.meta['chordResults']` populated. Prefer +`context.meta.valueListOr('chordResults', const [])` over manual list casts +when reading those results. ```dart file=/../packages/stem/example/docs_snippets/lib/canvas_chord.dart#canvas-chord diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 0c50b98f..227e49d6 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -65,6 +65,9 @@ `args.valueOr(...)`, and `ctx.params.requiredValue(...)` so manual tasks and workflows can decode scalars and codec-backed DTOs without repeating raw map casts. +- Added `valueList()`, `valueListOr(...)`, and `requiredValueList(...)` to + the shared payload-map helpers so canvas chains/chords and other meta-driven + paths can decode typed list payloads without manual list casts. - Added `WorkflowExecutionContext` as the shared typed execution context for flow steps and script checkpoints, and taught `stem_builder` to accept that shared context type directly in annotated workflow methods. diff --git a/packages/stem/example/canvas_patterns/chain_example.dart b/packages/stem/example/canvas_patterns/chain_example.dart index 20968897..18102ef6 100644 --- a/packages/stem/example/canvas_patterns/chain_example.dart +++ b/packages/stem/example/canvas_patterns/chain_example.dart @@ -11,14 +11,17 @@ Future main() async { FunctionTaskHandler( name: 'enrich.user', entrypoint: (context, args) async { - final prev = context.meta['chainPrevResult'] as String? ?? 'Friend'; + final prev = context.meta.valueOr('chainPrevResult', 'Friend'); return '$prev Lovelace'; }, ), FunctionTaskHandler( name: 'send.email', entrypoint: (context, args) async { - final fullName = context.meta['chainPrevResult'] as String? ?? 'Friend'; + final fullName = context.meta.valueOr( + 'chainPrevResult', + 'Friend', + ); print('Sending email to $fullName'); return null; }, diff --git a/packages/stem/example/canvas_patterns/chord_example.dart b/packages/stem/example/canvas_patterns/chord_example.dart index 32f5e7e1..f504e1ec 100644 --- a/packages/stem/example/canvas_patterns/chord_example.dart +++ b/packages/stem/example/canvas_patterns/chord_example.dart @@ -8,17 +8,16 @@ Future main() async { name: 'fetch.metric', entrypoint: (context, args) async { await Future.delayed(const Duration(milliseconds: 40)); - return args['value'] as int; + return args.requiredValue('value'); }, ), FunctionTaskHandler( name: 'aggregate.metric', entrypoint: (context, args) async { - final values = - (context.meta['chordResults'] as List?) - ?.whereType() - .toList() ?? - const []; + final values = context.meta.valueListOr( + 'chordResults', + const [], + ); final sum = values.fold(0, (a, b) => a + b); print('Aggregated result: $sum'); return null; diff --git a/packages/stem/example/docs_snippets/lib/canvas_chain.dart b/packages/stem/example/docs_snippets/lib/canvas_chain.dart index 2bebd54a..27d9b34b 100644 --- a/packages/stem/example/docs_snippets/lib/canvas_chain.dart +++ b/packages/stem/example/docs_snippets/lib/canvas_chain.dart @@ -1,5 +1,5 @@ // Canvas chain example for documentation. -// ignore_for_file: unused_local_variable, unused_import, dead_code, avoid_print +// ignore_for_file: avoid_print import 'dart:async'; @@ -16,15 +16,20 @@ Future main() async { FunctionTaskHandler( name: 'enrich.user', entrypoint: (context, args) async { - final prev = context.meta['chainPrevResult'] as String? ?? 'Friend'; + final prev = context.meta.valueOr( + 'chainPrevResult', + 'Friend', + ); return '$prev Lovelace'; }, ), FunctionTaskHandler( name: 'send.email', entrypoint: (context, args) async { - final fullName = - context.meta['chainPrevResult'] as String? ?? 'Friend'; + final fullName = context.meta.valueOr( + 'chainPrevResult', + 'Friend', + ); print('Sending email to $fullName'); return null; }, diff --git a/packages/stem/example/docs_snippets/lib/canvas_chord.dart b/packages/stem/example/docs_snippets/lib/canvas_chord.dart index ed701572..84f77f55 100644 --- a/packages/stem/example/docs_snippets/lib/canvas_chord.dart +++ b/packages/stem/example/docs_snippets/lib/canvas_chord.dart @@ -1,5 +1,5 @@ // Canvas chord example for documentation. -// ignore_for_file: unused_local_variable, unused_import, dead_code, avoid_print +// ignore_for_file: avoid_print import 'dart:async'; @@ -13,17 +13,16 @@ Future main() async { name: 'fetch.metric', entrypoint: (context, args) async { await Future.delayed(const Duration(milliseconds: 40)); - return args['value'] as int; + return args.requiredValue('value'); }, ), FunctionTaskHandler( name: 'aggregate.metric', entrypoint: (context, args) async { - final values = - (context.meta['chordResults'] as List?) - ?.whereType() - .toList() ?? - const []; + final values = context.meta.valueListOr( + 'chordResults', + const [], + ); final sum = values.fold(0, (a, b) => a + b); print('Aggregated result: $sum'); return null; diff --git a/packages/stem/lib/src/core/payload_map.dart b/packages/stem/lib/src/core/payload_map.dart index dc049c7f..12988919 100644 --- a/packages/stem/lib/src/core/payload_map.dart +++ b/packages/stem/lib/src/core/payload_map.dart @@ -27,4 +27,36 @@ extension PayloadMapX on Map { } return value(key, codec: codec) as T; } + + /// Returns the decoded list value for [key], or `null` when it is absent. + /// + /// When [codec] is supplied, each stored durable payload is decoded through + /// that codec before being returned. + List? valueList(String key, {PayloadCodec? codec}) { + final payload = this[key]; + if (payload == null) return null; + final values = payload as List; + if (codec != null) { + return List.unmodifiable(values.map(codec.decode)); + } + return List.unmodifiable(values.cast()); + } + + /// Returns the decoded list value for [key], or [fallback] when it is + /// absent. + List valueListOr( + String key, + List fallback, { + PayloadCodec? codec, + }) { + return valueList(key, codec: codec) ?? fallback; + } + + /// Returns the decoded list value for [key], throwing when it is missing. + List requiredValueList(String key, {PayloadCodec? codec}) { + if (!containsKey(key) || this[key] == null) { + throw StateError("Missing required payload key '$key'."); + } + return valueList(key, codec: codec)!; + } } diff --git a/packages/stem/test/unit/core/payload_map_test.dart b/packages/stem/test/unit/core/payload_map_test.dart index 952ea832..a033175b 100644 --- a/packages/stem/test/unit/core/payload_map_test.dart +++ b/packages/stem/test/unit/core/payload_map_test.dart @@ -44,6 +44,55 @@ void main() { expect(draft.documentId, 'doc-42'); }); + + test('valueList reads typed scalar lists', () { + const payload = { + 'scores': [1, 2, 3], + }; + + expect(payload.valueList('scores'), [1, 2, 3]); + expect(payload.valueList('missing'), isNull); + }); + + test('valueListOr returns fallback for missing lists', () { + const payload = { + 'scores': [1, 2, 3], + }; + + expect(payload.valueListOr('scores', const [9]), [1, 2, 3]); + expect(payload.valueListOr('missing', const [9]), [9]); + }); + + test('requiredValueList throws for missing payload keys', () { + const payload = {'name': 'Stem'}; + + expect( + () => payload.requiredValueList('labels'), + throwsA( + isA().having( + (error) => error.message, + 'message', + "Missing required payload key 'labels'.", + ), + ), + ); + }); + + test('requiredValueList decodes codec-backed DTO lists', () { + final payload = { + 'drafts': const [ + {'documentId': 'doc-42'}, + {'documentId': 'doc-99'}, + ], + }; + + final drafts = payload.requiredValueList<_ApprovalDraft>( + 'drafts', + codec: _approvalDraftCodec, + ); + + expect(drafts.map((draft) => draft.documentId), ['doc-42', 'doc-99']); + }); }); } From 5f30d8ad8ee6cb0533ef64aa61193b0cc4e1f184 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 08:38:16 -0500 Subject: [PATCH 197/302] Add direct json payload entry readers --- .../workflows/context-and-serialization.md | 2 + packages/stem/CHANGELOG.md | 4 ++ packages/stem/README.md | 9 +++- .../example/docs_snippets/lib/workflows.dart | 14 ++++-- packages/stem/lib/src/core/contracts.dart | 41 +++++++++++++++++ packages/stem/lib/src/core/payload_map.dart | 45 +++++++++++++++++++ .../core/workflow_execution_context.dart | 42 +++++++++++++++++ .../core/workflow_script_context.dart | 42 +++++++++++++++++ .../stem/test/unit/core/payload_map_test.dart | 31 +++++++++++++ 9 files changed, 224 insertions(+), 6 deletions(-) diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index 5ac4b541..4c5af807 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -36,6 +36,8 @@ Depending on the context type, you can access: - workflow params and previous results - `param()` / `requiredParam()` for typed access to workflow start params +- `paramJson()` / `requiredParamJson()` for nested DTO params without a + separate codec constant - `previousValue()` / `requiredPreviousValue()` for typed access to the prior step or checkpoint result - `sleepUntilResumed(...)` for common sleep/retry loops diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 227e49d6..644b0542 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -65,6 +65,10 @@ `args.valueOr(...)`, and `ctx.params.requiredValue(...)` so manual tasks and workflows can decode scalars and codec-backed DTOs without repeating raw map casts. +- Added direct JSON entry readers like `args.requiredValueJson(...)`, + `ctx.requiredParamJson(...)`, and shared `valueJson(...)` / + `requiredValueJson(...)` helpers so nested DTO payload fields no longer need + a separate `PayloadCodec` constant. - Added `valueList()`, `valueListOr(...)`, and `requiredValueList(...)` to the shared payload-map helpers so canvas chains/chords and other meta-driven paths can decode typed list payloads without manual list casts. diff --git a/packages/stem/README.md b/packages/stem/README.md index f5ab62b3..9f5e1b4c 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -436,6 +436,8 @@ Inside a script checkpoint you can access the same metadata as `FlowContext`: - `step.previousResult` contains the prior step’s persisted value. - `step.param()` / `step.requiredParam()` read workflow params without repeating raw map lookups. +- `step.paramJson()` / `step.requiredParamJson()` decode nested DTO + params without a separate codec constant. - `step.previousValue()` reads the prior persisted value without repeating manual casts. - `step.iteration` tracks the current auto-version suffix when @@ -485,8 +487,11 @@ final approvalsFlow = Flow( name: 'approvals.flow', build: (flow) { flow.step('draft', (ctx) async { - final payload = ctx.requiredParam>('draft'); - return payload.requiredValue('documentId'); + final draft = ctx.requiredParamJson( + 'draft', + decode: ApprovalDraft.fromJson, + ); + return draft.documentId; }); flow.step('manager-review', (ctx) async { diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index 15b1e455..e2e1ff13 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -55,8 +55,11 @@ class ApprovalsFlow { name: 'approvals.flow', build: (flow) { flow.step('draft', (ctx) async { - final payload = ctx.requiredParam>('draft'); - return payload.requiredValue('documentId'); + final draft = ctx.requiredParamJson( + 'draft', + decode: ApprovalDraft.fromJson, + ); + return draft.documentId; }); flow.step('manager-review', (ctx) async { @@ -167,8 +170,11 @@ class ApprovalsAnnotatedWorkflow { @WorkflowStep() Future draft({FlowContext? context}) async { final ctx = context!; - final payload = ctx.requiredParam>('draft'); - return payload.requiredValue('documentId'); + final draft = ctx.requiredParamJson( + 'draft', + decode: ApprovalDraft.fromJson, + ); + return draft.documentId; } @WorkflowStep(name: 'manager-review') diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 1a13a54a..50a2aebb 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -1756,6 +1756,47 @@ extension TaskInputContextArgs on TaskInputContext { T requiredArg(String key, {PayloadCodec? codec}) { return args.requiredValue(key, codec: codec); } + + /// Returns the decoded task arg DTO for [key], or `null`. + T? argJson( + String key, { + required T Function(Map payload) decode, + String? typeName, + }) { + return args.valueJson( + key, + decode: decode, + typeName: typeName, + ); + } + + /// Returns the decoded task arg DTO for [key], or [fallback]. + T argJsonOr( + String key, + T fallback, { + required T Function(Map payload) decode, + String? typeName, + }) { + return args.valueJsonOr( + key, + fallback, + decode: decode, + typeName: typeName, + ); + } + + /// Returns the decoded task arg DTO for [key], throwing when absent. + T requiredArgJson( + String key, { + required T Function(Map payload) decode, + String? typeName, + }) { + return args.requiredValueJson( + key, + decode: decode, + typeName: typeName, + ); + } } /// Context passed to handler implementations during execution. diff --git a/packages/stem/lib/src/core/payload_map.dart b/packages/stem/lib/src/core/payload_map.dart index 12988919..ee304474 100644 --- a/packages/stem/lib/src/core/payload_map.dart +++ b/packages/stem/lib/src/core/payload_map.dart @@ -28,6 +28,51 @@ extension PayloadMapX on Map { return value(key, codec: codec) as T; } + /// Decodes the value for [key] as a typed DTO with a JSON decoder. + T? valueJson( + String key, { + required T Function(Map payload) decode, + String? typeName, + }) { + final payload = this[key]; + if (payload == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(payload); + } + + /// Decodes the value for [key] as a typed DTO, or [fallback] when absent. + T valueJsonOr( + String key, + T fallback, { + required T Function(Map payload) decode, + String? typeName, + }) { + return valueJson( + key, + decode: decode, + typeName: typeName, + ) ?? + fallback; + } + + /// Decodes the value for [key] as a typed DTO, throwing when absent. + T requiredValueJson( + String key, { + required T Function(Map payload) decode, + String? typeName, + }) { + if (!containsKey(key) || this[key] == null) { + throw StateError("Missing required payload key '$key'."); + } + return valueJson( + key, + decode: decode, + typeName: typeName, + ) as T; + } + /// Returns the decoded list value for [key], or `null` when it is absent. /// /// When [codec] is supplied, each stored durable payload is decoded through diff --git a/packages/stem/lib/src/workflow/core/workflow_execution_context.dart b/packages/stem/lib/src/workflow/core/workflow_execution_context.dart index 4e2ee32b..558eaad3 100644 --- a/packages/stem/lib/src/workflow/core/workflow_execution_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_execution_context.dart @@ -58,6 +58,48 @@ extension WorkflowExecutionContextParams on WorkflowExecutionContext { T requiredParam(String key, {PayloadCodec? codec}) { return params.requiredValue(key, codec: codec); } + + /// Returns the decoded workflow parameter DTO for [key], or `null`. + T? paramJson( + String key, { + required T Function(Map payload) decode, + String? typeName, + }) { + return params.valueJson( + key, + decode: decode, + typeName: typeName, + ); + } + + /// Returns the decoded workflow parameter DTO for [key], or [fallback]. + T paramJsonOr( + String key, + T fallback, { + required T Function(Map payload) decode, + String? typeName, + }) { + return params.valueJsonOr( + key, + fallback, + decode: decode, + typeName: typeName, + ); + } + + /// Returns the decoded workflow parameter DTO for [key], throwing when + /// absent. + T requiredParamJson( + String key, { + required T Function(Map payload) decode, + String? typeName, + }) { + return params.requiredValueJson( + key, + decode: decode, + typeName: typeName, + ); + } } /// Typed read helpers for prior workflow step and checkpoint values. diff --git a/packages/stem/lib/src/workflow/core/workflow_script_context.dart b/packages/stem/lib/src/workflow/core/workflow_script_context.dart index 7e13e601..91208718 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script_context.dart @@ -48,6 +48,48 @@ extension WorkflowScriptContextParams on WorkflowScriptContext { T requiredParam(String key, {PayloadCodec? codec}) { return params.requiredValue(key, codec: codec); } + + /// Returns the decoded workflow parameter DTO for [key], or `null`. + T? paramJson( + String key, { + required T Function(Map payload) decode, + String? typeName, + }) { + return params.valueJson( + key, + decode: decode, + typeName: typeName, + ); + } + + /// Returns the decoded workflow parameter DTO for [key], or [fallback]. + T paramJsonOr( + String key, + T fallback, { + required T Function(Map payload) decode, + String? typeName, + }) { + return params.valueJsonOr( + key, + fallback, + decode: decode, + typeName: typeName, + ); + } + + /// Returns the decoded workflow parameter DTO for [key], throwing when + /// absent. + T requiredParamJson( + String key, { + required T Function(Map payload) decode, + String? typeName, + }) { + return params.requiredValueJson( + key, + decode: decode, + typeName: typeName, + ); + } } /// Context provided to each script checkpoint invocation. Mirrors diff --git a/packages/stem/test/unit/core/payload_map_test.dart b/packages/stem/test/unit/core/payload_map_test.dart index a033175b..6e37efff 100644 --- a/packages/stem/test/unit/core/payload_map_test.dart +++ b/packages/stem/test/unit/core/payload_map_test.dart @@ -45,6 +45,37 @@ void main() { expect(draft.documentId, 'doc-42'); }); + test('valueJson decodes DTO values without a codec constant', () { + final payload = { + 'draft': const {'documentId': 'doc-42'}, + }; + + final draft = payload.valueJson<_ApprovalDraft>( + 'draft', + decode: _ApprovalDraft.fromJson, + ); + + expect(draft?.documentId, 'doc-42'); + }); + + test('requiredValueJson throws for missing payload keys', () { + const payload = {'name': 'Stem'}; + + expect( + () => payload.requiredValueJson<_ApprovalDraft>( + 'draft', + decode: _ApprovalDraft.fromJson, + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + "Missing required payload key 'draft'.", + ), + ), + ); + }); + test('valueList reads typed scalar lists', () { const payload = { 'scores': [1, 2, 3], From f385b321a03850beeb3a7c70e850704bfba258f9 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 08:41:24 -0500 Subject: [PATCH 198/302] Add workflow previous and resume json helpers --- .../workflows/context-and-serialization.md | 11 +- packages/stem/CHANGELOG.md | 4 + packages/stem/README.md | 13 +- .../core/workflow_execution_context.dart | 42 ++++++ .../src/workflow/core/workflow_resume.dart | 55 +++++++ .../unit/workflow/workflow_resume_test.dart | 137 ++++++++++++++++++ 6 files changed, 256 insertions(+), 6 deletions(-) diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index 4c5af807..ef87053c 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -40,12 +40,18 @@ Depending on the context type, you can access: separate codec constant - `previousValue()` / `requiredPreviousValue()` for typed access to the prior step or checkpoint result +- `previousJson()` / `requiredPreviousJson()` for prior DTO results + without a separate codec constant - `sleepUntilResumed(...)` for common sleep/retry loops - `waitForEventValue(...)` for common event waits +- `waitForEventValueJson(...)` for DTO event waits without a separate codec + constant - `event.awaitOn(step)` when a flow deliberately wants the lower-level `FlowStepControl` suspend-first path on a typed event ref - `takeResumeData()` for event-driven resumes - `takeResumeValue(codec: ...)` for typed event-driven resumes +- `takeResumeJson(...)` for DTO event-driven resumes without a separate + codec constant - `idempotencyKey(...)` - direct child-workflow start helpers such as `ref.start(context, params: value)` and @@ -144,8 +150,9 @@ Prefer the higher-level helpers first: continue on resume - `waitForEventValue(...)` when the step/checkpoint is waiting on one event -Drop down to `takeResumeData()` / `takeResumeValue(...)` only when you need -custom branching around resume payloads. +Drop down to `takeResumeData()`, `takeResumeValue(...)`, or +`takeResumeJson(...)` only when you need custom branching around resume +payloads. The runnable `annotated_workflows` example demonstrates both the context-aware and plain serializable forms. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 644b0542..7a036639 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -69,6 +69,10 @@ `ctx.requiredParamJson(...)`, and shared `valueJson(...)` / `requiredValueJson(...)` helpers so nested DTO payload fields no longer need a separate `PayloadCodec` constant. +- Added `previousJson(...)`, `requiredPreviousJson(...)`, + `takeResumeJson(...)`, `waitForEventValueJson(...)`, and + `waitForEventJson(...)` so workflow steps and checkpoints can decode prior + DTO results and resume/event payloads without separate codec constants. - Added `valueList()`, `valueListOr(...)`, and `requiredValueList(...)` to the shared payload-map helpers so canvas chains/chords and other meta-driven paths can decode typed list payloads without manual list casts. diff --git a/packages/stem/README.md b/packages/stem/README.md index 9f5e1b4c..411bdcfc 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -438,8 +438,10 @@ Inside a script checkpoint you can access the same metadata as `FlowContext`: repeating raw map lookups. - `step.paramJson()` / `step.requiredParamJson()` decode nested DTO params without a separate codec constant. -- `step.previousValue()` reads the prior persisted value without repeating - manual casts. +- `step.previousValue()` / `step.requiredPreviousValue()` read the prior + persisted value without repeating manual casts. +- `step.previousJson()` / `step.requiredPreviousJson()` decode prior DTO + results without a separate codec constant. - `step.iteration` tracks the current auto-version suffix when `autoVersion: true` is set. - `step.idempotencyKey('scope')` builds stable outbound identifiers. @@ -451,12 +453,15 @@ Inside a script checkpoint you can access the same metadata as `FlowContext`: - `step.sleepUntilResumed(...)` handles the common sleep-once, continue-on- resume path. - `step.waitForEventValue(...)` handles the common wait-for-one-event path. +- `step.waitForEventValueJson(...)` handles the same path for DTO event + payloads without a separate codec constant. - `event.waitValue(step)` handles the same path when you already have a typed `WorkflowEventRef`. - `event.awaitOn(step)` keeps the lower-level flow-control suspend-first path on that same typed event ref instead of dropping back to a raw topic string. -- `step.takeResumeData()` and `step.takeResumeValue(codec: ...)` surface - payloads from sleeps or awaited events when you need lower-level control. +- `step.takeResumeData()`, `step.takeResumeValue(codec: ...)`, and + `step.takeResumeJson(...)` surface payloads from sleeps or awaited events + when you need lower-level control. ### Current workflow model diff --git a/packages/stem/lib/src/workflow/core/workflow_execution_context.dart b/packages/stem/lib/src/workflow/core/workflow_execution_context.dart index 558eaad3..15ee6ce2 100644 --- a/packages/stem/lib/src/workflow/core/workflow_execution_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_execution_context.dart @@ -126,4 +126,46 @@ extension WorkflowExecutionContextValues on WorkflowExecutionContext { } return value; } + + /// Returns the decoded prior step/checkpoint value as a typed DTO, or + /// `null`. + T? previousJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + final value = previousResult; + if (value == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(value); + } + + /// Returns the decoded prior step/checkpoint DTO, or [fallback]. + T previousJsonOr( + T fallback, { + required T Function(Map payload) decode, + String? typeName, + }) { + return previousJson( + decode: decode, + typeName: typeName, + ) ?? + fallback; + } + + /// Returns the decoded prior step/checkpoint DTO, throwing when absent. + T requiredPreviousJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + final value = previousJson( + decode: decode, + typeName: typeName, + ); + if (value == null) { + throw StateError('WorkflowExecutionContext.previousResult is null.'); + } + return value; + } } diff --git a/packages/stem/lib/src/workflow/core/workflow_resume.dart b/packages/stem/lib/src/workflow/core/workflow_resume.dart index 0769bc7d..d742684d 100644 --- a/packages/stem/lib/src/workflow/core/workflow_resume.dart +++ b/packages/stem/lib/src/workflow/core/workflow_resume.dart @@ -28,6 +28,19 @@ extension WorkflowResumeContextValues on WorkflowResumeContext { return payload as T; } + /// Returns the next resume payload as a typed DTO and consumes it. + T? takeResumeJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + final payload = takeResumeData(); + if (payload == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(payload); + } + /// Suspends the current step on the first invocation and /// returns `true` once the step resumes. /// @@ -106,6 +119,29 @@ extension WorkflowResumeContextValues on WorkflowResumeContext { return null; } + /// Returns the next event payload as a typed DTO when the step has resumed, + /// or registers an event wait and returns `null` on the first invocation. + T? waitForEventValueJson( + String topic, { + required T Function(Map payload) decode, + DateTime? deadline, + Map? data, + String? typeName, + }) { + final payload = takeResumeJson( + decode: decode, + typeName: typeName, + ); + if (payload != null) { + return payload; + } + final pending = waitForTopic(topic, deadline: deadline, data: data); + if (pending is Future) { + unawaited(pending); + } + return null; + } + /// Suspends until [topic] is emitted, then returns the resumed payload. Future waitForEvent({ required String topic, @@ -120,6 +156,25 @@ extension WorkflowResumeContextValues on WorkflowResumeContext { await waitForTopic(topic, deadline: deadline, data: data); throw const WorkflowSuspensionSignal(); } + + /// Suspends until [topic] is emitted, then returns the resumed DTO payload. + Future waitForEventJson({ + required String topic, + required T Function(Map payload) decode, + DateTime? deadline, + Map? data, + String? typeName, + }) async { + final payload = takeResumeJson( + decode: decode, + typeName: typeName, + ); + if (payload != null) { + return payload; + } + await waitForTopic(topic, deadline: deadline, data: data); + throw const WorkflowSuspensionSignal(); + } } /// Direct typed wait helpers on [WorkflowEventRef]. diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart index edfeb232..85868dd0 100644 --- a/packages/stem/test/unit/workflow/workflow_resume_test.dart +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -50,6 +50,31 @@ void main() { expect(context.takeResumeValue>(), isNull); }); + test('FlowContext.takeResumeJson decodes DTO payloads', () { + final context = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + resumeData: const {'message': 'approved'}, + ); + + final value = context.takeResumeJson<_ResumePayload>( + decode: _ResumePayload.fromJson, + ); + + expect(value, isNotNull); + expect(value!.message, 'approved'); + expect( + context.takeResumeJson<_ResumePayload>( + decode: _ResumePayload.fromJson, + ), + isNull, + ); + }); + test( 'WorkflowExecutionContext.previousValue reads typed previous results', () { @@ -127,6 +152,26 @@ void main() { }, ); + test( + 'WorkflowExecutionContext.requiredPreviousJson decodes DTO values', + () { + final flowContext = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'tail', + params: const {}, + previousResult: const {'message': 'approved'}, + stepIndex: 1, + ); + + final value = flowContext.requiredPreviousJson<_ResumePayload>( + decode: _ResumePayload.fromJson, + ); + + expect(value.message, 'approved'); + }, + ); + test('FlowContext.sleepUntilResumed suspends once then resumes', () { final firstContext = FlowContext( workflow: 'demo', @@ -242,6 +287,51 @@ void main() { }, ); + test( + 'FlowContext.waitForEventValueJson registers watcher ' + 'then decodes DTO payload', + () { + final firstContext = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + ); + + final firstResult = firstContext.waitForEventValueJson<_ResumePayload>( + 'demo.event', + decode: _ResumePayload.fromJson, + ); + + expect(firstResult, isNull); + final control = firstContext.takeControl(); + expect(control, isNotNull); + expect(control!.type, FlowControlType.waitForEvent); + expect(control.topic, 'demo.event'); + + final resumedContext = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + resumeData: const {'message': 'approved'}, + ); + + final resumed = resumedContext.waitForEventValueJson<_ResumePayload>( + 'demo.event', + decode: _ResumePayload.fromJson, + ); + + expect(resumed, isNotNull); + expect(resumed!.message, 'approved'); + expect(resumedContext.takeControl(), isNull); + }, + ); + test('WorkflowEventRef.waitValue reuses topic and codec for flows', () { const event = WorkflowEventRef<_ResumePayload>( topic: 'demo.event', @@ -366,6 +456,53 @@ void main() { ); }); + test( + 'FlowContext.waitForEventJson uses named args and resumes with DTO payload', + () { + final waiting = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + ); + + expect( + () => waiting.waitForEventJson<_ResumePayload>( + topic: 'demo.event', + decode: _ResumePayload.fromJson, + ), + throwsA(isA()), + ); + expect(waiting.takeControl()?.topic, 'demo.event'); + + final resumed = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + resumeData: const {'message': 'approved'}, + ); + + expect( + resumed.waitForEventJson<_ResumePayload>( + topic: 'demo.event', + decode: _ResumePayload.fromJson, + ), + completion( + isA<_ResumePayload>().having( + (value) => value.message, + 'message', + 'approved', + ), + ), + ); + }, + ); + test('WorkflowEventRef.awaitOn reuses the event topic for flows', () { const event = WorkflowEventRef<_ResumePayload>( topic: 'demo.event', From f0e24c130417fc390335bd70153c4a1cad2f4913 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 08:43:13 -0500 Subject: [PATCH 199/302] Add direct json payload list readers --- .../workflows/context-and-serialization.md | 2 + packages/stem/CHANGELOG.md | 4 ++ packages/stem/lib/src/core/contracts.dart | 41 ++++++++++++++++ packages/stem/lib/src/core/payload_map.dart | 48 +++++++++++++++++++ .../core/workflow_execution_context.dart | 42 ++++++++++++++++ .../core/workflow_script_context.dart | 42 ++++++++++++++++ .../stem/test/unit/core/payload_map_test.dart | 37 ++++++++++++++ 7 files changed, 216 insertions(+) diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index ef87053c..2df1ccea 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -38,6 +38,8 @@ Depending on the context type, you can access: params - `paramJson()` / `requiredParamJson()` for nested DTO params without a separate codec constant +- `paramListJson()` / `requiredParamListJson()` for lists of nested DTO + params without a separate codec constant - `previousValue()` / `requiredPreviousValue()` for typed access to the prior step or checkpoint result - `previousJson()` / `requiredPreviousJson()` for prior DTO results diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 7a036639..8bc430a0 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -73,6 +73,10 @@ `takeResumeJson(...)`, `waitForEventValueJson(...)`, and `waitForEventJson(...)` so workflow steps and checkpoints can decode prior DTO results and resume/event payloads without separate codec constants. +- Added `valueListJson(...)`, `requiredValueListJson(...)`, + `argListJson(...)`, and `paramListJson(...)` so nested DTO lists can be + decoded directly from durable payload maps without separate codec constants + or manual list mapping. - Added `valueList()`, `valueListOr(...)`, and `requiredValueList(...)` to the shared payload-map helpers so canvas chains/chords and other meta-driven paths can decode typed list payloads without manual list casts. diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 50a2aebb..fd54820b 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -1797,6 +1797,47 @@ extension TaskInputContextArgs on TaskInputContext { typeName: typeName, ); } + + /// Returns the decoded task arg DTO list for [key], or `null`. + List? argListJson( + String key, { + required T Function(Map payload) decode, + String? typeName, + }) { + return args.valueListJson( + key, + decode: decode, + typeName: typeName, + ); + } + + /// Returns the decoded task arg DTO list for [key], or [fallback]. + List argListJsonOr( + String key, + List fallback, { + required T Function(Map payload) decode, + String? typeName, + }) { + return args.valueListJsonOr( + key, + fallback, + decode: decode, + typeName: typeName, + ); + } + + /// Returns the decoded task arg DTO list for [key], throwing when absent. + List requiredArgListJson( + String key, { + required T Function(Map payload) decode, + String? typeName, + }) { + return args.requiredValueListJson( + key, + decode: decode, + typeName: typeName, + ); + } } /// Context passed to handler implementations during execution. diff --git a/packages/stem/lib/src/core/payload_map.dart b/packages/stem/lib/src/core/payload_map.dart index ee304474..1fbd56a7 100644 --- a/packages/stem/lib/src/core/payload_map.dart +++ b/packages/stem/lib/src/core/payload_map.dart @@ -104,4 +104,52 @@ extension PayloadMapX on Map { } return valueList(key, codec: codec)!; } + + /// Returns the decoded DTO list value for [key], or `null` when it is + /// absent. + List? valueListJson( + String key, { + required T Function(Map payload) decode, + String? typeName, + }) { + final payload = this[key]; + if (payload == null) return null; + final values = payload as List; + final codec = PayloadCodec.json( + decode: decode, + typeName: typeName, + ); + return List.unmodifiable(values.map(codec.decode)); + } + + /// Returns the decoded DTO list value for [key], or [fallback] when absent. + List valueListJsonOr( + String key, + List fallback, { + required T Function(Map payload) decode, + String? typeName, + }) { + return valueListJson( + key, + decode: decode, + typeName: typeName, + ) ?? + fallback; + } + + /// Returns the decoded DTO list value for [key], throwing when absent. + List requiredValueListJson( + String key, { + required T Function(Map payload) decode, + String? typeName, + }) { + if (!containsKey(key) || this[key] == null) { + throw StateError("Missing required payload key '$key'."); + } + return valueListJson( + key, + decode: decode, + typeName: typeName, + )!; + } } diff --git a/packages/stem/lib/src/workflow/core/workflow_execution_context.dart b/packages/stem/lib/src/workflow/core/workflow_execution_context.dart index 15ee6ce2..f183952c 100644 --- a/packages/stem/lib/src/workflow/core/workflow_execution_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_execution_context.dart @@ -100,6 +100,48 @@ extension WorkflowExecutionContextParams on WorkflowExecutionContext { typeName: typeName, ); } + + /// Returns the decoded workflow parameter DTO list for [key], or `null`. + List? paramListJson( + String key, { + required T Function(Map payload) decode, + String? typeName, + }) { + return params.valueListJson( + key, + decode: decode, + typeName: typeName, + ); + } + + /// Returns the decoded workflow parameter DTO list for [key], or [fallback]. + List paramListJsonOr( + String key, + List fallback, { + required T Function(Map payload) decode, + String? typeName, + }) { + return params.valueListJsonOr( + key, + fallback, + decode: decode, + typeName: typeName, + ); + } + + /// Returns the decoded workflow parameter DTO list for [key], throwing when + /// absent. + List requiredParamListJson( + String key, { + required T Function(Map payload) decode, + String? typeName, + }) { + return params.requiredValueListJson( + key, + decode: decode, + typeName: typeName, + ); + } } /// Typed read helpers for prior workflow step and checkpoint values. diff --git a/packages/stem/lib/src/workflow/core/workflow_script_context.dart b/packages/stem/lib/src/workflow/core/workflow_script_context.dart index 91208718..8e161fb4 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script_context.dart @@ -90,6 +90,48 @@ extension WorkflowScriptContextParams on WorkflowScriptContext { typeName: typeName, ); } + + /// Returns the decoded workflow parameter DTO list for [key], or `null`. + List? paramListJson( + String key, { + required T Function(Map payload) decode, + String? typeName, + }) { + return params.valueListJson( + key, + decode: decode, + typeName: typeName, + ); + } + + /// Returns the decoded workflow parameter DTO list for [key], or [fallback]. + List paramListJsonOr( + String key, + List fallback, { + required T Function(Map payload) decode, + String? typeName, + }) { + return params.valueListJsonOr( + key, + fallback, + decode: decode, + typeName: typeName, + ); + } + + /// Returns the decoded workflow parameter DTO list for [key], throwing when + /// absent. + List requiredParamListJson( + String key, { + required T Function(Map payload) decode, + String? typeName, + }) { + return params.requiredValueListJson( + key, + decode: decode, + typeName: typeName, + ); + } } /// Context provided to each script checkpoint invocation. Mirrors diff --git a/packages/stem/test/unit/core/payload_map_test.dart b/packages/stem/test/unit/core/payload_map_test.dart index 6e37efff..1a77dc0b 100644 --- a/packages/stem/test/unit/core/payload_map_test.dart +++ b/packages/stem/test/unit/core/payload_map_test.dart @@ -124,6 +124,43 @@ void main() { expect(drafts.map((draft) => draft.documentId), ['doc-42', 'doc-99']); }); + + test('valueListJson decodes DTO lists without a codec constant', () { + final payload = { + 'drafts': const [ + {'documentId': 'doc-42'}, + {'documentId': 'doc-99'}, + ], + }; + + final drafts = payload.valueListJson<_ApprovalDraft>( + 'drafts', + decode: _ApprovalDraft.fromJson, + ); + + expect( + drafts?.map((draft) => draft.documentId).toList(), + ['doc-42', 'doc-99'], + ); + }); + + test('requiredValueListJson throws for missing payload keys', () { + const payload = {'name': 'Stem'}; + + expect( + () => payload.requiredValueListJson<_ApprovalDraft>( + 'drafts', + decode: _ApprovalDraft.fromJson, + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + "Missing required payload key 'drafts'.", + ), + ), + ); + }); }); } From 0b06909e67fbe40408c766c6a38ba3a4c91659d3 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 08:48:52 -0500 Subject: [PATCH 200/302] Add workflow view DTO decode helpers --- .site/docs/workflows/starting-and-waiting.md | 9 ++- packages/stem/CHANGELOG.md | 5 ++ packages/stem/README.md | 19 +++-- .../src/workflow/runtime/workflow_views.dart | 72 ++++++++++++++++++- .../workflow_metadata_views_test.dart | 60 ++++++++++++++++ 5 files changed, 154 insertions(+), 11 deletions(-) diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 3c22a6ab..918e09db 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -94,8 +94,13 @@ If you are inspecting the underlying `RunState` directly, use `state.resultJson(...)`, `state.resultAs(codec: ...)`, `state.suspensionPayloadJson(...)`, or `state.suspensionPayloadAs(codec: ...)` instead of manual raw-map casts. -Checkpoint entries from `viewCheckpoints(...)` expose the same convenience -surface via `entry.valueJson(...)` and `entry.valueAs(codec: ...)`. +Workflow run detail views expose the same convenience surface via +`runView.resultJson(...)`, `runView.resultAs(codec: ...)`, +`runView.suspensionPayloadJson(...)`, and +`runView.suspensionPayloadAs(codec: ...)`. +Checkpoint entries from `viewCheckpoints(...)` and +`WorkflowCheckpointView.fromEntry(...)` expose the same surface via +`entry.valueJson(...)` and `entry.valueAs(codec: ...)`. Use the returned `WorkflowResult` when you need: diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 8bc430a0..66063c4d 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -46,6 +46,11 @@ `WorkflowStepEntry.valueJson(...)` / `valueAs(codec: ...)` so raw watcher and checkpoint inspection paths can decode DTO payloads without manual casts. +- Added `WorkflowRunView.resultJson(...)`, + `WorkflowRunView.suspensionPayloadJson(...)`, and + `WorkflowCheckpointView.valueJson(...)` plus their `...As(codec: ...)` + counterparts so dashboard/CLI workflow detail views can decode DTO payloads + without manual casts. - Added `GroupStatus.resultValues()`, `resultJson(...)`, and `resultAs(codec: ...)` so canvas/group status inspection can decode typed child results without manually mapping raw `TaskStatus.payload` values. diff --git a/packages/stem/README.md b/packages/stem/README.md index 411bdcfc..cb149517 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -500,13 +500,14 @@ final approvalsFlow = Flow( }); flow.step('manager-review', (ctx) async { - final resume = ctx.waitForEventValue>( + final resume = ctx.waitForEventValueJson( 'approvals.manager', + decode: ApprovalDecision.fromJson, ); if (resume == null) { return null; } - return resume['approvedBy'] as String?; + return resume.approvedBy; }); flow.step('finalize', (ctx) async { @@ -581,13 +582,14 @@ final billingRetryScript = WorkflowScript( name: 'billing.retry-script', run: (script) async { final chargeId = await script.step('charge', (ctx) async { - final resume = ctx.waitForEventValue>( + final resume = ctx.waitForEventValueJson( 'billing.charge.prepared', + decode: ChargePrepared.fromJson, ); if (resume == null) { return 'pending'; } - return resume['chargeId'] as String; + return resume.chargeId; }); return script.step('confirm', (ctx) async { @@ -901,8 +903,13 @@ If you are inspecting the underlying `RunState` directly, use `state.resultJson(...)`, `state.resultAs(codec: ...)`, `state.suspensionPayloadJson(...)`, or `state.suspensionPayloadAs(codec: ...)` instead of manual raw-map casts. -Checkpoint entries from `viewCheckpoints(...)` now expose the same convenience -surface via `entry.valueJson(...)` and `entry.valueAs(codec: ...)`. +Workflow run detail views expose the same convenience surface via +`runView.resultJson(...)`, `runView.resultAs(codec: ...)`, +`runView.suspensionPayloadJson(...)`, and +`runView.suspensionPayloadAs(codec: ...)`. +Checkpoint entries from `viewCheckpoints(...)` and +`WorkflowCheckpointView.fromEntry(...)` expose the same surface via +`entry.valueJson(...)` and `entry.valueAs(codec: ...)`. In the example above, these calls inside `run(...)`: diff --git a/packages/stem/lib/src/workflow/runtime/workflow_views.dart b/packages/stem/lib/src/workflow/runtime/workflow_views.dart index 9cc2ba08..52fd2099 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_views.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_views.dart @@ -1,3 +1,4 @@ +import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/workflow/core/run_state.dart'; import 'package:stem/src/workflow/core/workflow_status.dart'; import 'package:stem/src/workflow/core/workflow_step_entry.dart'; @@ -11,11 +12,11 @@ class WorkflowRunView { required this.status, required this.cursor, required this.createdAt, + required this.params, + required this.runtime, this.updatedAt, this.result, this.lastError, - required this.params, - required this.runtime, this.suspensionData, }); @@ -57,6 +58,26 @@ class WorkflowRunView { /// Final result payload when completed. final Object? result; + /// Decodes the final result payload with [codec]. + TResult? resultAs({required PayloadCodec codec}) { + final stored = result; + if (stored == null) return null; + return codec.decode(stored); + } + + /// Decodes the final result payload with a JSON decoder. + TResult? resultJson({ + required TResult Function(Map payload) decode, + String? typeName, + }) { + final stored = result; + if (stored == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(stored); + } + /// Last error payload, if present. final Map? lastError; @@ -69,6 +90,31 @@ class WorkflowRunView { /// Suspension payload, if run is suspended. final Map? suspensionData; + /// Resume payload delivered to the suspended run, when present. + Object? get suspensionPayload => suspensionData?['payload']; + + /// Decodes the suspension payload with [codec], when present. + TPayload? suspensionPayloadAs({ + required PayloadCodec codec, + }) { + final stored = suspensionPayload; + if (stored == null) return null; + return codec.decode(stored); + } + + /// Decodes the suspension payload with a JSON decoder, when present. + TPayload? suspensionPayloadJson({ + required TPayload Function(Map payload) decode, + String? typeName, + }) { + final stored = suspensionPayload; + if (stored == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(stored); + } + /// Serializes this view into JSON. Map toJson() { return { @@ -95,8 +141,8 @@ class WorkflowCheckpointView { required this.workflow, required this.checkpointName, required this.baseCheckpointName, - this.iteration, required this.position, + this.iteration, this.completedAt, this.value, }); @@ -143,6 +189,26 @@ class WorkflowCheckpointView { /// Persisted checkpoint value. final Object? value; + /// Decodes the persisted checkpoint value with [codec]. + TValue? valueAs({required PayloadCodec codec}) { + final stored = value; + if (stored == null) return null; + return codec.decode(stored); + } + + /// Decodes the persisted checkpoint value with a JSON decoder. + TValue? valueJson({ + required TValue Function(Map payload) decode, + String? typeName, + }) { + final stored = value; + if (stored == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(stored); + } + /// Serializes this view into JSON. Map toJson() { return { diff --git a/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart b/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart index 7e4b1dbb..ac5fd344 100644 --- a/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart +++ b/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart @@ -228,6 +228,66 @@ void main() { expect(plain.iteration, isNull); }); }); + + group('Workflow view decode helpers', () { + test('decodes run results and suspension payloads as DTOs', () { + final state = RunState( + id: 'run-view-1', + workflow: 'invoice', + status: WorkflowStatus.suspended, + cursor: 2, + params: const {'tenant': 'acme'}, + createdAt: DateTime.utc(2026, 2, 25), + result: const {'invoiceId': 'inv-4'}, + suspensionData: const { + 'type': 'event', + 'payload': {'invoiceId': 'inv-5'}, + }, + ); + final view = WorkflowRunView.fromState(state); + + expect( + view.resultJson<_InvoicePayload>(decode: _InvoicePayload.fromJson), + isA<_InvoicePayload>().having( + (value) => value.invoiceId, + 'invoiceId', + 'inv-4', + ), + ); + expect( + view.suspensionPayloadJson<_InvoicePayload>( + decode: _InvoicePayload.fromJson, + ), + isA<_InvoicePayload>().having( + (value) => value.invoiceId, + 'invoiceId', + 'inv-5', + ), + ); + }); + + test('decodes checkpoint values as DTOs', () { + const entry = WorkflowStepEntry( + name: 'approval#1', + value: {'invoiceId': 'inv-6'}, + position: 1, + ); + final view = WorkflowCheckpointView.fromEntry( + runId: 'run-view-2', + workflow: 'invoice', + entry: entry, + ); + + expect( + view.valueJson<_InvoicePayload>(decode: _InvoicePayload.fromJson), + isA<_InvoicePayload>().having( + (value) => value.invoiceId, + 'invoiceId', + 'inv-6', + ), + ); + }); + }); } class _InvoicePayload { From a99a977606fb50eb6ad4ffaedc515820e54922ee Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 08:49:47 -0500 Subject: [PATCH 201/302] Add workflow introspection result helpers --- .site/docs/core-concepts/observability.md | 4 +++ packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 2 ++ .../runtime/workflow_introspection.dart | 21 +++++++++++++ .../stem/test/unit/core/stem_event_test.dart | 30 +++++++++++++++++++ 5 files changed, 60 insertions(+) diff --git a/.site/docs/core-concepts/observability.md b/.site/docs/core-concepts/observability.md index 308002c5..54a51287 100644 --- a/.site/docs/core-concepts/observability.md +++ b/.site/docs/core-concepts/observability.md @@ -80,6 +80,10 @@ class LoggingWorkflowIntrospectionSink implements WorkflowIntrospectionSink { } ``` +When a completed step or checkpoint carries a DTO payload, prefer +`event.resultJson(...)` or `event.resultAs(codec: ...)` over manual +`event.result as Map` casts. + ## Logging Use `stemLogger` (Contextual logger) for structured logs. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 66063c4d..e6981330 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -51,6 +51,9 @@ `WorkflowCheckpointView.valueJson(...)` plus their `...As(codec: ...)` counterparts so dashboard/CLI workflow detail views can decode DTO payloads without manual casts. +- Added `WorkflowStepEvent.resultJson(...)` and `resultAs(codec: ...)` so + workflow introspection consumers can decode DTO checkpoint results without + manual casts. - Added `GroupStatus.resultValues()`, `resultJson(...)`, and `resultAs(codec: ...)` so canvas/group status inspection can decode typed child results without manually mapping raw `TaskStatus.payload` values. diff --git a/packages/stem/README.md b/packages/stem/README.md index cb149517..92a291c4 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -910,6 +910,8 @@ Workflow run detail views expose the same convenience surface via Checkpoint entries from `viewCheckpoints(...)` and `WorkflowCheckpointView.fromEntry(...)` expose the same surface via `entry.valueJson(...)` and `entry.valueAs(codec: ...)`. +Workflow introspection events expose matching helpers via +`event.resultJson(...)` and `event.resultAs(codec: ...)`. In the example above, these calls inside `run(...)`: diff --git a/packages/stem/lib/src/workflow/runtime/workflow_introspection.dart b/packages/stem/lib/src/workflow/runtime/workflow_introspection.dart index a5c61c66..567604cd 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_introspection.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_introspection.dart @@ -1,4 +1,5 @@ import 'package:stem/src/core/stem_event.dart'; +import 'package:stem/src/core/payload_codec.dart'; /// Enumerates workflow step event types emitted by the runtime. enum WorkflowStepEventType { @@ -57,6 +58,26 @@ class WorkflowStepEvent implements StemEvent { /// Optional result payload for completed steps. final Object? result; + /// Decodes the step result payload with [codec], when present. + TResult? resultAs({required PayloadCodec codec}) { + final stored = result; + if (stored == null) return null; + return codec.decode(stored); + } + + /// Decodes the step result payload with a JSON decoder, when present. + TResult? resultJson({ + required TResult Function(Map payload) decode, + String? typeName, + }) { + final stored = result; + if (stored == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(stored); + } + /// Optional error message for failed steps. final String? error; diff --git a/packages/stem/test/unit/core/stem_event_test.dart b/packages/stem/test/unit/core/stem_event_test.dart index 8ad552d9..d3aa2755 100644 --- a/packages/stem/test/unit/core/stem_event_test.dart +++ b/packages/stem/test/unit/core/stem_event_test.dart @@ -42,5 +42,35 @@ void main() { expect(event.attributes['runId'], 'run-1'); expect(event.attributes['stepId'], 'charge'); }); + + test('WorkflowStepEvent decodes DTO result payloads', () { + final event = WorkflowStepEvent( + runId: 'run-2', + workflow: 'checkout', + stepId: 'charge', + type: WorkflowStepEventType.completed, + timestamp: DateTime.utc(2026, 2, 24, 16, 30), + result: const {'chargeId': 'ch_123'}, + ); + + expect( + event.resultJson<_ChargeResult>(decode: _ChargeResult.fromJson), + isA<_ChargeResult>().having( + (value) => value.chargeId, + 'chargeId', + 'ch_123', + ), + ); + }); }); } + +class _ChargeResult { + const _ChargeResult({required this.chargeId}); + + factory _ChargeResult.fromJson(Map json) { + return _ChargeResult(chargeId: json['chargeId'] as String); + } + + final String chargeId; +} From d36c95a7c9e3c98428e19101ab96cfe64cab3698 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 08:53:39 -0500 Subject: [PATCH 202/302] Add json task progress helpers --- .site/docs/core-concepts/tasks.md | 2 ++ packages/stem/CHANGELOG.md | 3 +++ packages/stem/lib/src/core/contracts.dart | 12 +++++++++ .../stem/lib/src/core/task_invocation.dart | 12 +++++++++ .../unit/core/task_context_enqueue_test.dart | 26 +++++++++++++++++++ .../test/unit/core/task_invocation_test.dart | 25 ++++++++++++++++++ 6 files changed, 80 insertions(+) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 404935f1..6e2ccffa 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -122,6 +122,8 @@ every retry signal and shows how the strategy interacts with broker timings. - `context.heartbeat()` – extend the lease to avoid timeouts. - `context.extendLease(Duration by)` – request additional processing time. - `context.progress(percent, data: {...})` – emit progress signals for UI hooks. +- `context.progressJson(percent, dto)` – emit DTO progress payloads without + hand-built maps. Use the context to build idempotent handlers. Re-enqueue work, cancel jobs, or store audit details in `context.meta`. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index e6981330..4a8c34aa 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -85,6 +85,9 @@ `argListJson(...)`, and `paramListJson(...)` so nested DTO lists can be decoded directly from durable payload maps without separate codec constants or manual list mapping. +- Added `TaskContext.progressJson(...)` and + `TaskInvocationContext.progressJson(...)` so task progress updates can emit + DTO payloads without hand-built maps. - Added `valueList()`, `valueListOr(...)`, and `requiredValueList(...)` to the shared payload-map helpers so canvas chains/chords and other meta-driven paths can decode typed list payloads without manual list casts. diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index fd54820b..7119c883 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -1890,6 +1890,18 @@ class TaskContext }) progress; + /// Report progress with a JSON-serializable DTO payload. + Future progressJson(double percentComplete, T value, { + String? typeName, + }) { + return progress( + percentComplete, + data: Map.from( + PayloadCodec.encodeJsonMap(value, typeName: typeName), + ), + ); + } + /// Optional enqueuer for scheduling additional tasks. final TaskEnqueuer? enqueuer; diff --git a/packages/stem/lib/src/core/task_invocation.dart b/packages/stem/lib/src/core/task_invocation.dart index 9f26c00c..bfdad2f2 100644 --- a/packages/stem/lib/src/core/task_invocation.dart +++ b/packages/stem/lib/src/core/task_invocation.dart @@ -402,6 +402,18 @@ class TaskInvocationContext Future progress(double percentComplete, {Map? data}) => _progress(percentComplete, data: data); + /// Report progress with a JSON-serializable DTO payload. + Future progressJson(double percentComplete, T value, { + String? typeName, + }) { + return progress( + percentComplete, + data: Map.from( + PayloadCodec.encodeJsonMap(value, typeName: typeName), + ), + ); + } + /// Enqueue a task from within a task invocation. /// /// Headers and metadata from this context are merged into the enqueue diff --git a/packages/stem/test/unit/core/task_context_enqueue_test.dart b/packages/stem/test/unit/core/task_context_enqueue_test.dart index 2c0f5f9d..7a2c6243 100644 --- a/packages/stem/test/unit/core/task_context_enqueue_test.dart +++ b/packages/stem/test/unit/core/task_context_enqueue_test.dart @@ -25,6 +25,24 @@ void main() { expect(context.argOr('tenant', 'global'), equals('global')); }); + test('reports progress with JSON DTO payloads', () async { + Object? progressData; + final context = TaskContext( + id: 'parent-0b', + args: const {}, + attempt: 0, + headers: const {}, + meta: const {}, + heartbeat: () {}, + extendLease: (_) async {}, + progress: (_, {data}) async => progressData = data, + ); + + await context.progressJson(50, const _ProgressUpdate(stage: 'warming')); + + expect(progressData, equals(const {'stage': 'warming'})); + }); + test('propagates headers/meta and lineage by default', () async { final enqueuer = _RecordingEnqueuer(); final context = TaskContext( @@ -369,6 +387,14 @@ void main() { }); } +class _ProgressUpdate { + const _ProgressUpdate({required this.stage}); + + final String stage; + + Map toJson() => {'stage': stage}; +} + class _ExampleArgs { const _ExampleArgs(this.value); final String value; diff --git a/packages/stem/test/unit/core/task_invocation_test.dart b/packages/stem/test/unit/core/task_invocation_test.dart index 784a888f..464f0823 100644 --- a/packages/stem/test/unit/core/task_invocation_test.dart +++ b/packages/stem/test/unit/core/task_invocation_test.dart @@ -145,6 +145,23 @@ void main() { expect(context.argOr('tenant', 'global'), equals('global')); }); + test('TaskInvocationContext.local reports progress with JSON DTO payloads', () async { + Object? progressData; + final context = TaskInvocationContext.local( + id: 'task-1b', + headers: const {}, + meta: const {}, + attempt: 0, + heartbeat: () {}, + extendLease: (_) async {}, + progress: (_, {Map? data}) async => progressData = data, + ); + + await context.progressJson(25, const _ProgressUpdate(stage: 'warming')); + + expect(progressData, equals(const {'stage': 'warming'})); + }); + test('TaskInvocationContext.local merges headers/meta and lineage', () async { final enqueuer = _CapturingEnqueuer('task-1'); final context = TaskInvocationContext.local( @@ -533,6 +550,14 @@ class _WorkflowEventPayload { final String value; } +class _ProgressUpdate { + const _ProgressUpdate({required this.stage}); + + final String stage; + + Map toJson() => {'stage': stage}; +} + const PayloadCodec<_WorkflowEventPayload> _eventPayloadCodec = PayloadCodec<_WorkflowEventPayload>( encode: _encodeWorkflowEventPayload, From 42a529480d226c17e4df6a490e50edf094ea7f46 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 08:57:16 -0500 Subject: [PATCH 203/302] Add json workflow suspension helpers --- .../workflows/context-and-serialization.md | 3 ++ .../docs/workflows/suspensions-and-events.md | 6 +++- packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 3 ++ .../lib/src/workflow/core/flow_context.dart | 27 +++++++++++++++ .../stem/lib/src/workflow/core/flow_step.dart | 26 +++++++++++++++ .../core/workflow_script_context.dart | 29 ++++++++++++++++ .../test/unit/workflow/flow_context_test.dart | 33 +++++++++++++++++++ .../test/unit/workflow/flow_step_test.dart | 24 ++++++++++++++ .../unit/workflow/workflow_resume_test.dart | 30 +++++++++++++++++ 10 files changed, 183 insertions(+), 1 deletion(-) diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index 2df1ccea..fa222dc4 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -50,6 +50,9 @@ Depending on the context type, you can access: constant - `event.awaitOn(step)` when a flow deliberately wants the lower-level `FlowStepControl` suspend-first path on a typed event ref +- `sleepJson(...)`, `awaitEventJson(...)`, and `FlowStepControl.awaitTopicJson(...)` + when lower-level suspension directives still need DTO metadata without a + separate codec constant - `takeResumeData()` for event-driven resumes - `takeResumeValue(codec: ...)` for typed event-driven resumes - `takeResumeJson(...)` for DTO event-driven resumes without a separate diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index 293b7d69..d94a4dd8 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -40,8 +40,9 @@ Typical flow: For the common "wait for one event and continue" case, prefer: ```dart -final payload = await ctx.waitForEvent>( +final payload = await ctx.waitForEventJson( topic: 'orders.payment.confirmed', + decode: PaymentConfirmed.fromJson, ); ``` @@ -70,6 +71,9 @@ remains available as the lower-level prebuilt-call variant. Pair that with `await event.wait(ctx)`. If you are writing a flow and deliberately want the lower-level `FlowStepControl` path, use `event.awaitOn(step)` instead of dropping back to a raw topic string. +For low-level sleep/event directives that still need DTO metadata, use +`step.sleepJson(...)`, `step.awaitEventJson(...)`, or +`FlowStepControl.awaitTopicJson(...)` instead of hand-built maps. ## Inspect waiting runs diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 4a8c34aa..281e0157 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -88,6 +88,9 @@ - Added `TaskContext.progressJson(...)` and `TaskInvocationContext.progressJson(...)` so task progress updates can emit DTO payloads without hand-built maps. +- Added `sleepJson(...)`, `awaitEventJson(...)`, and + `FlowStepControl.awaitTopicJson(...)` so lower-level flow/script suspension + directives can carry DTO metadata without hand-built maps. - Added `valueList()`, `valueListOr(...)`, and `requiredValueList(...)` to the shared payload-map helpers so canvas chains/chords and other meta-driven paths can decode typed list payloads without manual list casts. diff --git a/packages/stem/README.md b/packages/stem/README.md index 92a291c4..b0066d41 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -912,6 +912,9 @@ Checkpoint entries from `viewCheckpoints(...)` and `entry.valueJson(...)` and `entry.valueAs(codec: ...)`. Workflow introspection events expose matching helpers via `event.resultJson(...)` and `event.resultAs(codec: ...)`. +For lower-level suspension directives, prefer `step.sleepJson(...)`, +`step.awaitEventJson(...)`, and `FlowStepControl.awaitTopicJson(...)` over +hand-built maps. In the example above, these calls inside `run(...)`: diff --git a/packages/stem/lib/src/workflow/core/flow_context.dart b/packages/stem/lib/src/workflow/core/flow_context.dart index b647da73..e718d512 100644 --- a/packages/stem/lib/src/workflow/core/flow_context.dart +++ b/packages/stem/lib/src/workflow/core/flow_context.dart @@ -1,4 +1,5 @@ import 'package:stem/src/core/contracts.dart'; +import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/workflow/core/flow_step.dart'; import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; import 'package:stem/src/workflow/core/workflow_clock.dart'; @@ -106,6 +107,16 @@ class FlowContext implements WorkflowExecutionContext { return _control!; } + /// Suspends the workflow for [duration] with a JSON-serializable DTO payload. + FlowStepControl sleepJson(Duration duration, T value, {String? typeName}) { + return sleep( + duration, + data: Map.from( + PayloadCodec.encodeJsonMap(value, typeName: typeName), + ), + ); + } + /// Suspends the workflow until an event with [topic] is emitted. /// /// When the event bus resumes the run, the payload is made available via @@ -124,6 +135,22 @@ class FlowContext implements WorkflowExecutionContext { return _control!; } + /// Suspends the workflow until [topic] arrives with a DTO payload. + FlowStepControl awaitEventJson( + String topic, + T value, { + DateTime? deadline, + String? typeName, + }) { + return awaitEvent( + topic, + deadline: deadline, + data: Map.from( + PayloadCodec.encodeJsonMap(value, typeName: typeName), + ), + ); + } + @override void suspendFor(Duration duration, {Map? data}) { sleep(duration, data: data); diff --git a/packages/stem/lib/src/workflow/core/flow_step.dart b/packages/stem/lib/src/workflow/core/flow_step.dart index 9413d0bd..2ca66737 100644 --- a/packages/stem/lib/src/workflow/core/flow_step.dart +++ b/packages/stem/lib/src/workflow/core/flow_step.dart @@ -166,6 +166,18 @@ class FlowStepControl { Map? data, }) => FlowStepControl._(FlowControlType.sleep, delay: duration, data: data); + /// Suspend the run until [duration] elapses with a DTO payload. + static FlowStepControl sleepJson( + Duration duration, + T value, { + String? typeName, + }) => FlowStepControl.sleep( + duration, + data: Map.from( + PayloadCodec.encodeJsonMap(value, typeName: typeName), + ), + ); + /// Suspend the run until an event with [topic] arrives. factory FlowStepControl.awaitTopic( String topic, { @@ -178,6 +190,20 @@ class FlowStepControl { data: data, ); + /// Suspend the run until an event with [topic] arrives with a DTO payload. + static FlowStepControl awaitTopicJson( + String topic, + T value, { + DateTime? deadline, + String? typeName, + }) => FlowStepControl.awaitTopic( + topic, + deadline: deadline, + data: Map.from( + PayloadCodec.encodeJsonMap(value, typeName: typeName), + ), + ); + /// Continue execution without suspending. factory FlowStepControl.continueRun() => FlowStepControl._(FlowControlType.continueRun); diff --git a/packages/stem/lib/src/workflow/core/workflow_script_context.dart b/packages/stem/lib/src/workflow/core/workflow_script_context.dart index 8e161fb4..f1e51a10 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script_context.dart @@ -32,6 +32,35 @@ abstract class WorkflowScriptContext { }); } +/// Low-level suspension helpers for workflow script checkpoints. +extension WorkflowScriptStepSuspensionJson on WorkflowScriptStepContext { + /// Suspends the workflow for [duration] with a JSON-serializable DTO payload. + Future sleepJson(Duration duration, T value, {String? typeName}) { + return sleep( + duration, + data: Map.from( + PayloadCodec.encodeJsonMap(value, typeName: typeName), + ), + ); + } + + /// Suspends the workflow until [topic] arrives with a DTO payload. + Future awaitEventJson( + String topic, + T value, { + DateTime? deadline, + String? typeName, + }) { + return awaitEvent( + topic, + deadline: deadline, + data: Map.from( + PayloadCodec.encodeJsonMap(value, typeName: typeName), + ), + ); + } +} + /// Typed read helpers for workflow start parameters in script run methods. extension WorkflowScriptContextParams on WorkflowScriptContext { /// Returns the decoded workflow parameter for [key], or `null`. diff --git a/packages/stem/test/unit/workflow/flow_context_test.dart b/packages/stem/test/unit/workflow/flow_context_test.dart index ac27a490..d2070ca5 100644 --- a/packages/stem/test/unit/workflow/flow_context_test.dart +++ b/packages/stem/test/unit/workflow/flow_context_test.dart @@ -51,6 +51,31 @@ void main() { expect(second, isNull); }); + test('FlowContext JSON suspension helpers encode DTO payloads', () { + final context = FlowContext( + workflow: 'demo', + runId: 'run-2b', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 1, + ); + + final sleep = context.sleepJson( + const Duration(seconds: 3), + const _SuspensionPayload(stage: 'sleeping'), + ); + final wait = context.awaitEventJson( + 'topic', + const _SuspensionPayload(stage: 'waiting'), + deadline: DateTime.parse('2025-01-01T00:00:00Z'), + ); + + expect(sleep.data, equals(const {'stage': 'sleeping'})); + expect(wait.data, equals(const {'stage': 'waiting'})); + expect(wait.deadline, DateTime.parse('2025-01-01T00:00:00Z')); + }); + test( 'FlowContext resume data is consumed and idempotency key derives scope', () { @@ -161,6 +186,14 @@ void main() { }); } +class _SuspensionPayload { + const _SuspensionPayload({required this.stage}); + + final String stage; + + Map toJson() => {'stage': stage}; +} + class _RecordingEnqueuer implements TaskEnqueuer { String? lastName; Map? lastArgs; diff --git a/packages/stem/test/unit/workflow/flow_step_test.dart b/packages/stem/test/unit/workflow/flow_step_test.dart index 2952f39a..e4870287 100644 --- a/packages/stem/test/unit/workflow/flow_step_test.dart +++ b/packages/stem/test/unit/workflow/flow_step_test.dart @@ -23,4 +23,28 @@ void main() { final cont = FlowStepControl.continueRun(); expect(cont.type, FlowControlType.continueRun); }); + + test('FlowStepControl JSON factories encode DTO payloads', () { + final sleep = FlowStepControl.sleepJson( + const Duration(seconds: 5), + const _SuspensionPayload(stage: 'sleeping'), + ); + final wait = FlowStepControl.awaitTopicJson( + 'topic', + const _SuspensionPayload(stage: 'waiting'), + deadline: DateTime.parse('2025-01-01T00:00:00Z'), + ); + + expect(sleep.data, equals(const {'stage': 'sleeping'})); + expect(wait.data, equals(const {'stage': 'waiting'})); + expect(wait.deadline, DateTime.parse('2025-01-01T00:00:00Z')); + }); +} + +class _SuspensionPayload { + const _SuspensionPayload({required this.stage}); + + final String stage; + + Map toJson() => {'stage': stage}; } diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart index 85868dd0..23a9776c 100644 --- a/packages/stem/test/unit/workflow/workflow_resume_test.dart +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -503,6 +503,28 @@ void main() { }, ); + test( + 'WorkflowScriptStepContext JSON suspension helpers encode DTO payloads', + () async { + final context = _FakeWorkflowScriptStepContext(); + + await context.sleepJson( + const Duration(seconds: 2), + const _SuspensionPayload(stage: 'sleeping'), + ); + await context.awaitEventJson( + 'topic', + const _SuspensionPayload(stage: 'waiting'), + deadline: DateTime.parse('2025-01-01T00:00:00Z'), + ); + + expect(context.sleepCalls, equals([const Duration(seconds: 2)])); + expect(context.awaitedTopics, equals(['topic'])); + expect(context.awaitedData, equals(const {'stage': 'waiting'})); + expect(context.awaitedDeadline, DateTime.parse('2025-01-01T00:00:00Z')); + }, + ); + test('WorkflowEventRef.awaitOn reuses the event topic for flows', () { const event = WorkflowEventRef<_ResumePayload>( topic: 'demo.event', @@ -1049,6 +1071,14 @@ class _FakeWorkflowScriptContext implements WorkflowScriptContext { } } +class _SuspensionPayload { + const _SuspensionPayload({required this.stage}); + + final String stage; + + Map toJson() => {'stage': stage}; +} + class _RecordingTaskEnqueuer implements TaskEnqueuer { String? lastName; Map? lastArgs; From 49b2cd7a457c419b0017335bf1b9c8a79162c130 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 09:02:43 -0500 Subject: [PATCH 204/302] Add task signal result helpers --- .site/docs/core-concepts/observability.md | 4 ++++ packages/stem/CHANGELOG.md | 3 +++ packages/stem/README.md | 2 ++ packages/stem/lib/src/signals/payloads.dart | 21 +++++++++++++++++++ .../stem/test/unit/signals/payloads_test.dart | 16 ++++++++++++++ 5 files changed, 46 insertions(+) diff --git a/.site/docs/core-concepts/observability.md b/.site/docs/core-concepts/observability.md index 54a51287..9dc6c0e2 100644 --- a/.site/docs/core-concepts/observability.md +++ b/.site/docs/core-concepts/observability.md @@ -58,6 +58,10 @@ control-plane commands. ``` +When you inspect `TaskPostrunPayload` directly, prefer `payload.resultJson(...)` +or `payload.resultAs(codec: ...)` over manual `payload.result as Map` +casts. + ## Workflow Introspection Workflow runtimes can emit execution events (started/completed/failed/retrying) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 281e0157..567028a9 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -54,6 +54,9 @@ - Added `WorkflowStepEvent.resultJson(...)` and `resultAs(codec: ...)` so workflow introspection consumers can decode DTO checkpoint results without manual casts. +- Added `TaskPostrunPayload.resultJson(...)` and `resultAs(codec: ...)` so + task lifecycle signal consumers can decode DTO task results without manual + casts. - Added `GroupStatus.resultValues()`, `resultJson(...)`, and `resultAs(codec: ...)` so canvas/group status inspection can decode typed child results without manually mapping raw `TaskStatus.payload` values. diff --git a/packages/stem/README.md b/packages/stem/README.md index b0066d41..cdd446bd 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -915,6 +915,8 @@ Workflow introspection events expose matching helpers via For lower-level suspension directives, prefer `step.sleepJson(...)`, `step.awaitEventJson(...)`, and `FlowStepControl.awaitTopicJson(...)` over hand-built maps. +Task lifecycle signals expose matching result helpers on `TaskPostrunPayload` +via `payload.resultJson(...)` and `payload.resultAs(codec: ...)`. In the example above, these calls inside `run(...)`: diff --git a/packages/stem/lib/src/signals/payloads.dart b/packages/stem/lib/src/signals/payloads.dart index c334d748..0ae90415 100644 --- a/packages/stem/lib/src/signals/payloads.dart +++ b/packages/stem/lib/src/signals/payloads.dart @@ -2,6 +2,7 @@ import 'package:stem/src/control/control_messages.dart'; import 'package:stem/src/core/clock.dart'; import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/core/envelope.dart'; +import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/core/stem_event.dart'; /// Status of a workflow run emitted via signals. @@ -211,6 +212,26 @@ class TaskPostrunPayload implements StemEvent { /// The result returned by the task. final Object? result; + /// Decodes the task result with [codec]. + TResult? resultAs({required PayloadCodec codec}) { + final stored = result; + if (stored == null) return null; + return codec.decode(stored); + } + + /// Decodes the task result with a JSON decoder. + TResult? resultJson({ + required TResult Function(Map payload) decode, + String? typeName, + }) { + final stored = result; + if (stored == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(stored); + } + /// The final state of the task. final TaskState state; diff --git a/packages/stem/test/unit/signals/payloads_test.dart b/packages/stem/test/unit/signals/payloads_test.dart index 5d55b599..a149a218 100644 --- a/packages/stem/test/unit/signals/payloads_test.dart +++ b/packages/stem/test/unit/signals/payloads_test.dart @@ -51,6 +51,12 @@ void main() { expect(postrun.taskId, equals('task-1')); expect(postrun.taskName, equals('demo.task')); expect(postrun.attempt, equals(2)); + expect( + postrun.resultJson<_TaskResultPayload>( + decode: _TaskResultPayload.fromJson, + ), + isA<_TaskResultPayload>().having((value) => value.ok, 'ok', isTrue), + ); final retry = TaskRetryPayload( envelope: envelope, @@ -100,3 +106,13 @@ void main() { }); }); } + +class _TaskResultPayload { + const _TaskResultPayload({required this.ok}); + + factory _TaskResultPayload.fromJson(Map json) { + return _TaskResultPayload(ok: json['ok'] as bool); + } + + final bool ok; +} From 0ed2dfabf2e8c6d7abadc380f789467a4e9a62f3 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 09:03:10 -0500 Subject: [PATCH 205/302] Prefer workflow json wait helpers in docs snippets --- .../example/docs_snippets/lib/workflows.dart | 44 +++++++++++++++---- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index e2e1ff13..5305d098 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -17,6 +17,30 @@ class ApprovalDraft { } } +class ApprovalDecision { + const ApprovalDecision({required this.approvedBy}); + + final String approvedBy; + + Map toJson() => {'approvedBy': approvedBy}; + + factory ApprovalDecision.fromJson(Map json) { + return ApprovalDecision(approvedBy: json['approvedBy'] as String); + } +} + +class ChargePrepared { + const ChargePrepared({required this.chargeId}); + + final String chargeId; + + Map toJson() => {'chargeId': chargeId}; + + factory ChargePrepared.fromJson(Map json) { + return ChargePrepared(chargeId: json['chargeId'] as String); + } +} + // #region workflows-runtime Future bootstrapWorkflowApp() async { // #region workflows-app-create @@ -63,13 +87,14 @@ class ApprovalsFlow { }); flow.step('manager-review', (ctx) async { - final resume = ctx.waitForEventValue>( + final resume = ctx.waitForEventValueJson( 'approvals.manager', + decode: ApprovalDecision.fromJson, ); if (resume == null) { return null; } - return resume.value('approvedBy'); + return resume.approvedBy; }); flow.step('finalize', (ctx) async { @@ -96,13 +121,14 @@ final retryScript = WorkflowScript( name: 'billing.retry-script', run: (script) async { final chargeId = await script.step('charge', (ctx) async { - final resume = ctx.waitForEventValue>( + final resume = ctx.waitForEventValueJson( 'billing.charge.prepared', + decode: ChargePrepared.fromJson, ); if (resume == null) { return 'pending'; } - return resume.requiredValue('chargeId'); + return resume.chargeId; }); final receipt = await script.step('confirm', (ctx) async { @@ -180,13 +206,14 @@ class ApprovalsAnnotatedWorkflow { @WorkflowStep(name: 'manager-review') Future managerReview({FlowContext? context}) async { final ctx = context!; - final resume = ctx.waitForEventValue>( + final resume = ctx.waitForEventValueJson( 'approvals.manager', + decode: ApprovalDecision.fromJson, ); if (resume == null) { return null; } - return resume.value('approvedBy'); + return resume.approvedBy; } @WorkflowStep() @@ -202,13 +229,14 @@ class BillingRetryAnnotatedWorkflow { Future run({WorkflowScriptContext? context}) async { final script = context!; final chargeId = await script.step('charge', (ctx) async { - final resume = ctx.waitForEventValue>( + final resume = ctx.waitForEventValueJson( 'billing.charge.prepared', + decode: ChargePrepared.fromJson, ); if (resume == null) { return 'pending'; } - return resume.requiredValue('chargeId'); + return resume.chargeId; }); return script.step('confirm', (ctx) async { From 3b4dcc9bb4ef741224f2ba4895d38133501aead9 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 09:03:20 -0500 Subject: [PATCH 206/302] Tidy helper API follow-up cleanup --- .../stem/lib/src/workflow/core/flow_step.dart | 58 +++++++++---------- .../runtime/workflow_introspection.dart | 2 +- .../unit/core/task_context_enqueue_test.dart | 1 - .../test/unit/core/task_invocation_test.dart | 21 ++++--- 4 files changed, 42 insertions(+), 40 deletions(-) diff --git a/packages/stem/lib/src/workflow/core/flow_step.dart b/packages/stem/lib/src/workflow/core/flow_step.dart index 2ca66737..7106a738 100644 --- a/packages/stem/lib/src/workflow/core/flow_step.dart +++ b/packages/stem/lib/src/workflow/core/flow_step.dart @@ -50,6 +50,19 @@ class FlowStep { taskNames = List.unmodifiable(taskNames), metadata = metadata == null ? null : Map.unmodifiable(metadata); + /// Rehydrates a flow step from serialized JSON. + factory FlowStep.fromJson(Map json) { + return FlowStep( + name: json['name']?.toString() ?? '', + title: json['title']?.toString(), + kind: _kindFromJson(json['kind']), + taskNames: (json['taskNames'] as List?)?.cast() ?? const [], + autoVersion: json['autoVersion'] == true, + metadata: (json['metadata'] as Map?)?.cast(), + handler: (_) async {}, + ); + } + /// Creates a step definition backed by a typed [valueCodec]. static FlowStep typed({ required String name, @@ -74,19 +87,6 @@ class FlowStep { ); } - /// Rehydrates a flow step from serialized JSON. - factory FlowStep.fromJson(Map json) { - return FlowStep( - name: json['name']?.toString() ?? '', - title: json['title']?.toString(), - kind: _kindFromJson(json['kind']), - taskNames: (json['taskNames'] as List?)?.cast() ?? const [], - autoVersion: json['autoVersion'] == true, - metadata: (json['metadata'] as Map?)?.cast(), - handler: (_) async {}, - ); - } - /// Step name used for checkpoints and scheduling. final String name; @@ -166,18 +166,6 @@ class FlowStepControl { Map? data, }) => FlowStepControl._(FlowControlType.sleep, delay: duration, data: data); - /// Suspend the run until [duration] elapses with a DTO payload. - static FlowStepControl sleepJson( - Duration duration, - T value, { - String? typeName, - }) => FlowStepControl.sleep( - duration, - data: Map.from( - PayloadCodec.encodeJsonMap(value, typeName: typeName), - ), - ); - /// Suspend the run until an event with [topic] arrives. factory FlowStepControl.awaitTopic( String topic, { @@ -190,6 +178,22 @@ class FlowStepControl { data: data, ); + /// Continue execution without suspending. + factory FlowStepControl.continueRun() => + FlowStepControl._(FlowControlType.continueRun); + + /// Suspend the run until [duration] elapses with a DTO payload. + static FlowStepControl sleepJson( + Duration duration, + T value, { + String? typeName, + }) => FlowStepControl.sleep( + duration, + data: Map.from( + PayloadCodec.encodeJsonMap(value, typeName: typeName), + ), + ); + /// Suspend the run until an event with [topic] arrives with a DTO payload. static FlowStepControl awaitTopicJson( String topic, @@ -204,10 +208,6 @@ class FlowStepControl { ), ); - /// Continue execution without suspending. - factory FlowStepControl.continueRun() => - FlowStepControl._(FlowControlType.continueRun); - /// Control type emitted by the step. final FlowControlType type; diff --git a/packages/stem/lib/src/workflow/runtime/workflow_introspection.dart b/packages/stem/lib/src/workflow/runtime/workflow_introspection.dart index 567604cd..8187565f 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_introspection.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_introspection.dart @@ -1,5 +1,5 @@ -import 'package:stem/src/core/stem_event.dart'; import 'package:stem/src/core/payload_codec.dart'; +import 'package:stem/src/core/stem_event.dart'; /// Enumerates workflow step event types emitted by the runtime. enum WorkflowStepEventType { diff --git a/packages/stem/test/unit/core/task_context_enqueue_test.dart b/packages/stem/test/unit/core/task_context_enqueue_test.dart index 7a2c6243..f1111298 100644 --- a/packages/stem/test/unit/core/task_context_enqueue_test.dart +++ b/packages/stem/test/unit/core/task_context_enqueue_test.dart @@ -29,7 +29,6 @@ void main() { Object? progressData; final context = TaskContext( id: 'parent-0b', - args: const {}, attempt: 0, headers: const {}, meta: const {}, diff --git a/packages/stem/test/unit/core/task_invocation_test.dart b/packages/stem/test/unit/core/task_invocation_test.dart index 464f0823..3da06e29 100644 --- a/packages/stem/test/unit/core/task_invocation_test.dart +++ b/packages/stem/test/unit/core/task_invocation_test.dart @@ -145,22 +145,25 @@ void main() { expect(context.argOr('tenant', 'global'), equals('global')); }); - test('TaskInvocationContext.local reports progress with JSON DTO payloads', () async { - Object? progressData; - final context = TaskInvocationContext.local( - id: 'task-1b', - headers: const {}, - meta: const {}, + test( + 'TaskInvocationContext.local reports progress with JSON DTO payloads', + () async { + Object? progressData; + final context = TaskInvocationContext.local( + id: 'task-1b', + headers: const {}, + meta: const {}, attempt: 0, heartbeat: () {}, extendLease: (_) async {}, progress: (_, {Map? data}) async => progressData = data, ); - await context.progressJson(25, const _ProgressUpdate(stage: 'warming')); + await context.progressJson(25, const _ProgressUpdate(stage: 'warming')); - expect(progressData, equals(const {'stage': 'warming'})); - }); + expect(progressData, equals(const {'stage': 'warming'})); + }, + ); test('TaskInvocationContext.local merges headers/meta and lineage', () async { final enqueuer = _CapturingEnqueuer('task-1'); From e6d87006b960457339d75c0a11ed9e500ec9a604 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 09:04:13 -0500 Subject: [PATCH 207/302] Add task success signal result helpers --- .site/docs/core-concepts/observability.md | 6 +++--- packages/stem/CHANGELOG.md | 3 +++ packages/stem/README.md | 5 +++-- packages/stem/lib/src/signals/payloads.dart | 20 +++++++++++++++++++ .../stem/test/unit/signals/payloads_test.dart | 12 +++++++++++ 5 files changed, 41 insertions(+), 5 deletions(-) diff --git a/.site/docs/core-concepts/observability.md b/.site/docs/core-concepts/observability.md index 9dc6c0e2..bb8b4393 100644 --- a/.site/docs/core-concepts/observability.md +++ b/.site/docs/core-concepts/observability.md @@ -58,9 +58,9 @@ control-plane commands. ``` -When you inspect `TaskPostrunPayload` directly, prefer `payload.resultJson(...)` -or `payload.resultAs(codec: ...)` over manual `payload.result as Map` -casts. +When you inspect `TaskPostrunPayload` or `TaskSuccessPayload` directly, prefer +`payload.resultJson(...)` or `payload.resultAs(codec: ...)` over manual +`payload.result as Map` casts. ## Workflow Introspection diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 567028a9..42a4c7e9 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -57,6 +57,9 @@ - Added `TaskPostrunPayload.resultJson(...)` and `resultAs(codec: ...)` so task lifecycle signal consumers can decode DTO task results without manual casts. +- Added `TaskSuccessPayload.resultJson(...)` and `resultAs(codec: ...)` so + success-only task signal consumers can decode DTO task results without + manual casts. - Added `GroupStatus.resultValues()`, `resultJson(...)`, and `resultAs(codec: ...)` so canvas/group status inspection can decode typed child results without manually mapping raw `TaskStatus.payload` values. diff --git a/packages/stem/README.md b/packages/stem/README.md index cdd446bd..8dd5e77b 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -915,8 +915,9 @@ Workflow introspection events expose matching helpers via For lower-level suspension directives, prefer `step.sleepJson(...)`, `step.awaitEventJson(...)`, and `FlowStepControl.awaitTopicJson(...)` over hand-built maps. -Task lifecycle signals expose matching result helpers on `TaskPostrunPayload` -via `payload.resultJson(...)` and `payload.resultAs(codec: ...)`. +Task lifecycle signals expose matching result helpers on +`TaskPostrunPayload` and `TaskSuccessPayload` via `payload.resultJson(...)` +and `payload.resultAs(codec: ...)`. In the example above, these calls inside `run(...)`: diff --git a/packages/stem/lib/src/signals/payloads.dart b/packages/stem/lib/src/signals/payloads.dart index 0ae90415..79c55428 100644 --- a/packages/stem/lib/src/signals/payloads.dart +++ b/packages/stem/lib/src/signals/payloads.dart @@ -336,6 +336,26 @@ class TaskSuccessPayload implements StemEvent { /// The result returned by the successful task. final Object? result; + /// Decodes the task result with [codec]. + TResult? resultAs({required PayloadCodec codec}) { + final stored = result; + if (stored == null) return null; + return codec.decode(stored); + } + + /// Decodes the task result with a JSON decoder. + TResult? resultJson({ + required TResult Function(Map payload) decode, + String? typeName, + }) { + final stored = result; + if (stored == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(stored); + } + final DateTime _occurredAt; /// The unique identifier for the task. diff --git a/packages/stem/test/unit/signals/payloads_test.dart b/packages/stem/test/unit/signals/payloads_test.dart index a149a218..35fa873d 100644 --- a/packages/stem/test/unit/signals/payloads_test.dart +++ b/packages/stem/test/unit/signals/payloads_test.dart @@ -73,6 +73,18 @@ void main() { retry.attributes['nextRetryAt'], equals(DateTime.utc(2025).toIso8601String()), ); + + final success = TaskSuccessPayload( + envelope: envelope, + worker: worker, + result: const {'ok': true}, + ); + expect( + success.resultJson<_TaskResultPayload>( + decode: _TaskResultPayload.fromJson, + ), + isA<_TaskResultPayload>().having((value) => value.ok, 'ok', isTrue), + ); }); test('control command payload timestamps are frozen at creation', () { From 2080fd4364377ee6a1c961c2686f426386806eca Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 09:05:05 -0500 Subject: [PATCH 208/302] Add flow control payload decode helpers --- .../workflows/context-and-serialization.md | 2 ++ packages/stem/CHANGELOG.md | 3 +++ packages/stem/README.md | 2 ++ .../stem/lib/src/workflow/core/flow_step.dart | 20 +++++++++++++++++++ .../test/unit/workflow/flow_step_test.dart | 12 +++++++++++ 5 files changed, 39 insertions(+) diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index fa222dc4..66208c39 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -53,6 +53,8 @@ Depending on the context type, you can access: - `sleepJson(...)`, `awaitEventJson(...)`, and `FlowStepControl.awaitTopicJson(...)` when lower-level suspension directives still need DTO metadata without a separate codec constant +- `control.dataJson(...)` / `control.dataAs(codec: ...)` when you inspect a + lower-level `FlowStepControl` directly - `takeResumeData()` for event-driven resumes - `takeResumeValue(codec: ...)` for typed event-driven resumes - `takeResumeJson(...)` for DTO event-driven resumes without a separate diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 42a4c7e9..6c8f58d5 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -60,6 +60,9 @@ - Added `TaskSuccessPayload.resultJson(...)` and `resultAs(codec: ...)` so success-only task signal consumers can decode DTO task results without manual casts. +- Added `FlowStepControl.dataJson(...)` and `dataAs(codec: ...)` so + lower-level suspension control objects can decode DTO metadata without + manual casts. - Added `GroupStatus.resultValues()`, `resultJson(...)`, and `resultAs(codec: ...)` so canvas/group status inspection can decode typed child results without manually mapping raw `TaskStatus.payload` values. diff --git a/packages/stem/README.md b/packages/stem/README.md index 8dd5e77b..004152bd 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -918,6 +918,8 @@ hand-built maps. Task lifecycle signals expose matching result helpers on `TaskPostrunPayload` and `TaskSuccessPayload` via `payload.resultJson(...)` and `payload.resultAs(codec: ...)`. +Low-level `FlowStepControl` objects expose matching suspension metadata +helpers via `control.dataJson(...)` and `control.dataAs(codec: ...)`. In the example above, these calls inside `run(...)`: diff --git a/packages/stem/lib/src/workflow/core/flow_step.dart b/packages/stem/lib/src/workflow/core/flow_step.dart index 7106a738..abe30ec5 100644 --- a/packages/stem/lib/src/workflow/core/flow_step.dart +++ b/packages/stem/lib/src/workflow/core/flow_step.dart @@ -222,6 +222,26 @@ class FlowStepControl { /// Additional data to persist with the suspension. final Map? data; + + /// Decodes the suspension metadata with [codec], when present. + TData? dataAs({required PayloadCodec codec}) { + final stored = data; + if (stored == null) return null; + return codec.decode(stored); + } + + /// Decodes the suspension metadata with a JSON decoder, when present. + TData? dataJson({ + required TData Function(Map payload) decode, + String? typeName, + }) { + final stored = data; + if (stored == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(stored); + } } /// Enumerates the suspension control types. diff --git a/packages/stem/test/unit/workflow/flow_step_test.dart b/packages/stem/test/unit/workflow/flow_step_test.dart index e4870287..b693b662 100644 --- a/packages/stem/test/unit/workflow/flow_step_test.dart +++ b/packages/stem/test/unit/workflow/flow_step_test.dart @@ -36,6 +36,14 @@ void main() { ); expect(sleep.data, equals(const {'stage': 'sleeping'})); + expect( + sleep.dataJson<_SuspensionPayload>(decode: _SuspensionPayload.fromJson), + isA<_SuspensionPayload>().having( + (value) => value.stage, + 'stage', + 'sleeping', + ), + ); expect(wait.data, equals(const {'stage': 'waiting'})); expect(wait.deadline, DateTime.parse('2025-01-01T00:00:00Z')); }); @@ -44,6 +52,10 @@ void main() { class _SuspensionPayload { const _SuspensionPayload({required this.stage}); + factory _SuspensionPayload.fromJson(Map json) { + return _SuspensionPayload(stage: json['stage'] as String); + } + final String stage; Map toJson() => {'stage': stage}; From 03996f8762c3758f91615bc3c95e64cb2defb64d Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 09:15:50 -0500 Subject: [PATCH 209/302] Add workflow run metadata decode helpers --- .site/docs/core-concepts/observability.md | 3 ++ packages/stem/CHANGELOG.md | 4 ++ packages/stem/README.md | 3 ++ packages/stem/lib/src/signals/payloads.dart | 37 +++++++++++++++++++ .../stem/test/unit/signals/payloads_test.dart | 37 +++++++++++++++++++ 5 files changed, 84 insertions(+) diff --git a/.site/docs/core-concepts/observability.md b/.site/docs/core-concepts/observability.md index bb8b4393..44d69ffe 100644 --- a/.site/docs/core-concepts/observability.md +++ b/.site/docs/core-concepts/observability.md @@ -61,6 +61,9 @@ control-plane commands. When you inspect `TaskPostrunPayload` or `TaskSuccessPayload` directly, prefer `payload.resultJson(...)` or `payload.resultAs(codec: ...)` over manual `payload.result as Map` casts. +For workflow lifecycle signals, prefer `payload.metadataJson('key', ...)` or +`payload.metadataAs('key', codec: ...)` over manual +`payload.metadata['key'] as Map` casts. ## Workflow Introspection diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 6c8f58d5..4d3647b1 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -60,6 +60,10 @@ - Added `TaskSuccessPayload.resultJson(...)` and `resultAs(codec: ...)` so success-only task signal consumers can decode DTO task results without manual casts. +- Added `WorkflowRunPayload.metadataValue(...)`, + `requiredMetadataValue(...)`, `metadataJson(...)`, and + `metadataAs(codec: ...)` so workflow lifecycle signal consumers can decode + structured metadata without raw map casts. - Added `FlowStepControl.dataJson(...)` and `dataAs(codec: ...)` so lower-level suspension control objects can decode DTO metadata without manual casts. diff --git a/packages/stem/README.md b/packages/stem/README.md index 004152bd..0fa21f90 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -918,6 +918,9 @@ hand-built maps. Task lifecycle signals expose matching result helpers on `TaskPostrunPayload` and `TaskSuccessPayload` via `payload.resultJson(...)` and `payload.resultAs(codec: ...)`. +Workflow lifecycle signals expose matching metadata helpers on +`WorkflowRunPayload` via `payload.metadataJson('key', ...)`, +`payload.metadataAs('key', codec: ...)`, and `payload.metadataValue('key')`. Low-level `FlowStepControl` objects expose matching suspension metadata helpers via `control.dataJson(...)` and `control.dataAs(codec: ...)`. diff --git a/packages/stem/lib/src/signals/payloads.dart b/packages/stem/lib/src/signals/payloads.dart index 79c55428..48d65b80 100644 --- a/packages/stem/lib/src/signals/payloads.dart +++ b/packages/stem/lib/src/signals/payloads.dart @@ -3,6 +3,7 @@ import 'package:stem/src/core/clock.dart'; import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/core/envelope.dart'; import 'package:stem/src/core/payload_codec.dart'; +import 'package:stem/src/core/payload_map.dart'; import 'package:stem/src/core/stem_event.dart'; /// Status of a workflow run emitted via signals. @@ -596,6 +597,42 @@ class WorkflowRunPayload implements StemEvent { /// Additional metadata associated with the workflow run. final Map metadata; + /// Returns the decoded metadata value for [key], or `null` when absent. + /// + /// When [codec] is supplied, the stored durable payload is decoded through + /// that codec before being returned. + T? metadataValue(String key, {PayloadCodec? codec}) { + return metadata.value(key, codec: codec); + } + + /// Decodes the metadata value for [key] as a typed DTO with [codec]. + T? metadataAs(String key, {required PayloadCodec codec}) { + return metadata.value(key, codec: codec); + } + + /// Decodes the metadata value for [key] as a typed DTO with a JSON decoder. + T? metadataJson( + String key, { + required T Function(Map payload) decode, + String? typeName, + }) { + return metadata.valueJson( + key, + decode: decode, + typeName: typeName, + ); + } + + /// Returns the decoded metadata value for [key], or [fallback] when absent. + T metadataValueOr(String key, T fallback, {PayloadCodec? codec}) { + return metadata.valueOr(key, fallback, codec: codec); + } + + /// Returns the decoded metadata value for [key], throwing when absent. + T requiredMetadataValue(String key, {PayloadCodec? codec}) { + return metadata.requiredValue(key, codec: codec); + } + /// Optional canonical signal name when this payload is emitted. final String? signalName; diff --git a/packages/stem/test/unit/signals/payloads_test.dart b/packages/stem/test/unit/signals/payloads_test.dart index 35fa873d..5a28952e 100644 --- a/packages/stem/test/unit/signals/payloads_test.dart +++ b/packages/stem/test/unit/signals/payloads_test.dart @@ -117,6 +117,33 @@ void main() { expect(completed.occurredAt, DateTime.utc(2025, 1, 1, 0, 1)); }); }); + + test('workflow run payload exposes typed metadata helpers', () { + final payload = WorkflowRunPayload( + runId: 'run-1', + workflow: 'demo.workflow', + status: WorkflowRunStatus.suspended, + metadata: const { + 'attempt': 3, + 'approval': {'approved': true}, + }, + ); + + expect(payload.metadataValue('attempt'), 3); + expect(payload.metadataValueOr('missing', 'fallback'), 'fallback'); + expect(payload.requiredMetadataValue('attempt'), 3); + expect( + payload.metadataJson<_WorkflowRunMetadata>( + 'approval', + decode: _WorkflowRunMetadata.fromJson, + ), + isA<_WorkflowRunMetadata>().having( + (value) => value.approved, + 'approved', + isTrue, + ), + ); + }); } class _TaskResultPayload { @@ -128,3 +155,13 @@ class _TaskResultPayload { final bool ok; } + +class _WorkflowRunMetadata { + const _WorkflowRunMetadata({required this.approved}); + + factory _WorkflowRunMetadata.fromJson(Map json) { + return _WorkflowRunMetadata(approved: json['approved'] as bool); + } + + final bool approved; +} From aae21e6828d37a3942119aa020f80d2ca1462eae Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 09:39:52 -0500 Subject: [PATCH 210/302] Add shared task execution context --- .site/docs/core-concepts/stem-builder.md | 2 +- .site/docs/workflows/annotated-workflows.md | 2 +- .../workflows/context-and-serialization.md | 6 +- packages/stem/CHANGELOG.md | 3 + packages/stem/README.md | 7 +- .../example/annotated_workflows/README.md | 2 +- .../annotated_workflows/lib/definitions.dart | 4 +- packages/stem/lib/src/core/contracts.dart | 97 +++++++++++++------ .../stem/lib/src/core/task_invocation.dart | 26 ++--- .../unit/core/task_context_enqueue_test.dart | 4 +- .../test/unit/core/task_invocation_test.dart | 15 +-- packages/stem_builder/README.md | 6 +- .../lib/src/stem_registry_builder.dart | 6 +- .../test/stem_registry_builder_test.dart | 10 +- 14 files changed, 115 insertions(+), 75 deletions(-) diff --git a/.site/docs/core-concepts/stem-builder.md b/.site/docs/core-concepts/stem-builder.md index 86da9c0f..d9b7f9ac 100644 --- a/.site/docs/core-concepts/stem-builder.md +++ b/.site/docs/core-concepts/stem-builder.md @@ -56,7 +56,7 @@ class UserSignupWorkflow { Future logAudit( String event, String id, { - TaskInvocationContext? context, + TaskExecutionContext? context, }) async { final ctx = context!; ctx.progress(1.0, data: {'event': event, 'id': id}); diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index 167adbfa..b1327516 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -153,7 +153,7 @@ example that demonstrates: - `WorkflowScriptContext` - `WorkflowScriptStepContext` - optional named context injection -- `TaskInvocationContext` +- `TaskExecutionContext` - codec-backed DTO workflow checkpoints and final workflow results - typed task DTO input and result decoding diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index 66208c39..d56421d5 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -11,7 +11,7 @@ Everything else that crosses a durable boundary must be serializable. - script runs: `WorkflowScriptContext` - script checkpoints: `WorkflowScriptStepContext` or `WorkflowExecutionContext` -- tasks: `TaskInvocationContext` +- tasks: `TaskExecutionContext` Those context objects are not part of the persisted payload shape. They are injected by the runtime when the handler executes. @@ -22,7 +22,7 @@ parameter: - `Future run(String email, {WorkflowScriptContext? context})` - `Future checkpoint(String email, {WorkflowExecutionContext? context})` - `Future step({WorkflowExecutionContext? context})` -- `Future task(String id, {TaskInvocationContext? context})` +- `Future task(String id, {TaskExecutionContext? context})` ## What context gives you @@ -64,7 +64,7 @@ Depending on the context type, you can access: `ref.start(context, params: value)` and `ref.startAndWait(context, params: value)` - direct task enqueue APIs because `WorkflowExecutionContext` and - `TaskInvocationContext` both implement `TaskEnqueuer` + `TaskExecutionContext` both implement `TaskEnqueuer` - task metadata like `id`, `attempt`, `meta` Child workflow starts belong in durable boundaries: diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 4d3647b1..6e71d3c3 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -64,6 +64,9 @@ `requiredMetadataValue(...)`, `metadataJson(...)`, and `metadataAs(codec: ...)` so workflow lifecycle signal consumers can decode structured metadata without raw map casts. +- Added `TaskExecutionContext` as the shared task-side execution surface for + `TaskContext` and `TaskInvocationContext`, and taught `stem_builder` to + accept it directly in annotated task definitions. - Added `FlowStepControl.dataJson(...)` and `dataAs(codec: ...)` so lower-level suspension control objects can decode DTO metadata without manual casts. diff --git a/packages/stem/README.md b/packages/stem/README.md index 0fa21f90..a422a8c9 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -312,7 +312,8 @@ class ParentTask implements TaskHandler { ``` When a task runs inside a workflow-enabled runtime like `StemWorkflowApp`, -`TaskContext` and `TaskInvocationContext` can also start typed child workflows +`TaskExecutionContext` implementations like `TaskContext` and +`TaskInvocationContext` can also start typed child workflows and emit typed workflow events: ```dart @@ -649,7 +650,7 @@ class BuilderUserSignupWorkflow { @TaskDefn(name: 'builder.example.task') Future builderExampleTask( Map args, - {TaskInvocationContext? context} + {TaskExecutionContext? context} ) async {} ``` @@ -667,7 +668,7 @@ Context injection works at every runtime layer: - script runs can take `WorkflowScriptContext` - script checkpoints can take `WorkflowScriptStepContext` or `WorkflowExecutionContext` -- tasks can take `TaskInvocationContext` +- tasks can take `TaskExecutionContext` Durable workflow execution contexts enqueue tasks directly: diff --git a/packages/stem/example/annotated_workflows/README.md b/packages/stem/example/annotated_workflows/README.md index e3649a16..b6608c69 100644 --- a/packages/stem/example/annotated_workflows/README.md +++ b/packages/stem/example/annotated_workflows/README.md @@ -19,7 +19,7 @@ It now demonstrates the generated script-proxy behavior explicitly: `StemWorkflowDefinitions.*.startAndWait(context, params: value)` - a plain script workflow that returns a codec-backed DTO result and persists a codec-backed DTO checkpoint value -- a typed `@TaskDefn` using optional named `TaskInvocationContext? context` +- a typed `@TaskDefn` using optional named `TaskExecutionContext? context` plus codec-backed DTO input/output types When you run the example, it prints: diff --git a/packages/stem/example/annotated_workflows/lib/definitions.dart b/packages/stem/example/annotated_workflows/lib/definitions.dart index 34713f65..088d5651 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.dart @@ -309,7 +309,7 @@ class AnnotatedContextScriptWorkflow { @TaskDefn(name: 'send_email', options: TaskOptions(maxRetries: 1)) Future sendEmail( Map args, { - TaskInvocationContext? context, + TaskExecutionContext? context, }) async { final ctx = context!; ctx.heartbeat(); @@ -319,7 +319,7 @@ Future sendEmail( @TaskDefn(name: 'send_email_typed', options: TaskOptions(maxRetries: 1)) Future sendEmailTyped( EmailDispatch dispatch, { - TaskInvocationContext? context, + TaskExecutionContext? context, }) async { final ctx = context!; ctx.heartbeat(); diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 7119c883..806cee27 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -1840,67 +1840,100 @@ extension TaskInputContextArgs on TaskInputContext { } } -/// Context passed to handler implementations during execution. -class TaskContext +/// Shared execution surface for task handlers and isolate entrypoints. +abstract interface class TaskExecutionContext implements TaskEnqueuer, WorkflowCaller, WorkflowEventEmitter, TaskInputContext { + /// The unique identifier of the task. + String get id; + + /// The current attempt number. + int get attempt; + + /// Headers associated with the task. + Map get headers; + + /// Metadata for the task invocation. + Map get meta; + + /// Notify the worker that the task is still running. + void heartbeat(); + + /// Request an extension of the current lease by [by]. + Future extendLease(Duration by); + + /// Report progress back to the worker. + Future progress(double percentComplete, {Map? data}); +} + +/// Shared task-progress helpers for execution contexts. +extension TaskExecutionContextProgressX on TaskExecutionContext { + /// Report progress with a JSON-serializable DTO payload. + Future progressJson( + double percentComplete, + T value, { + String? typeName, + }) { + return progress( + percentComplete, + data: Map.from( + PayloadCodec.encodeJsonMap(value, typeName: typeName), + ), + ); + } +} + +/// Context passed to handler implementations during execution. +class TaskContext implements TaskExecutionContext { /// Creates a task execution context for a handler invocation. TaskContext({ required this.id, required this.attempt, required this.headers, required this.meta, - required this.heartbeat, - required this.extendLease, - required this.progress, + required void Function() heartbeat, + required Future Function(Duration) extendLease, + required Future Function( + double percentComplete, { + Map? data, + }) + progress, this.args = const {}, this.enqueuer, this.workflows, this.workflowEvents, - }); + }) : _heartbeat = heartbeat, + _extendLease = extendLease, + _progress = progress; /// The unique identifier of the task. + @override final String id; @override final Map args; /// The current attempt number. + @override final int attempt; /// Headers associated with the task. + @override final Map headers; /// Metadata for the task. + @override final Map meta; - - /// Function to send a heartbeat. - final void Function() heartbeat; - - /// Function to extend the lease by a given duration. - final Future Function(Duration) extendLease; - - /// Function to report progress. + final void Function() _heartbeat; + final Future Function(Duration) _extendLease; final Future Function( double percentComplete, { Map? data, }) - progress; - - /// Report progress with a JSON-serializable DTO payload. - Future progressJson(double percentComplete, T value, { - String? typeName, - }) { - return progress( - percentComplete, - data: Map.from( - PayloadCodec.encodeJsonMap(value, typeName: typeName), - ), - ); - } + _progress; /// Optional enqueuer for scheduling additional tasks. final TaskEnqueuer? enqueuer; @@ -1911,6 +1944,16 @@ class TaskContext /// Optional workflow event emitter for resuming waiting workflows. final WorkflowEventEmitter? workflowEvents; + @override + void heartbeat() => _heartbeat(); + + @override + Future extendLease(Duration by) => _extendLease(by); + + @override + Future progress(double percentComplete, {Map? data}) => + _progress(percentComplete, data: data); + /// Enqueue a task with default context propagation. /// /// Headers and metadata from this context are merged into the enqueue diff --git a/packages/stem/lib/src/core/task_invocation.dart b/packages/stem/lib/src/core/task_invocation.dart index bfdad2f2..8d516199 100644 --- a/packages/stem/lib/src/core/task_invocation.dart +++ b/packages/stem/lib/src/core/task_invocation.dart @@ -276,12 +276,7 @@ class EmitWorkflowEventResponse { } /// Context exposed to task entrypoints regardless of execution environment. -class TaskInvocationContext - implements - TaskEnqueuer, - WorkflowCaller, - WorkflowEventEmitter, - TaskInputContext { +class TaskInvocationContext implements TaskExecutionContext { /// Context implementation used when executing locally in the same isolate. factory TaskInvocationContext.local({ required String id, @@ -361,18 +356,22 @@ class TaskInvocationContext _workflowEvents = workflowEvents; /// The unique identifier of the task. + @override final String id; @override final Map args; /// Headers passed to the task invocation. + @override final Map headers; /// Invocation metadata (e.g. trace, tenant). + @override final Map meta; /// Current attempt count. + @override final int attempt; final void Function() _heartbeat; @@ -393,27 +392,18 @@ class TaskInvocationContext final WorkflowEventEmitter? _workflowEvents; /// Notify the worker that the task is still running. + @override void heartbeat() => _heartbeat(); /// Request an extension of the underlying broker lease/visibility timeout. + @override Future extendLease(Duration by) => _extendLease(by); /// Report progress back to the worker. + @override Future progress(double percentComplete, {Map? data}) => _progress(percentComplete, data: data); - /// Report progress with a JSON-serializable DTO payload. - Future progressJson(double percentComplete, T value, { - String? typeName, - }) { - return progress( - percentComplete, - data: Map.from( - PayloadCodec.encodeJsonMap(value, typeName: typeName), - ), - ); - } - /// Enqueue a task from within a task invocation. /// /// Headers and metadata from this context are merged into the enqueue diff --git a/packages/stem/test/unit/core/task_context_enqueue_test.dart b/packages/stem/test/unit/core/task_context_enqueue_test.dart index f1111298..7ad47bae 100644 --- a/packages/stem/test/unit/core/task_context_enqueue_test.dart +++ b/packages/stem/test/unit/core/task_context_enqueue_test.dart @@ -10,7 +10,7 @@ const _parentAttemptKey = 'stem.parentAttempt'; void main() { group('TaskContext.enqueue', () { test('exposes typed arg readers on the context', () async { - final context = TaskContext( + final TaskExecutionContext context = TaskContext( id: 'parent-0', args: const {'invoiceId': 'inv-42'}, attempt: 0, @@ -27,7 +27,7 @@ void main() { test('reports progress with JSON DTO payloads', () async { Object? progressData; - final context = TaskContext( + final TaskExecutionContext context = TaskContext( id: 'parent-0b', attempt: 0, headers: const {}, diff --git a/packages/stem/test/unit/core/task_invocation_test.dart b/packages/stem/test/unit/core/task_invocation_test.dart index 3da06e29..ca885192 100644 --- a/packages/stem/test/unit/core/task_invocation_test.dart +++ b/packages/stem/test/unit/core/task_invocation_test.dart @@ -130,7 +130,7 @@ class _CapturingWorkflowEventEmitter implements WorkflowEventEmitter { void main() { test('TaskInvocationContext.local exposes typed arg readers', () { - final context = TaskInvocationContext.local( + final TaskExecutionContext context = TaskInvocationContext.local( id: 'task-1', args: const {'customerId': 'cus-42'}, headers: const {}, @@ -149,15 +149,16 @@ void main() { 'TaskInvocationContext.local reports progress with JSON DTO payloads', () async { Object? progressData; - final context = TaskInvocationContext.local( + final TaskExecutionContext context = TaskInvocationContext.local( id: 'task-1b', headers: const {}, meta: const {}, - attempt: 0, - heartbeat: () {}, - extendLease: (_) async {}, - progress: (_, {Map? data}) async => progressData = data, - ); + attempt: 0, + heartbeat: () {}, + extendLease: (_) async {}, + progress: (_, {Map? data}) async => + progressData = data, + ); await context.progressJson(25, const _ProgressUpdate(stage: 'warming')); diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index d6940017..a129187e 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -55,7 +55,7 @@ class HelloScript { @TaskDefn(name: 'hello.task') Future helloTask( String email, - {TaskInvocationContext? context} + {TaskExecutionContext? context} ) async { // ... } @@ -102,7 +102,7 @@ Supported context injection points: - script runs: `WorkflowScriptContext` - script checkpoints: `WorkflowScriptStepContext` or `WorkflowExecutionContext` -- tasks: `TaskInvocationContext` +- tasks: `TaskExecutionContext` Durable workflow execution contexts enqueue tasks directly: @@ -293,4 +293,4 @@ See [`example/README.md`](example/README.md) for runnable examples, including: - Generated registration + execution with `StemWorkflowApp` - Runtime manifest + run detail views with `WorkflowRuntime` - Plain direct-call script checkpoints and context-aware script checkpoints -- Typed `@TaskDefn` parameters with `TaskInvocationContext` +- Typed `@TaskDefn` parameters with `TaskExecutionContext` diff --git a/packages/stem_builder/lib/src/stem_registry_builder.dart b/packages/stem_builder/lib/src/stem_registry_builder.dart index e518eb1f..c0e10e3e 100644 --- a/packages/stem_builder/lib/src/stem_registry_builder.dart +++ b/packages/stem_builder/lib/src/stem_registry_builder.dart @@ -57,7 +57,7 @@ class StemRegistryBuilder implements Builder { inPackage: 'stem', ); const taskContextChecker = TypeChecker.typeNamed( - TaskInvocationContext, + TaskExecutionContext, inPackage: 'stem', ); const mapChecker = TypeChecker.typeNamed(Map, inSdk: true); @@ -617,7 +617,7 @@ class StemRegistryBuilder implements Builder { [taskContextChecker], function, annotationLabel: '@TaskDefn function', - contextTypeLabel: 'TaskInvocationContext', + contextTypeLabel: 'TaskExecutionContext', ); final remaining = parameters @@ -648,7 +648,7 @@ class StemRegistryBuilder implements Builder { for (final parameter in remaining) { if (!parameter.isRequiredPositional) { throw InvalidGenerationSourceError( - '@TaskDefn function ${function.displayName} only supports required positional serializable or codec-backed parameters after TaskInvocationContext.', + '@TaskDefn function ${function.displayName} only supports required positional serializable or codec-backed parameters after TaskExecutionContext.', element: function, ); } diff --git a/packages/stem_builder/test/stem_registry_builder_test.dart b/packages/stem_builder/test/stem_registry_builder_test.dart index 74d17a96..4f2cb112 100644 --- a/packages/stem_builder/test/stem_registry_builder_test.dart +++ b/packages/stem_builder/test/stem_registry_builder_test.dart @@ -79,7 +79,8 @@ class WorkflowScriptContext { }) async => throw UnimplementedError(); } class WorkflowScriptStepContext implements WorkflowExecutionContext {} -class TaskInvocationContext {} +abstract class TaskExecutionContext {} +class TaskInvocationContext implements TaskExecutionContext {} class NoArgsTaskDefinition { const NoArgsTaskDefinition({ @@ -997,7 +998,8 @@ class SignupWorkflow { ); test( - 'supports optional named WorkflowExecutionContext injection in script checkpoints', + 'supports optional named WorkflowExecutionContext injection ' + 'in script checkpoints', () async { const input = ''' import 'package:stem/stem.dart'; @@ -1102,7 +1104,7 @@ class HelloWorkflow { }, ); - test('supports optional named TaskInvocationContext injection', () async { + test('supports optional named TaskExecutionContext injection', () async { const input = ''' import 'package:stem/stem.dart'; @@ -1111,7 +1113,7 @@ part 'workflows.stem.g.dart'; @TaskDefn(name: 'typed.task') Future typedTask( String email, { - TaskInvocationContext? context, + TaskExecutionContext? context, }) async {} '''; From c3a43d6ae21d451eeed27b79f3a0bf11911815b7 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 09:48:54 -0500 Subject: [PATCH 211/302] Add task progress signal decode helpers --- .site/docs/core-concepts/tasks.md | 2 + packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 4 ++ .../stem/lib/src/core/task_invocation.dart | 46 +++++++++++++++++++ .../test/unit/core/task_invocation_test.dart | 34 ++++++++++++-- 5 files changed, 85 insertions(+), 4 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 6e2ccffa..a41ec8a3 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -124,6 +124,8 @@ every retry signal and shows how the strategy interacts with broker timings. - `context.progress(percent, data: {...})` – emit progress signals for UI hooks. - `context.progressJson(percent, dto)` – emit DTO progress payloads without hand-built maps. +- when you inspect a raw `ProgressSignal`, prefer `signal.dataJson('key', ...)` + or `signal.dataValue('key')` over manual `signal.data?['key']` casts. Use the context to build idempotent handlers. Re-enqueue work, cancel jobs, or store audit details in `context.meta`. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 6e71d3c3..7890e0fa 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -64,6 +64,9 @@ `requiredMetadataValue(...)`, `metadataJson(...)`, and `metadataAs(codec: ...)` so workflow lifecycle signal consumers can decode structured metadata without raw map casts. +- Added `ProgressSignal.dataValue(...)`, `requiredDataValue(...)`, + `dataJson(...)`, and `dataAs(codec: ...)` so raw task-progress signal + consumers can decode structured progress metadata without raw map casts. - Added `TaskExecutionContext` as the shared task-side execution surface for `TaskContext` and `TaskInvocationContext`, and taught `stem_builder` to accept it directly in annotated task definitions. diff --git a/packages/stem/README.md b/packages/stem/README.md index a422a8c9..b57a2151 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -311,6 +311,10 @@ class ParentTask implements TaskHandler { } ``` +If you inspect raw task progress signals, prefer +`signal.dataJson('key', ...)`, `signal.dataAs('key', codec: ...)`, or +`signal.dataValue('key')` over manual `signal.data?['key']` casts. + When a task runs inside a workflow-enabled runtime like `StemWorkflowApp`, `TaskExecutionContext` implementations like `TaskContext` and `TaskInvocationContext` can also start typed child workflows diff --git a/packages/stem/lib/src/core/task_invocation.dart b/packages/stem/lib/src/core/task_invocation.dart index 8d516199..e19b5454 100644 --- a/packages/stem/lib/src/core/task_invocation.dart +++ b/packages/stem/lib/src/core/task_invocation.dart @@ -38,6 +38,7 @@ import 'dart:isolate'; import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/core/payload_codec.dart'; +import 'package:stem/src/core/payload_map.dart'; import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; import 'package:stem/src/workflow/core/workflow_event_ref.dart'; import 'package:stem/src/workflow/core/workflow_ref.dart'; @@ -80,6 +81,51 @@ class ProgressSignal extends TaskInvocationSignal { /// Optional progress metadata. final Map? data; + + /// Returns the decoded progress metadata value for [key], or `null`. + T? dataValue(String key, {PayloadCodec? codec}) { + final payload = data; + if (payload == null) return null; + return payload.value(key, codec: codec); + } + + /// Returns the decoded progress metadata value for [key], or [fallback]. + T dataValueOr(String key, T fallback, {PayloadCodec? codec}) { + final payload = data; + if (payload == null) return fallback; + return payload.valueOr(key, fallback, codec: codec); + } + + /// Returns the decoded progress metadata value for [key], throwing if absent. + T requiredDataValue(String key, {PayloadCodec? codec}) { + final payload = data; + if (payload == null) { + throw StateError('Progress signal does not include metadata.'); + } + return payload.requiredValue(key, codec: codec); + } + + /// Decodes the progress metadata value for [key] as a typed DTO with [codec]. + T? dataAs(String key, {required PayloadCodec codec}) { + final payload = data; + if (payload == null) return null; + return payload.value(key, codec: codec); + } + + /// Decodes the progress metadata value for [key] as a typed DTO from JSON. + T? dataJson( + String key, { + required T Function(Map payload) decode, + String? typeName, + }) { + final payload = data; + if (payload == null) return null; + return payload.valueJson( + key, + decode: decode, + typeName: typeName, + ); + } } /// Request to enqueue a task from an isolate. diff --git a/packages/stem/test/unit/core/task_invocation_test.dart b/packages/stem/test/unit/core/task_invocation_test.dart index ca885192..5654cd2e 100644 --- a/packages/stem/test/unit/core/task_invocation_test.dart +++ b/packages/stem/test/unit/core/task_invocation_test.dart @@ -148,7 +148,7 @@ void main() { test( 'TaskInvocationContext.local reports progress with JSON DTO payloads', () async { - Object? progressData; + ProgressSignal? progressSignal; final TaskExecutionContext context = TaskInvocationContext.local( id: 'task-1b', headers: const {}, @@ -156,16 +156,38 @@ void main() { attempt: 0, heartbeat: () {}, extendLease: (_) async {}, - progress: (_, {Map? data}) async => - progressData = data, + progress: (percent, {Map? data}) async { + progressSignal = ProgressSignal(percent, data: data); + }, ); await context.progressJson(25, const _ProgressUpdate(stage: 'warming')); - expect(progressData, equals(const {'stage': 'warming'})); + expect(progressSignal?.data, equals(const {'stage': 'warming'})); }, ); + test('ProgressSignal exposes typed progress metadata helpers', () { + const signal = ProgressSignal( + 50, + data: { + 'step': 2, + 'update': {'stage': 'warming'}, + }, + ); + + expect(signal.dataValue('step'), 2); + expect(signal.dataValueOr('missing', 'fallback'), 'fallback'); + expect(signal.requiredDataValue('step'), 2); + expect( + signal.dataJson<_ProgressUpdate>( + 'update', + decode: _ProgressUpdate.fromJson, + ), + isA<_ProgressUpdate>().having((value) => value.stage, 'stage', 'warming'), + ); + }); + test('TaskInvocationContext.local merges headers/meta and lineage', () async { final enqueuer = _CapturingEnqueuer('task-1'); final context = TaskInvocationContext.local( @@ -557,6 +579,10 @@ class _WorkflowEventPayload { class _ProgressUpdate { const _ProgressUpdate({required this.stage}); + factory _ProgressUpdate.fromJson(Map json) { + return _ProgressUpdate(stage: json['stage'] as String); + } + final String stage; Map toJson() => {'stage': stage}; From cca2dd9f992dcaba974e28356fd5da5e72746056 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 09:50:13 -0500 Subject: [PATCH 212/302] Add shared task retry surface --- .site/docs/core-concepts/tasks.md | 2 ++ packages/stem/CHANGELOG.md | 3 +++ packages/stem/README.md | 3 +++ packages/stem/lib/src/core/contracts.dart | 11 +++++++++++ .../stem/test/unit/core/task_invocation_test.dart | 2 +- 5 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index a41ec8a3..99ab92e0 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -124,6 +124,8 @@ every retry signal and shows how the strategy interacts with broker timings. - `context.progress(percent, data: {...})` – emit progress signals for UI hooks. - `context.progressJson(percent, dto)` – emit DTO progress payloads without hand-built maps. +- `context.retry(...)` – request an immediate retry with optional per-call + retry policy overrides. - when you inspect a raw `ProgressSignal`, prefer `signal.dataJson('key', ...)` or `signal.dataValue('key')` over manual `signal.data?['key']` casts. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 7890e0fa..4d0bef56 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -70,6 +70,9 @@ - Added `TaskExecutionContext` as the shared task-side execution surface for `TaskContext` and `TaskInvocationContext`, and taught `stem_builder` to accept it directly in annotated task definitions. +- Added `TaskExecutionContext.retry(...)` so typed task handlers can request + retries through the shared task context surface instead of depending on + concrete runtime classes. - Added `FlowStepControl.dataJson(...)` and `dataAs(codec: ...)` so lower-level suspension control objects can decode DTO metadata without manual casts. diff --git a/packages/stem/README.md b/packages/stem/README.md index b57a2151..e996b308 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -314,6 +314,9 @@ class ParentTask implements TaskHandler { If you inspect raw task progress signals, prefer `signal.dataJson('key', ...)`, `signal.dataAs('key', codec: ...)`, or `signal.dataValue('key')` over manual `signal.data?['key']` casts. +Shared `TaskExecutionContext` implementations also expose +`context.retry(...)`, so typed annotated tasks can request retries without +depending on a concrete task runtime class. When a task runs inside a workflow-enabled runtime like `StemWorkflowApp`, `TaskExecutionContext` implementations like `TaskContext` and diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 806cee27..b2947562 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -1867,6 +1867,16 @@ abstract interface class TaskExecutionContext /// Report progress back to the worker. Future progress(double percentComplete, {Map? data}); + + /// Request a retry of the current task. + Future retry({ + Duration? countdown, + DateTime? eta, + TaskRetryPolicy? retryPolicy, + int? maxRetries, + Duration? timeLimit, + Duration? softTimeLimit, + }); } /// Shared task-progress helpers for execution contexts. @@ -2139,6 +2149,7 @@ class TaskContext implements TaskExecutionContext { /// Throws a [TaskRetryRequest] which is intercepted by the worker to /// schedule the retry. Override retry policies/time limits per invocation /// by passing the optional parameters. + @override Future retry({ Duration? countdown, DateTime? eta, diff --git a/packages/stem/test/unit/core/task_invocation_test.dart b/packages/stem/test/unit/core/task_invocation_test.dart index 5654cd2e..17c97607 100644 --- a/packages/stem/test/unit/core/task_invocation_test.dart +++ b/packages/stem/test/unit/core/task_invocation_test.dart @@ -552,7 +552,7 @@ void main() { }); test('TaskInvocationContext.retry throws TaskRetryRequest', () { - final context = TaskInvocationContext.local( + final TaskExecutionContext context = TaskInvocationContext.local( id: 'retry-task', headers: const {}, meta: const {}, From 3625f9c4b7c37dedcd9901d6fb198112a7084dda Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 09:51:34 -0500 Subject: [PATCH 213/302] Prefer shared task context in docs --- .site/docs/core-concepts/tasks.md | 18 +++++++++--------- packages/stem/CHANGELOG.md | 3 +++ packages/stem/README.md | 5 ++--- .../stem/example/docs_snippets/lib/tasks.dart | 6 +++--- .../stem/example/task_context_mixed/README.md | 3 +++ 5 files changed, 20 insertions(+), 15 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 99ab92e0..e36dcfe8 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -142,14 +142,14 @@ final tenant = context.argOr('tenant', 'global'); See the `packages/stem/example/task_context_mixed` demo for a runnable sample that exercises inline + isolate enqueue, TaskRetryPolicy overrides, and enqueue options. -The `packages/stem/example/task_usage_patterns.dart` sample shows in-memory TaskContext and -TaskInvocationContext patterns without external dependencies. +The `packages/stem/example/task_usage_patterns.dart` sample shows in-memory +`TaskExecutionContext` patterns without external dependencies. ### Enqueue from a running task -Use `TaskContext.enqueue`/`spawn` to schedule follow-up work with the same -defaults as `Stem.enqueue`. For isolate entrypoints, `TaskInvocationContext` -exposes the same API plus the fluent builder. +Use `TaskExecutionContext.enqueue`/`spawn` to schedule follow-up work with the +same defaults as `Stem.enqueue`. Concrete runtimes like `TaskContext` and +`TaskInvocationContext` expose the same API. ```dart file=/../packages/stem/example/docs_snippets/lib/tasks.dart#tasks-context-enqueue @@ -162,15 +162,15 @@ Inside isolate entrypoints: ``` When a task runs inside a workflow-enabled runtime like `StemWorkflowApp`, -both `TaskContext` and `TaskInvocationContext` also implement -`WorkflowCaller`, so handlers and isolate entrypoints can start or wait for +`TaskExecutionContext` also implements `WorkflowCaller`, so handlers and +isolate entrypoints can start or wait for typed child workflows without dropping to raw workflow-name APIs. For manual flows and scripts, prefer `childFlow.startAndWait(context)` or `childWorkflowRef.startAndWait(context, params: value)` for the simple case. Use a builder only when you need advanced overrides. -Those same contexts also implement `WorkflowEventEmitter`, so tasks can resume -waiting workflows through `emitValue(...)` or typed `WorkflowEventRef` +That same shared task context also implements `WorkflowEventEmitter`, so tasks +can resume waiting workflows through `emitValue(...)` or typed `WorkflowEventRef` instances when a workflow runtime is attached. ### Retry from a running task diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 4d0bef56..854260f9 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -73,6 +73,9 @@ - Added `TaskExecutionContext.retry(...)` so typed task handlers can request retries through the shared task context surface instead of depending on concrete runtime classes. +- Updated task docs/snippets to prefer `TaskExecutionContext` for shared + enqueue/workflow/event examples, leaving `TaskContext` and + `TaskInvocationContext` only where the runtime distinction matters. - Added `FlowStepControl.dataJson(...)` and `dataAs(codec: ...)` so lower-level suspension control objects can decode DTO metadata without manual casts. diff --git a/packages/stem/README.md b/packages/stem/README.md index e996b308..a766271e 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -319,9 +319,8 @@ Shared `TaskExecutionContext` implementations also expose depending on a concrete task runtime class. When a task runs inside a workflow-enabled runtime like `StemWorkflowApp`, -`TaskExecutionContext` implementations like `TaskContext` and -`TaskInvocationContext` can also start typed child workflows -and emit typed workflow events: +`TaskExecutionContext` can also start typed child workflows and emit typed +workflow events: ```dart final childWorkflow = Flow( diff --git a/packages/stem/example/docs_snippets/lib/tasks.dart b/packages/stem/example/docs_snippets/lib/tasks.dart index c1b52816..7a4b54b8 100644 --- a/packages/stem/example/docs_snippets/lib/tasks.dart +++ b/packages/stem/example/docs_snippets/lib/tasks.dart @@ -97,7 +97,7 @@ Future runTypedDefinitionExample() async { // #endregion tasks-typed-definition // #region tasks-context-enqueue -Future enqueueFromContext(TaskContext context) async { +Future enqueueFromContext(TaskExecutionContext context) async { await context.enqueue( 'tasks.child', args: {'id': '123'}, @@ -129,7 +129,7 @@ final childDefinition = TaskDefinition( ); // #region tasks-invocation-builder -Future enqueueWithBuilder(TaskInvocationContext invocation) async { +Future enqueueWithBuilder(TaskExecutionContext context) async { await childDefinition .prepareEnqueue(const ChildArgs('value')) .queue('critical') @@ -144,7 +144,7 @@ Future enqueueWithBuilder(TaskInvocationContext invocation) async { ), ), ) - .enqueue(invocation); + .enqueue(context); } // #endregion tasks-invocation-builder diff --git a/packages/stem/example/task_context_mixed/README.md b/packages/stem/example/task_context_mixed/README.md index f1c94323..a80fc637 100644 --- a/packages/stem/example/task_context_mixed/README.md +++ b/packages/stem/example/task_context_mixed/README.md @@ -5,6 +5,9 @@ handlers) and `TaskInvocationContext` (inline + isolate entrypoints). It also shows the full `TaskEnqueueOptions` / `TaskRetryPolicy` surface that mirrors Celery-style `apply_async` controls. +Both concrete contexts share the same `TaskExecutionContext` surface for +enqueueing, progress reporting, workflow/event calls, and retry requests. + ## Requirements - Dart 3.3+ From 7af0d67d55aedbb1a4aa9a11110f4c04fbb456fc Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 09:53:48 -0500 Subject: [PATCH 214/302] Add shared task spawn surface --- packages/stem/CHANGELOG.md | 3 +++ packages/stem/lib/src/core/contracts.dart | 14 ++++++++++++ .../stem/lib/src/core/task_invocation.dart | 4 ++++ .../unit/core/task_context_enqueue_test.dart | 22 +++++++++++++++++++ .../test/unit/core/task_invocation_test.dart | 19 ++++++++++++++++ 5 files changed, 62 insertions(+) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 854260f9..afdc1ec6 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -73,6 +73,9 @@ - Added `TaskExecutionContext.retry(...)` so typed task handlers can request retries through the shared task context surface instead of depending on concrete runtime classes. +- Added `TaskExecutionContext.spawn(...)` so the shared task context surface + now covers the common follow-up enqueue alias, including `notBefore` + forwarding. - Updated task docs/snippets to prefer `TaskExecutionContext` for shared enqueue/workflow/event examples, leaving `TaskContext` and `TaskInvocationContext` only where the runtime distinction matters. diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index b2947562..78481b62 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -1877,6 +1877,17 @@ abstract interface class TaskExecutionContext Duration? timeLimit, Duration? softTimeLimit, }); + + /// Alias for [enqueue] when spawning follow-up work from the current task. + Future spawn( + String name, { + Map args, + Map headers, + TaskOptions options, + DateTime? notBefore, + Map meta, + TaskEnqueueOptions? enqueueOptions, + }); } /// Shared task-progress helpers for execution contexts. @@ -2126,12 +2137,14 @@ class TaskContext implements TaskExecutionContext { } /// Alias for [enqueue]. + @override Future spawn( String name, { Map args = const {}, Map headers = const {}, Map meta = const {}, TaskOptions options = const TaskOptions(), + DateTime? notBefore, TaskEnqueueOptions? enqueueOptions, }) { return enqueue( @@ -2140,6 +2153,7 @@ class TaskContext implements TaskExecutionContext { headers: headers, meta: meta, options: options, + notBefore: notBefore, enqueueOptions: enqueueOptions, ); } diff --git a/packages/stem/lib/src/core/task_invocation.dart b/packages/stem/lib/src/core/task_invocation.dart index e19b5454..3c900574 100644 --- a/packages/stem/lib/src/core/task_invocation.dart +++ b/packages/stem/lib/src/core/task_invocation.dart @@ -631,11 +631,13 @@ class TaskInvocationContext implements TaskExecutionContext { } /// Alias for enqueue. + @override Future spawn( String name, { Map args = const {}, Map headers = const {}, TaskOptions options = const TaskOptions(), + DateTime? notBefore, Map meta = const {}, TaskEnqueueOptions? enqueueOptions, }) { @@ -644,6 +646,7 @@ class TaskInvocationContext implements TaskExecutionContext { args: args, headers: headers, options: options, + notBefore: notBefore, meta: meta, enqueueOptions: enqueueOptions, ); @@ -654,6 +657,7 @@ class TaskInvocationContext implements TaskExecutionContext { /// Throws a [TaskRetryRequest] which is intercepted by the worker to /// schedule the retry. Override retry policies/time limits per invocation /// by passing the optional parameters. + @override Future retry({ Duration? countdown, DateTime? eta, diff --git a/packages/stem/test/unit/core/task_context_enqueue_test.dart b/packages/stem/test/unit/core/task_context_enqueue_test.dart index 7ad47bae..0d1a907c 100644 --- a/packages/stem/test/unit/core/task_context_enqueue_test.dart +++ b/packages/stem/test/unit/core/task_context_enqueue_test.dart @@ -97,6 +97,25 @@ void main() { expect(record.meta.containsKey(_parentAttemptKey), isFalse); }); + test('spawn forwards notBefore', () async { + final enqueuer = _RecordingEnqueuer(); + final TaskExecutionContext context = TaskContext( + id: 'parent-2b', + attempt: 0, + headers: const {}, + meta: const {}, + heartbeat: () {}, + extendLease: (_) async {}, + progress: (_, {data}) async {}, + enqueuer: enqueuer, + ); + final scheduledAt = DateTime.now().add(const Duration(minutes: 1)); + + await context.spawn('tasks.child', notBefore: scheduledAt); + + expect(enqueuer.last?.notBefore, scheduledAt); + }); + test('spawn delegates to enqueue semantics', () async { final enqueuer = _RecordingEnqueuer(); final context = TaskContext( @@ -406,6 +425,7 @@ class _RecordedEnqueue { required this.headers, required this.meta, required this.options, + required this.notBefore, required this.enqueueOptions, }); @@ -414,6 +434,7 @@ class _RecordedEnqueue { final Map headers; final Map meta; final TaskOptions options; + final DateTime? notBefore; final TaskEnqueueOptions? enqueueOptions; } @@ -439,6 +460,7 @@ class _RecordingEnqueuer implements TaskEnqueuer { headers: Map.from(headers), meta: Map.from(meta), options: options, + notBefore: notBefore, enqueueOptions: enqueueOptions, ), ); diff --git a/packages/stem/test/unit/core/task_invocation_test.dart b/packages/stem/test/unit/core/task_invocation_test.dart index 17c97607..dfe1349d 100644 --- a/packages/stem/test/unit/core/task_invocation_test.dart +++ b/packages/stem/test/unit/core/task_invocation_test.dart @@ -234,6 +234,25 @@ void main() { expect(enqueuer.lastNotBefore, scheduledAt); }); + test('TaskInvocationContext.local spawn forwards notBefore', () async { + final enqueuer = _CapturingEnqueuer('task-1'); + final TaskExecutionContext context = TaskInvocationContext.local( + id: 'root-task', + headers: const {}, + meta: const {}, + attempt: 0, + heartbeat: () {}, + extendLease: (_) async {}, + progress: (_, {Map? data}) async {}, + enqueuer: enqueuer, + ); + final scheduledAt = DateTime.now().add(const Duration(minutes: 5)); + + await context.spawn('child', notBefore: scheduledAt); + + expect(enqueuer.lastNotBefore, scheduledAt); + }); + test('TaskInvocationContext.local throws when enqueuer missing', () async { final context = TaskInvocationContext.local( id: 'no-enqueuer', From d53f369214c0e3201b62dfe2c43dec2a735facb7 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 09:58:22 -0500 Subject: [PATCH 215/302] Add versioned json payload codecs --- .../workflows/context-and-serialization.md | 5 + packages/stem/CHANGELOG.md | 2 + packages/stem/README.md | 5 + packages/stem/lib/src/core/payload_codec.dart | 82 ++++++++++++- .../test/unit/core/payload_codec_test.dart | 111 ++++++++++++++++++ 5 files changed, 204 insertions(+), 1 deletion(-) diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index d56421d5..a74bb262 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -134,6 +134,11 @@ For normal DTOs that expose `toJson()` and `Type.fromJson(...)`, prefer `PayloadCodec.json(...)`. Drop down to `PayloadCodec.map(...)` when you need a custom map encoder or a nonstandard decode function. +If the DTO payload shape is expected to evolve, use +`PayloadCodec.versionedJson(...)`. That persists a reserved +`__stemPayloadVersion` field beside the JSON payload and gives the decoder the +stored version so it can read older shapes explicitly. + For manual flows and scripts, prefer the typed workflow param helpers before dropping to raw map casts: diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index afdc1ec6..b0a8a446 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.1.1 +- Added `PayloadCodec.versionedJson(...)` so DTO payload codecs can persist a + schema version beside the JSON payload and decode older shapes explicitly. - Added low-level DTO shortcuts for name-based dispatch: `TaskEnqueuer.enqueueJson(...)`, `WorkflowRuntime.startWorkflowJson(...)`, `StemWorkflowApp.startWorkflowJson(...)`, `WorkflowRuntime.emitJson(...)`, diff --git a/packages/stem/README.md b/packages/stem/README.md index a766271e..e3faa412 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -266,6 +266,11 @@ common `toJson()` / `Type.fromJson(...)` case. Use paths keep waiting helpers typed and advertise the right result encoder in task metadata. +When a DTO payload needs an explicit persisted schema version, prefer +`PayloadCodec.versionedJson(...)`. It stores `__stemPayloadVersion` beside the +JSON payload and passes the persisted version into the decoder so you can keep +older payloads readable while newer producers emit the latest shape. + You can also build requests fluently from the task definition itself: ```dart diff --git a/packages/stem/lib/src/core/payload_codec.dart b/packages/stem/lib/src/core/payload_codec.dart index 33fb8331..7fa561e8 100644 --- a/packages/stem/lib/src/core/payload_codec.dart +++ b/packages/stem/lib/src/core/payload_codec.dart @@ -12,6 +12,9 @@ class PayloadCodec { }) : _encode = encode, _decode = decode, _decodeMap = null, + _decodeVersionedMap = null, + _jsonVersion = null, + _defaultDecodeVersion = null, _typeName = null; /// Creates a payload codec for DTOs that serialize to a durable map payload. @@ -32,6 +35,9 @@ class PayloadCodec { }) : _encode = encode, _decode = null, _decodeMap = decode, + _decodeVersionedMap = null, + _jsonVersion = null, + _defaultDecodeVersion = null, _typeName = typeName; /// Creates a payload codec for DTOs that expose `toJson()` and a matching @@ -50,11 +56,46 @@ class PayloadCodec { }) : _encode = _encodeJsonPayload, _decode = null, _decodeMap = decode, + _decodeVersionedMap = null, + _jsonVersion = null, + _defaultDecodeVersion = null, _typeName = typeName; + /// Creates a JSON DTO codec that also persists a schema version. + /// + /// Use this when a payload shape is expected to evolve over time and the + /// decoder needs to know which persisted schema version it is reading. + /// + /// ```dart + /// const approvalCodec = PayloadCodec.versionedJson( + /// version: 2, + /// defaultDecodeVersion: 1, + /// decode: Approval.fromVersionedJson, + /// ); + /// ``` + const PayloadCodec.versionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) : _encode = _encodeJsonPayload, + _decode = null, + _decodeMap = null, + _decodeVersionedMap = decode, + _jsonVersion = version, + _defaultDecodeVersion = defaultDecodeVersion, + _typeName = typeName; + + /// Reserved key used to persist payload schema versions for versioned codecs. + static const String versionKey = '__stemPayloadVersion'; + final Object? Function(T value) _encode; final T Function(Object? payload)? _decode; final T Function(Map payload)? _decodeMap; + final T Function(Map payload, int version)? + _decodeVersionedMap; + final int? _jsonVersion; + final int? _defaultDecodeVersion; final String? _typeName; /// Encodes a DTO to the string-keyed map shape required by task/workflow @@ -77,7 +118,16 @@ class PayloadCodec { } /// Converts a typed value into a durable payload representation. - Object? encode(T value) => _encode(value); + Object? encode(T value) { + final encoded = _encode(value); + final version = _jsonVersion; + if (version == null) return encoded; + final json = _payloadJsonMap(encoded, _typeName ?? '$T'); + return { + versionKey: version, + ...json, + }; + } /// Reconstructs a typed value from a durable payload representation. T decode(Object? payload) { @@ -85,6 +135,17 @@ class PayloadCodec { if (decode != null) { return decode(payload); } + final decodeVersionedMap = _decodeVersionedMap; + if (decodeVersionedMap != null) { + final json = _payloadJsonMap(payload, _typeName ?? '$T'); + final version = _payloadVersion( + json, + defaultVersion: _defaultDecodeVersion ?? _jsonVersion ?? 1, + typeName: _typeName ?? '$T', + ); + final normalized = Map.from(json)..remove(versionKey); + return decodeVersionedMap(normalized, version); + } final decodeMap = _decodeMap!; return decodeMap(_payloadJsonMap(payload, _typeName ?? '$T')); } @@ -139,6 +200,25 @@ Map _payloadJsonMap(Object? value, String typeName) { ); } +int _payloadVersion( + Map payload, { + required int defaultVersion, + required String typeName, +}) { + final rawVersion = payload[PayloadCodec.versionKey]; + if (rawVersion == null) return defaultVersion; + if (rawVersion is int) return rawVersion; + if (rawVersion is num) return rawVersion.toInt(); + if (rawVersion is String) { + final parsed = int.tryParse(rawVersion); + if (parsed != null) return parsed; + } + throw StateError( + '$typeName payload version must be an int-compatible value, got ' + '${rawVersion.runtimeType}.', + ); +} + /// Bridges a [PayloadCodec] into the existing [TaskPayloadEncoder] contract. class CodecTaskPayloadEncoder extends TaskPayloadEncoder { /// Creates a task payload encoder backed by a typed [codec]. diff --git a/packages/stem/test/unit/core/payload_codec_test.dart b/packages/stem/test/unit/core/payload_codec_test.dart index 61b0f71a..57b822d0 100644 --- a/packages/stem/test/unit/core/payload_codec_test.dart +++ b/packages/stem/test/unit/core/payload_codec_test.dart @@ -60,6 +60,89 @@ void main() { }); }); + group('PayloadCodec.versionedJson', () { + test('encodes DTOs with a persisted schema version', () { + const codec = PayloadCodec<_VersionedCodecPayload>.versionedJson( + version: 2, + decode: _VersionedCodecPayload.fromVersionedJson, + typeName: '_VersionedCodecPayload', + ); + + final payload = codec.encode( + const _VersionedCodecPayload(id: 'payload-v0', count: 4), + ); + + expect(payload, { + PayloadCodec.versionKey: 2, + 'id': 'payload-v0', + 'count': 4, + }); + }); + + test('passes the persisted schema version to the decoder', () { + const codec = PayloadCodec<_VersionedCodecPayload>.versionedJson( + version: 2, + decode: _VersionedCodecPayload.fromVersionedJson, + typeName: '_VersionedCodecPayload', + ); + + final decoded = codec.decode({ + PayloadCodec.versionKey: 3, + 'id': 'payload-v1', + 'count': 8, + }); + + expect(decoded.id, 'payload-v1'); + expect(decoded.count, 8); + expect(decoded.decodedVersion, 3); + }); + + test('falls back to the configured default decode version', () { + const codec = PayloadCodec<_VersionedCodecPayload>.versionedJson( + version: 3, + defaultDecodeVersion: 1, + decode: _VersionedCodecPayload.fromVersionedJson, + typeName: '_VersionedCodecPayload', + ); + + final decoded = codec.decode({ + 'id': 'payload-v2', + 'count': 11, + }); + + expect(decoded.id, 'payload-v2'); + expect(decoded.count, 11); + expect(decoded.decodedVersion, 1); + }); + + test('rejects invalid persisted schema versions with a clear error', () { + const codec = PayloadCodec<_VersionedCodecPayload>.versionedJson( + version: 2, + decode: _VersionedCodecPayload.fromVersionedJson, + typeName: '_VersionedCodecPayload', + ); + + expect( + () => codec.decode({ + PayloadCodec.versionKey: true, + 'id': 'payload-v3', + 'count': 13, + }), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains( + '_VersionedCodecPayload payload version must be an ' + 'int-compatible ' + 'value', + ), + ), + ), + ); + }); + }); + group('PayloadCodec.map', () { test('decodes typed DTO payloads from durable maps', () { const codec = PayloadCodec<_CodecPayload>.map( @@ -182,3 +265,31 @@ class _NoJsonPayload { final String id; } + +class _VersionedCodecPayload { + const _VersionedCodecPayload({ + required this.id, + required this.count, + this.decodedVersion, + }); + + factory _VersionedCodecPayload.fromVersionedJson( + Map json, + int version, + ) { + return _VersionedCodecPayload( + id: json['id']! as String, + count: json['count']! as int, + decodedVersion: version, + ); + } + + final String id; + final int count; + final int? decodedVersion; + + Map toJson() => { + 'id': id, + 'count': count, + }; +} From 2d355c64c3221d3754cd4ccee686e717b9fcfa88 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 10:02:37 -0500 Subject: [PATCH 216/302] Add whole-payload context decode helpers --- .site/docs/core-concepts/tasks.md | 9 +++++ .../workflows/context-and-serialization.md | 15 ++++++++ packages/stem/CHANGELOG.md | 4 +++ packages/stem/README.md | 6 ++++ packages/stem/lib/src/core/contracts.dart | 16 +++++++++ .../core/workflow_execution_context.dart | 16 +++++++++ .../core/workflow_script_context.dart | 16 +++++++++ .../test/unit/core/task_invocation_test.dart | 26 ++++++++++++++ .../unit/workflow/workflow_resume_test.dart | 36 ++++++++++++++++++- 9 files changed, 143 insertions(+), 1 deletion(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index e36dcfe8..217a8183 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -77,6 +77,15 @@ final customerId = args.requiredValue('customerId'); final tenant = args.valueOr('tenant', 'global'); ``` +When the whole task arg payload is one DTO, prefer decoding it directly from +the execution context: + +```dart +final request = context.argsJson( + decode: InvoicePayload.fromJson, +); +``` + `TaskEnqueueBuilder` also supports `enqueueAndWait(...)`, and typed task definitions can now create a fluent builder directly through `definition.prepareEnqueue(...)`. `TaskEnqueuer.prepareEnqueue(...)` remains diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index a74bb262..6997002c 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -36,6 +36,8 @@ Depending on the context type, you can access: - workflow params and previous results - `param()` / `requiredParam()` for typed access to workflow start params +- `paramsAs(codec: ...)` / `paramsJson()` for decoding the full workflow + start payload as one DTO - `paramJson()` / `requiredParamJson()` for nested DTO params without a separate codec constant - `paramListJson()` / `requiredParamListJson()` for lists of nested DTO @@ -65,6 +67,8 @@ Depending on the context type, you can access: `ref.startAndWait(context, params: value)` - direct task enqueue APIs because `WorkflowExecutionContext` and `TaskExecutionContext` both implement `TaskEnqueuer` +- `argsAs(codec: ...)` / `argsJson()` for decoding the full task-arg + payload as one DTO inside manual task handlers - task metadata like `id`, `attempt`, `meta` Child workflow starts belong in durable boundaries: @@ -143,6 +147,9 @@ For manual flows and scripts, prefer the typed workflow param helpers before dropping to raw map casts: ```dart +final request = ctx.paramsJson( + decode: OrderRequest.fromJson, +); final userId = ctx.requiredParam('userId'); final draft = ctx.requiredParam( 'draft', @@ -150,6 +157,14 @@ final draft = ctx.requiredParam( ); ``` +For manual tasks, the same pattern applies to the full arg payload: + +```dart +final request = context.argsJson( + decode: OrderRequest.fromJson, +); +``` + ## Practical rule When you need context metadata, add the appropriate optional named context diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index b0a8a446..c7389b88 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.1 +- Added `TaskExecutionContext.argsAs(...)` / `argsJson(...)`, + `WorkflowExecutionContext.paramsAs(...)` / `paramsJson(...)`, and the same + full-payload helpers on `WorkflowScriptContext`, so manual task/workflow + code can decode an entire DTO input without field-by-field map plumbing. - Added `PayloadCodec.versionedJson(...)` so DTO payload codecs can persist a schema version beside the JSON payload and decode older shapes explicitly. - Added low-level DTO shortcuts for name-based dispatch: diff --git a/packages/stem/README.md b/packages/stem/README.md index e3faa412..a75bccd5 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -236,6 +236,12 @@ final draft = ctx.requiredParam( 'draft', codec: approvalDraftCodec, ); +final taskArgs = context.argsJson( + decode: InvoicePayload.fromJson, +); +final workflowParams = ctx.paramsAs( + codec: approvalDraftCodec, +); ``` For typed task calls, the definition and call objects now expose the common diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 78481b62..08557189 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -1742,6 +1742,22 @@ abstract interface class TaskInputContext { /// Typed read helpers for task invocation args. extension TaskInputContextArgs on TaskInputContext { + /// Decodes the full task-argument payload through [codec]. + T argsAs({required PayloadCodec codec}) { + return codec.decode(args); + } + + /// Decodes the full task-argument payload as a DTO. + T argsJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(args); + } + /// Returns the decoded task arg for [key], or `null`. T? arg(String key, {PayloadCodec? codec}) { return args.value(key, codec: codec); diff --git a/packages/stem/lib/src/workflow/core/workflow_execution_context.dart b/packages/stem/lib/src/workflow/core/workflow_execution_context.dart index f183952c..773a0d0a 100644 --- a/packages/stem/lib/src/workflow/core/workflow_execution_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_execution_context.dart @@ -44,6 +44,22 @@ abstract interface class WorkflowExecutionContext /// Typed read helpers for workflow start parameters. extension WorkflowExecutionContextParams on WorkflowExecutionContext { + /// Decodes the full workflow start-parameter payload through [codec]. + T paramsAs({required PayloadCodec codec}) { + return codec.decode(params); + } + + /// Decodes the full workflow start-parameter payload as a DTO. + T paramsJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(params); + } + /// Returns the decoded workflow parameter for [key], or `null`. T? param(String key, {PayloadCodec? codec}) { return params.value(key, codec: codec); diff --git a/packages/stem/lib/src/workflow/core/workflow_script_context.dart b/packages/stem/lib/src/workflow/core/workflow_script_context.dart index f1e51a10..555823f2 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script_context.dart @@ -63,6 +63,22 @@ extension WorkflowScriptStepSuspensionJson on WorkflowScriptStepContext { /// Typed read helpers for workflow start parameters in script run methods. extension WorkflowScriptContextParams on WorkflowScriptContext { + /// Decodes the full workflow start-parameter payload through [codec]. + T paramsAs({required PayloadCodec codec}) { + return codec.decode(params); + } + + /// Decodes the full workflow start-parameter payload as a DTO. + T paramsJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(params); + } + /// Returns the decoded workflow parameter for [key], or `null`. T? param(String key, {PayloadCodec? codec}) { return params.value(key, codec: codec); diff --git a/packages/stem/test/unit/core/task_invocation_test.dart b/packages/stem/test/unit/core/task_invocation_test.dart index dfe1349d..9ce9db3d 100644 --- a/packages/stem/test/unit/core/task_invocation_test.dart +++ b/packages/stem/test/unit/core/task_invocation_test.dart @@ -145,6 +145,28 @@ void main() { expect(context.argOr('tenant', 'global'), equals('global')); }); + test('TaskExecutionContext decodes whole task arg DTOs', () { + final TaskExecutionContext context = TaskInvocationContext.local( + id: 'task-1a', + args: const {'stage': 'warming'}, + headers: const {}, + meta: const {}, + attempt: 0, + heartbeat: () {}, + extendLease: (_) async {}, + progress: (_, {Map? data}) async {}, + ); + + expect( + context.argsJson<_ProgressUpdate>(decode: _ProgressUpdate.fromJson).stage, + 'warming', + ); + expect( + context.argsAs<_ProgressUpdate>(codec: _progressUpdateCodec).stage, + 'warming', + ); + }); + test( 'TaskInvocationContext.local reports progress with JSON DTO payloads', () async { @@ -607,6 +629,10 @@ class _ProgressUpdate { Map toJson() => {'stage': stage}; } +const _progressUpdateCodec = PayloadCodec<_ProgressUpdate>.json( + decode: _ProgressUpdate.fromJson, +); + const PayloadCodec<_WorkflowEventPayload> _eventPayloadCodec = PayloadCodec<_WorkflowEventPayload>( encode: _encodeWorkflowEventPayload, diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart index 23a9776c..f10617b1 100644 --- a/packages/stem/test/unit/workflow/workflow_resume_test.dart +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -119,7 +119,9 @@ void main() { 'WorkflowScriptContext.requiredParam decodes codec-backed workflow params', () { final context = _FakeWorkflowScriptContext( - params: const {'payload': {'message': 'approved'}}, + params: const { + 'payload': {'message': 'approved'}, + }, ); final value = context.requiredParam<_ResumePayload>( @@ -131,6 +133,38 @@ void main() { }, ); + test( + 'workflow contexts decode whole workflow param DTOs', + () { + final flowContext = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'draft', + params: const {'message': 'approved'}, + previousResult: null, + stepIndex: 0, + ); + final scriptContext = _FakeWorkflowScriptContext( + params: const {'message': 'queued'}, + ); + + expect( + flowContext + .paramsJson<_ResumePayload>( + decode: _ResumePayload.fromJson, + ) + .message, + 'approved', + ); + expect( + scriptContext + .paramsAs<_ResumePayload>(codec: _resumePayloadCodec) + .message, + 'queued', + ); + }, + ); + test( 'WorkflowExecutionContext.requiredPreviousValue ' 'decodes codec-backed values', From e4f411361ff2ce4992ede4907bebf2ad2423ad1b Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 10:16:10 -0500 Subject: [PATCH 217/302] Add versioned low-level dto dispatch helpers --- .site/docs/core-concepts/queue-events.md | 3 + .site/docs/core-concepts/tasks.md | 4 +- .site/docs/workflows/starting-and-waiting.md | 3 +- .../docs/workflows/suspensions-and-events.md | 11 +- packages/stem/CHANGELOG.md | 7 + packages/stem/README.md | 10 +- .../stem/lib/src/bootstrap/workflow_app.dart | 54 +++++++- packages/stem/lib/src/core/contracts.dart | 30 +++++ packages/stem/lib/src/core/payload_codec.dart | 13 ++ packages/stem/lib/src/core/queue_events.dart | 26 ++++ .../workflow/runtime/workflow_runtime.dart | 46 +++++++ .../stem/test/bootstrap/stem_app_test.dart | 87 +++++++++++++ .../test/unit/core/payload_codec_test.dart | 14 ++ .../test/unit/core/queue_events_test.dart | 33 +++++ .../stem/test/unit/core/stem_core_test.dart | 27 ++++ .../test/workflow/workflow_runtime_test.dart | 121 +++++++++++++----- 16 files changed, 442 insertions(+), 47 deletions(-) diff --git a/.site/docs/core-concepts/queue-events.md b/.site/docs/core-concepts/queue-events.md index 0ff162da..e7fdd987 100644 --- a/.site/docs/core-concepts/queue-events.md +++ b/.site/docs/core-concepts/queue-events.md @@ -15,6 +15,7 @@ Use this when you need lightweight event streams for domain notifications - `QueueEventsProducer.emit(queue, eventName, payload, headers, meta)` - `QueueEventsProducer.emitJson(queue, eventName, dto, headers, meta)` +- `QueueEventsProducer.emitVersionedJson(queue, eventName, dto, version, headers, meta)` - `QueueEvents.start()` / `QueueEvents.close()` - `QueueEvents.events` stream (all events for that queue) - `QueueEvents.on(eventName)` stream (filtered by name) @@ -45,6 +46,8 @@ Multiple listeners on the same queue receive each emitted event. queue. - `emitJson(...)` is the DTO convenience path when the payload already exposes `toJson()`. +- `emitVersionedJson(...)` is the same convenience path when the payload + schema should persist an explicit `__stemPayloadVersion`. - `on(eventName)` matches exact event names. - `headers` and `meta` round-trip to listeners. - Event names and queue names must be non-empty. diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 217a8183..d946eab6 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -67,7 +67,9 @@ when the type already has `toJson()`. Use `TaskDefinition.codec(...)` when you need a custom `PayloadCodec`. Task args still need to encode to a string-keyed map (typically `Map`) because they are published as JSON-shaped -data. +data. For low-level name-based enqueue APIs, use `enqueueVersionedJson(...)` +when the DTO schema is expected to evolve and the payload should persist an +explicit `__stemPayloadVersion`. For manual handlers, prefer the typed payload readers on the argument map instead of repeating raw casts: diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 918e09db..083ad042 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -153,7 +153,8 @@ assemble a start request incrementally before dispatch. ## Parent runs and TTL -`WorkflowRuntime.startWorkflow(...)` and `startWorkflowJson(...)` also support: +`WorkflowRuntime.startWorkflow(...)`, `startWorkflowJson(...)`, and +`startWorkflowVersionedJson(...)` also support: - `parentRunId` when one workflow needs to track provenance from another run - `ttl` when you want run metadata to expire after a bounded retention period diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index d94a4dd8..091a81b5 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -49,9 +49,10 @@ final payload = await ctx.waitForEventJson( ## Emit resume events Use `WorkflowRuntime.emit(...)` / `WorkflowRuntime.emitJson(...)` / -`WorkflowRuntime.emitValue(...)` (or the app wrappers -`workflowApp.emitJson(...)` / `workflowApp.emitValue(...)`) instead of -hand-editing store state: +`WorkflowRuntime.emitVersionedJson(...)` / `WorkflowRuntime.emitValue(...)` +(or the app wrappers `workflowApp.emitJson(...)` / +`workflowApp.emitVersionedJson(...)` / `workflowApp.emitValue(...)`) instead +of hand-editing store state: ```dart await workflowApp.emitJson( @@ -61,8 +62,8 @@ await workflowApp.emitJson( ``` Typed event payloads still serialize to a string-keyed JSON-like map. -`emitJson(...)` and `emitValue(...)` are DTO/codec convenience layers, not a -new transport shape. +`emitJson(...)`, `emitVersionedJson(...)`, and `emitValue(...)` are +DTO/codec convenience layers, not a new transport shape. When the topic and codec travel together in your codebase, prefer `WorkflowEventRef.json(...)` for normal DTO payloads and keep diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index c7389b88..f1043a84 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -8,6 +8,13 @@ code can decode an entire DTO input without field-by-field map plumbing. - Added `PayloadCodec.versionedJson(...)` so DTO payload codecs can persist a schema version beside the JSON payload and decode older shapes explicitly. +- Added versioned low-level DTO shortcuts: + `TaskEnqueuer.enqueueVersionedJson(...)`, + `WorkflowRuntime.startWorkflowVersionedJson(...)`, + `StemWorkflowApp.startWorkflowVersionedJson(...)`, + `WorkflowRuntime.emitVersionedJson(...)`, + `StemWorkflowApp.emitVersionedJson(...)`, and + `QueueEventsProducer.emitVersionedJson(...)`. - Added low-level DTO shortcuts for name-based dispatch: `TaskEnqueuer.enqueueJson(...)`, `WorkflowRuntime.startWorkflowJson(...)`, `StemWorkflowApp.startWorkflowJson(...)`, `WorkflowRuntime.emitJson(...)`, diff --git a/packages/stem/README.md b/packages/stem/README.md index a75bccd5..cd99be9d 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -1219,7 +1219,8 @@ backend metadata under `stem.unique.duplicates`. - Awaited events behave the same way: the emitted payload is delivered via `takeResumeData()` / `takeResumeValue(codec: ...)` when the run resumes. - When you have a DTO event, emit it through `workflowApp.emitJson(...)` / - `workflowApp.emitValue(...)` (or `runtime.emitJson(...)` / + `workflowApp.emitVersionedJson(...)` / `workflowApp.emitValue(...)` (or + `runtime.emitJson(...)` / `runtime.emitVersionedJson(...)` / `runtime.emitValue(...)` when you are intentionally using the low-level runtime) with a `PayloadCodec`, or use `WorkflowEventRef.json(...)` as the shortest typed event form and call `event.emit(emitter, dto)` as the @@ -1261,8 +1262,11 @@ final runId = await workflowApp.startWorkflow( ``` When those low-level name-based paths already have DTO inputs, prefer -`client.enqueueJson(...)` and `workflowApp.startWorkflowJson(...)` over -hand-built map payloads. +`client.enqueueJson(...)` / `client.enqueueVersionedJson(...)` and +`workflowApp.startWorkflowJson(...)` / `workflowApp.startWorkflowVersionedJson(...)` +over hand-built map payloads. Use the versioned forms when the DTO schema is +expected to evolve and you want the payload to persist an explicit +`__stemPayloadVersion`. Adapter packages expose typed factories (e.g. `redisBrokerFactory`, `postgresResultBackendFactory`, `sqliteWorkflowStoreFactory`) so you can replace diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index f6b06dd1..d5d81ad2 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -225,6 +225,41 @@ class StemWorkflowApp ); } + /// Starts a workflow from a DTO and stores a schema [version] beside the + /// JSON payload. + Future startWorkflowVersionedJson( + String name, + T paramsJson, { + required int version, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + String? typeName, + }) { + if (!_started) { + return start().then( + (_) => runtime.startWorkflowVersionedJson( + name, + paramsJson, + version: version, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + typeName: typeName, + ), + ); + } + return runtime.startWorkflowVersionedJson( + name, + paramsJson, + version: version, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + typeName: typeName, + ); + } + /// Schedules a workflow run from a typed [WorkflowRef]. @override Future startWorkflowRef( @@ -267,6 +302,22 @@ class StemWorkflowApp ); } + /// Emits a DTO-backed external event and stores a schema [version] beside + /// the JSON payload. + Future emitVersionedJson( + String topic, + T payloadJson, { + required int version, + String? typeName, + }) { + return runtime.emitVersionedJson( + topic, + payloadJson, + version: version, + typeName: typeName, + ); + } + /// Schedules a workflow run from a prebuilt [WorkflowStartCall]. @override Future startWorkflowCall( @@ -599,8 +650,7 @@ class StemWorkflowApp Iterable additionalEncoders = const [], }) async { final effectiveModule = - StemModule.combine(module: module, modules: modules) ?? - stemApp?.module; + StemModule.combine(module: module, modules: modules) ?? stemApp?.module; final moduleTasks = effectiveModule?.tasks ?? const >[]; final moduleWorkflowDefinitions = diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 08557189..2f45bb13 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2945,6 +2945,36 @@ extension TaskEnqueuerBuilderExtension on TaskEnqueuer { ); } + /// Enqueues a name-based task from a DTO and persists a schema [version] + /// beside the JSON payload. + Future enqueueVersionedJson( + String name, + T argsJson, { + required int version, + Map headers = const {}, + TaskOptions options = const TaskOptions(), + DateTime? notBefore, + Map meta = const {}, + TaskEnqueueOptions? enqueueOptions, + String? typeName, + }) { + return enqueue( + name, + args: Map.from( + PayloadCodec.encodeVersionedJsonMap( + argsJson, + version: version, + typeName: typeName ?? '$T', + ), + ), + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ); + } + /// Creates a caller-bound fluent builder for a typed task definition. BoundTaskEnqueueBuilder prepareEnqueue({ required TaskDefinition definition, diff --git a/packages/stem/lib/src/core/payload_codec.dart b/packages/stem/lib/src/core/payload_codec.dart index 7fa561e8..027e5022 100644 --- a/packages/stem/lib/src/core/payload_codec.dart +++ b/packages/stem/lib/src/core/payload_codec.dart @@ -108,6 +108,19 @@ class PayloadCodec { return _payloadJsonMap(payload, typeName ?? value.runtimeType.toString()); } + /// Encodes a DTO to a string-keyed map and persists a schema [version] + /// alongside the payload. + static Map encodeVersionedJsonMap( + T value, { + required int version, + String? typeName, + }) { + return { + versionKey: version, + ...encodeJsonMap(value, typeName: typeName), + }; + } + /// Normalizes a durable payload into the string-keyed JSON map shape used by /// DTO-style decoders. static Map decodeJsonMap( diff --git a/packages/stem/lib/src/core/queue_events.dart b/packages/stem/lib/src/core/queue_events.dart index 568225b8..ded6e4ac 100644 --- a/packages/stem/lib/src/core/queue_events.dart +++ b/packages/stem/lib/src/core/queue_events.dart @@ -178,6 +178,32 @@ class QueueEventsProducer { meta: meta, ); } + + /// Emits [eventName] using a DTO payload and stores a schema [version] + /// beside the JSON payload. + Future emitVersionedJson( + String queue, + String eventName, + T payloadJson, { + required int version, + Map headers = const {}, + Map meta = const {}, + String? typeName, + }) { + return emit( + queue, + eventName, + payload: Map.from( + PayloadCodec.encodeVersionedJsonMap( + payloadJson, + version: version, + typeName: typeName ?? '$T', + ), + ), + headers: headers, + meta: meta, + ); + } } /// Listens for queue-scoped custom events emitted by [QueueEventsProducer]. diff --git a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart index c8bf34bd..9a0bb073 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart @@ -237,6 +237,32 @@ class WorkflowRuntime implements WorkflowCaller, WorkflowEventEmitter { ); } + /// Persists a new workflow run from a DTO and stores a schema [version] + /// beside the JSON payload. + Future startWorkflowVersionedJson( + String name, + T paramsJson, { + required int version, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + String? typeName, + }) { + return startWorkflow( + name, + params: Map.from( + PayloadCodec.encodeVersionedJsonMap( + paramsJson, + version: version, + typeName: typeName ?? '$T', + ), + ), + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + /// Starts a workflow from a typed [WorkflowRef]. @override Future startWorkflowRef( @@ -378,6 +404,26 @@ class WorkflowRuntime implements WorkflowCaller, WorkflowEventEmitter { ); } + /// Emits a DTO-backed external event and stores a schema [version] beside + /// the JSON payload. + Future emitVersionedJson( + String topic, + T payloadJson, { + required int version, + String? typeName, + }) { + return emit( + topic, + Map.from( + PayloadCodec.encodeVersionedJsonMap( + payloadJson, + version: version, + typeName: typeName ?? '$T', + ), + ), + ); + } + WorkflowResult _buildResult( RunState state, T Function(Object? payload)? decode, { diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index f1f48795..175f4995 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -507,6 +507,42 @@ void main() { } }); + test( + 'startWorkflowVersionedJson encodes DTO params with a persisted schema version', + () async { + final flow = Flow( + name: 'workflow.versioned.json.start', + build: (builder) { + builder.step( + 'payload', + (ctx) async => ctx.requiredParam('foo'), + ); + }, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + final runId = await workflowApp.startWorkflowVersionedJson( + 'workflow.versioned.json.start', + const _DemoPayload('bar'), + version: 2, + ); + final runState = await workflowApp.getRun(runId); + final run = await workflowApp.waitForCompletion( + runId, + timeout: const Duration(seconds: 2), + ); + + expect(runId, isNotEmpty); + expect(runState?.params, containsPair(PayloadCodec.versionKey, 2)); + expect(runState?.params, containsPair('foo', 'bar')); + expect(run?.requiredValue(), 'bar'); + } finally { + await workflowApp.shutdown(); + } + }, + ); + test( 'emitJson resumes runs with DTO payloads without a manual map', () async { @@ -555,6 +591,57 @@ void main() { }, ); + test( + 'emitVersionedJson resumes runs with versioned DTO payloads', + () async { + const demoPayloadCodec = PayloadCodec<_DemoPayload>.json( + decode: _DemoPayload.fromJson, + ); + final flow = Flow( + name: 'workflow.versioned.json.emit', + build: (builder) { + builder.step( + 'wait', + (ctx) async { + final resume = ctx.takeResumeValue<_DemoPayload>( + codec: demoPayloadCodec, + ); + if (resume == null) { + ctx.awaitEvent('workflow.versioned.json.emit.topic'); + return null; + } + return resume.foo; + }, + ); + }, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + final runId = await workflowApp.startWorkflow( + 'workflow.versioned.json.emit', + ); + await workflowApp.executeRun(runId); + + await workflowApp.emitVersionedJson( + 'workflow.versioned.json.emit.topic', + const _DemoPayload('qux'), + version: 2, + ); + + final run = await workflowApp.waitForCompletion( + runId, + timeout: const Duration(seconds: 2), + ); + + expect(runId, isNotEmpty); + expect(run?.requiredValue(), 'qux'); + } finally { + await workflowApp.shutdown(); + } + }, + ); + test( 'waitForCompletion does not decode when workflow is cancelled', () async { diff --git a/packages/stem/test/unit/core/payload_codec_test.dart b/packages/stem/test/unit/core/payload_codec_test.dart index 57b822d0..758ebbc1 100644 --- a/packages/stem/test/unit/core/payload_codec_test.dart +++ b/packages/stem/test/unit/core/payload_codec_test.dart @@ -61,6 +61,20 @@ void main() { }); group('PayloadCodec.versionedJson', () { + test('encodes DTOs to versioned JSON maps without a codec instance', () { + final payload = PayloadCodec.encodeVersionedJsonMap( + const _VersionedCodecPayload(id: 'payload-v-encode', count: 6), + version: 4, + typeName: '_VersionedCodecPayload', + ); + + expect(payload, { + PayloadCodec.versionKey: 4, + 'id': 'payload-v-encode', + 'count': 6, + }); + }); + test('encodes DTOs with a persisted schema version', () { const codec = PayloadCodec<_VersionedCodecPayload>.versionedJson( version: 2, diff --git a/packages/stem/test/unit/core/queue_events_test.dart b/packages/stem/test/unit/core/queue_events_test.dart index ef41c6de..4d150ef6 100644 --- a/packages/stem/test/unit/core/queue_events_test.dart +++ b/packages/stem/test/unit/core/queue_events_test.dart @@ -150,6 +150,39 @@ void main() { ); }); + test( + 'emitVersionedJson publishes DTO payloads with a persisted schema version', + () async { + final listener = QueueEvents( + broker: broker, + queue: 'orders', + consumerName: 'orders-listener-versioned', + ); + await listener.start(); + addTearDown(listener.close); + + final received = listener + .on('order.versioned') + .first + .timeout(const Duration(seconds: 5)); + + final eventId = await producer.emitVersionedJson( + 'orders', + 'order.versioned', + const _QueueEventPayload(orderId: 'o-3', status: 'versioned'), + version: 2, + ); + + final event = await received; + expect(event.id, eventId); + expect(event.payload, { + PayloadCodec.versionKey: 2, + 'orderId': 'o-3', + 'status': 'versioned', + }); + }, + ); + test('validates queue and event names', () async { expect( () => producer.emit('', 'evt'), diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index 08711f8c..528faeb1 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -163,6 +163,33 @@ void main() { expect(backend.records.single.state, TaskState.queued); }); + test( + 'enqueueVersionedJson publishes DTO args with a persisted schema version', + () async { + final broker = _RecordingBroker(); + final backend = _RecordingBackend(); + final stem = Stem( + broker: broker, + backend: backend, + tasks: [const _StubTaskHandler()], + ); + + final id = await stem.enqueueVersionedJson( + 'sample.task', + const _CodecTaskArgs('encoded'), + version: 2, + ); + + expect(id, isNotEmpty); + expect(broker.published.single.envelope.args, { + PayloadCodec.versionKey: 2, + 'value': 'encoded', + }); + expect(backend.records.single.id, id); + expect(backend.records.single.state, TaskState.queued); + }, + ); + test( 'enqueueCall uses definition encoder metadata on producer-only paths', () async { diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index 07c97edd..aac18432 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -156,9 +156,7 @@ void main() { name: 'parent.runtime.flow', build: (flow) { flow.step('spawn', (context) async { - return childRef - .call(const {'value': 'spawned'}) - .start(context); + return childRef.call(const {'value': 'spawned'}).start(context); }); }, ).definition, @@ -720,6 +718,56 @@ void main() { }, ); + test( + 'emitVersionedJson resumes flows with versioned DTO payloads', + () async { + _UserUpdatedEvent? observedPayload; + + runtime.registerWorkflow( + Flow( + name: 'event.versioned.json.workflow', + build: (flow) { + flow.step( + 'wait', + (context) async { + final resume = context.takeResumeValue<_UserUpdatedEvent>( + codec: _userUpdatedEventCodec, + ); + if (resume == null) { + context.awaitEvent('user.updated.versioned.json'); + return null; + } + observedPayload = resume; + return resume.id; + }, + ); + }, + ).definition, + ); + + final runId = await runtime.startWorkflow( + 'event.versioned.json.workflow', + ); + await runtime.executeRun(runId); + + final suspended = await store.get(runId); + expect(suspended?.status, WorkflowStatus.suspended); + expect(suspended?.waitTopic, 'user.updated.versioned.json'); + + await runtime.emitVersionedJson( + 'user.updated.versioned.json', + const _UserUpdatedEvent(id: 'user-json-2'), + version: 2, + ); + await runtime.executeRun(runId); + + final completed = await store.get(runId); + expect(completed?.status, WorkflowStatus.completed); + expect(observedPayload?.id, 'user-json-2'); + expect(completed?.result, 'user-json-2'); + }, + ); + test('emitEvent resumes flows with typed workflow event refs', () async { final event = WorkflowEventRef<_UserUpdatedEvent>.codec( topic: 'user.updated.ref', @@ -1139,43 +1187,46 @@ void main() { expect(completed?.result, 'user-42'); }); - test('script waitForEvent uses named args and resumes with payload', () async { - Map? resumePayload; + test( + 'script waitForEvent uses named args and resumes with payload', + () async { + Map? resumePayload; - runtime.registerWorkflow( - WorkflowScript( - name: 'script.event.expression', - run: (script) async { - final result = await script.step('wait', (step) async { - final payload = await step.waitForEvent>( - topic: 'user.updated.expression.script', - ); - resumePayload = payload; - return payload['id']; - }); - return result; - }, - ).definition, - ); + runtime.registerWorkflow( + WorkflowScript( + name: 'script.event.expression', + run: (script) async { + final result = await script.step('wait', (step) async { + final payload = await step.waitForEvent>( + topic: 'user.updated.expression.script', + ); + resumePayload = payload; + return payload['id']; + }); + return result; + }, + ).definition, + ); - final runId = await runtime.startWorkflow('script.event.expression'); - await runtime.executeRun(runId); + final runId = await runtime.startWorkflow('script.event.expression'); + await runtime.executeRun(runId); - final suspended = await store.get(runId); - expect(suspended?.status, WorkflowStatus.suspended); - expect(suspended?.waitTopic, 'user.updated.expression.script'); + final suspended = await store.get(runId); + expect(suspended?.status, WorkflowStatus.suspended); + expect(suspended?.waitTopic, 'user.updated.expression.script'); - await runtime.emit( - 'user.updated.expression.script', - const {'id': 'user-43'}, - ); - await runtime.executeRun(runId); + await runtime.emit( + 'user.updated.expression.script', + const {'id': 'user-43'}, + ); + await runtime.executeRun(runId); - final completed = await store.get(runId); - expect(completed?.status, WorkflowStatus.completed); - expect(resumePayload?['id'], 'user-43'); - expect(completed?.result, 'user-43'); - }); + final completed = await store.get(runId); + expect(completed?.status, WorkflowStatus.completed); + expect(resumePayload?['id'], 'user-43'); + expect(completed?.result, 'user-43'); + }, + ); test('script autoVersion step persists sequential checkpoints', () async { final iterations = []; From 6a3c59917cc7e5b14978b8e58903d5eac7782cb2 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 10:20:59 -0500 Subject: [PATCH 218/302] Add direct versioned json shortcuts --- .site/docs/core-concepts/tasks.md | 14 ++--- .site/docs/workflows/starting-and-waiting.md | 8 ++- .../docs/workflows/suspensions-and-events.md | 8 ++- packages/stem/CHANGELOG.md | 3 + packages/stem/README.md | 29 +++++----- packages/stem/lib/src/core/contracts.dart | 45 +++++++++++++++ packages/stem/lib/src/workflow/core/flow.dart | 16 ++++++ .../workflow/core/workflow_definition.dart | 18 ++++++ .../src/workflow/core/workflow_event_ref.dart | 20 +++++++ .../lib/src/workflow/core/workflow_ref.dart | 40 ++++++++++++++ .../src/workflow/core/workflow_script.dart | 16 ++++++ .../stem/test/unit/core/stem_core_test.dart | 25 +++++++++ .../workflow/workflow_runtime_ref_test.dart | 29 ++++++++++ .../test/workflow/workflow_runtime_test.dart | 55 +++++++++++++++++++ 14 files changed, 300 insertions(+), 26 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index d946eab6..0263655b 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -63,13 +63,13 @@ or `result.payloadAs(codec: ...)` to decode the stored task result DTO without another cast/closure. If your manual task args are DTOs, prefer `TaskDefinition.json(...)` -when the type already has `toJson()`. Use `TaskDefinition.codec(...)` when you -need a custom -`PayloadCodec`. Task args still need to encode to a string-keyed map -(typically `Map`) because they are published as JSON-shaped -data. For low-level name-based enqueue APIs, use `enqueueVersionedJson(...)` -when the DTO schema is expected to evolve and the payload should persist an -explicit `__stemPayloadVersion`. +when the type already has `toJson()`. Use `TaskDefinition.versionedJson(...)` +when the payload schema is expected to evolve and the published payload should +persist an explicit `__stemPayloadVersion`. Use `TaskDefinition.codec(...)` +when you need a custom `PayloadCodec`. Task args still need to encode to a +string-keyed map (typically `Map`) because they are published +as JSON-shaped data. For low-level name-based enqueue APIs, use +`enqueueVersionedJson(...)` for the same versioned DTO path. For manual handlers, prefer the typed payload readers on the argument map instead of repeating raw casts: diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 083ad042..efdd4cac 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -60,9 +60,11 @@ final runId = await approvalsRef `refJson(...)` is the shortest manual DTO path when the params already have `toJson()`, or when the final result also needs a `Type.fromJson(...)` -decoder. Use `refCodec(...)` when you need a custom `PayloadCodec`. -Workflow params still need to encode to a string-keyed map (typically -`Map`) because they are stored as JSON-shaped data. +decoder. Use `refVersionedJson(...)` when the persisted start payload should +carry an explicit `__stemPayloadVersion`. Use `refCodec(...)` when you need a +custom `PayloadCodec`. Workflow params still need to encode to a +string-keyed map (typically `Map`) because they are stored as +JSON-shaped data. Inside manual flow steps and script checkpoints, prefer `ctx.param()` / `ctx.requiredParam()` for workflow start params and diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index 091a81b5..9fee8ef1 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -66,9 +66,11 @@ Typed event payloads still serialize to a string-keyed JSON-like map. DTO/codec convenience layers, not a new transport shape. When the topic and codec travel together in your codebase, prefer -`WorkflowEventRef.json(...)` for normal DTO payloads and keep -`event.emit(emitter, dto)` as the happy path. `event.call(value).emit(...)` -remains available as the lower-level prebuilt-call variant. +`WorkflowEventRef.json(...)` for normal DTO payloads, +`WorkflowEventRef.versionedJson(...)` when the payload schema should carry +an explicit `__stemPayloadVersion`, and keep `event.emit(emitter, dto)` as the +happy path. `event.call(value).emit(...)` remains available as the lower-level +prebuilt-call variant. Pair that with `await event.wait(ctx)`. If you are writing a flow and deliberately want the lower-level `FlowStepControl` path, use `event.awaitOn(step)` instead of dropping back to a raw topic string. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index f1043a84..79e57109 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -15,6 +15,9 @@ `WorkflowRuntime.emitVersionedJson(...)`, `StemWorkflowApp.emitVersionedJson(...)`, and `QueueEventsProducer.emitVersionedJson(...)`. +- Added direct `versionedJson(...)` shortcuts for manual task definitions, + workflow refs, and workflow event refs so evolving DTO payloads do not need + a separate `PayloadCodec.versionedJson(...)` constant in the common case. - Added low-level DTO shortcuts for name-based dispatch: `TaskEnqueuer.enqueueJson(...)`, `WorkflowRuntime.startWorkflowJson(...)`, `StemWorkflowApp.startWorkflowJson(...)`, `WorkflowRuntime.emitJson(...)`, diff --git a/packages/stem/README.md b/packages/stem/README.md index cd99be9d..a32a66a4 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -219,11 +219,12 @@ producer-only processes do not need to register the worker handler locally just to enqueue typed calls. Use `TaskDefinition.json(...)` when your manual task args are normal -DTOs with `toJson()`. Drop down to `TaskDefinition.codec(...)` only when you -need a custom -`PayloadCodec`. Task args still need to encode to a string-keyed map -(typically `Map`) because they are published as JSON-shaped -data. +DTOs with `toJson()`. Use `TaskDefinition.versionedJson(...)` when the DTO +schema is expected to evolve and the published payload should persist an +explicit `__stemPayloadVersion`. Drop down to `TaskDefinition.codec(...)` only +when you need a custom `PayloadCodec`. Task args still need to encode to a +string-keyed map (typically `Map`) because they are published +as JSON-shaped data. For manual handlers, use the context arg helpers or the typed payload readers on the raw map instead of repeating casts. For workflows, use the context @@ -570,10 +571,11 @@ final runId = await approvalsRef Use `refJson(...)` when your manual workflow start params are DTOs with `toJson()`, or when the final result also needs a `Type.fromJson(...)` -decoder. Drop down to `refCodec(...)` when you need a custom -`PayloadCodec`. Workflow params still need to encode to a string-keyed map -(typically `Map`) because they are persisted as JSON-shaped -data. +decoder. Use `refVersionedJson(...)` when the start payload schema is expected +to evolve and the persisted params should store `__stemPayloadVersion`. Drop +down to `refCodec(...)` when you need a custom `PayloadCodec`. Workflow +params still need to encode to a string-keyed map (typically +`Map`) because they are persisted as JSON-shaped data. If a manual flow or script only needs DTO result decoding, prefer `Flow.json(...)` or `WorkflowScript.json(...)`. If the final result needs a @@ -1222,10 +1224,11 @@ backend metadata under `stem.unique.duplicates`. `workflowApp.emitVersionedJson(...)` / `workflowApp.emitValue(...)` (or `runtime.emitJson(...)` / `runtime.emitVersionedJson(...)` / `runtime.emitValue(...)` when you are intentionally using the low-level - runtime) with a `PayloadCodec`, or use `WorkflowEventRef.json(...)` - as the shortest typed event form and call `event.emit(emitter, dto)` as the - happy path. `event.call(value).emit(...)` remains available as the - lower-level prebuilt-call variant. + runtime) with a `PayloadCodec`, or use `WorkflowEventRef.json(...)` / + `WorkflowEventRef.versionedJson(...)` as the shortest typed event forms + and call `event.emit(emitter, dto)` as the happy path. + `event.call(value).emit(...)` remains available as the lower-level + prebuilt-call variant. Pair that with `await event.wait(ctx)`. Event payloads still serialize onto a string-keyed JSON-like map. - Only return values you want persisted. If a handler returns `null`, the diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 2f45bb13..d2bf0efb 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2391,6 +2391,38 @@ class TaskDefinition { ); } + /// Creates a typed task definition for DTO args that already expose + /// `toJson()` and persist a schema [version] beside the payload. + factory TaskDefinition.versionedJson({ + required String name, + required int version, + TaskMetaBuilder? encodeMeta, + TaskOptions defaultOptions = const TaskOptions(), + TaskMetadata metadata = const TaskMetadata(), + TResult Function(Map payload)? decodeResultJson, + String? argsTypeName, + String? resultTypeName, + }) { + final resultCodec = decodeResultJson == null + ? null + : PayloadCodec.json( + decode: decodeResultJson, + typeName: resultTypeName ?? '$TResult', + ); + return TaskDefinition( + name: name, + encodeArgs: (args) => _encodeVersionedJsonArgs( + args, + version: version, + typeName: argsTypeName ?? '$TArgs', + ), + encodeMeta: encodeMeta, + defaultOptions: defaultOptions, + metadata: _metadataWithResultCodec(name, metadata, resultCodec), + decodeResult: resultCodec?.decode, + ); + } + /// Creates a typed task definition for handlers with no producer args. static NoArgsTaskDefinition noArgsCodec({ required String name, @@ -2509,6 +2541,19 @@ class TaskDefinition { return Map.from(payload); } + static Map _encodeVersionedJsonArgs( + T args, { + required int version, + required String typeName, + }) { + final payload = PayloadCodec.encodeVersionedJsonMap( + args, + version: version, + typeName: typeName, + ); + return Map.from(payload); + } + static TaskMetadata _metadataWithResultCodec( String taskName, TaskMetadata metadata, diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index fc0c23a4..b967b6ea 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -105,6 +105,22 @@ class Flow { ); } + /// Builds a typed [WorkflowRef] for DTO params that already expose + /// `toJson()` and persist a schema [version] beside the payload. + WorkflowRef refVersionedJson({ + required int version, + T Function(Map payload)? decodeResultJson, + String? paramsTypeName, + String? resultTypeName, + }) { + return definition.refVersionedJson( + version: version, + decodeResultJson: decodeResultJson, + paramsTypeName: paramsTypeName, + resultTypeName: resultTypeName, + ); + } + /// Builds a typed [NoArgsWorkflowRef] for flows without start params. NoArgsWorkflowRef ref0() { return definition.ref0(); diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index 9506fa10..d7c08465 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -448,6 +448,24 @@ class WorkflowDefinition { ); } + /// Builds a typed [WorkflowRef] for DTO params that already expose + /// `toJson()` and persist a schema [version] beside the payload. + WorkflowRef refVersionedJson({ + required int version, + T Function(Map payload)? decodeResultJson, + String? paramsTypeName, + String? resultTypeName, + }) { + return WorkflowRef.versionedJson( + name: name, + version: version, + decodeResultJson: decodeResultJson, + decodeResult: (payload) => decodeResult(payload) as T, + paramsTypeName: paramsTypeName, + resultTypeName: resultTypeName, + ); + } + /// Builds a typed [NoArgsWorkflowRef] from this definition. NoArgsWorkflowRef ref0() { return NoArgsWorkflowRef( diff --git a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart index 9aa5d2a1..6c4ab86f 100644 --- a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart @@ -54,6 +54,26 @@ class WorkflowEventRef { ); } + /// Creates a typed workflow event reference for DTO payloads that already + /// expose `toJson()` and persist a schema [version] beside the payload. + factory WorkflowEventRef.versionedJson({ + required String topic, + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return WorkflowEventRef.codec( + topic: topic, + codec: PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ), + ); + } + /// Durable topic name used to suspend and resume workflow runs. final String topic; diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 6705d123..b1dfec30 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -52,6 +52,33 @@ class WorkflowRef { ); } + /// Creates a typed workflow reference for DTO params that already expose + /// `toJson()` and persist a schema [version] beside the payload. + factory WorkflowRef.versionedJson({ + required String name, + required int version, + TResult Function(Map payload)? decodeResultJson, + TResult Function(Object? payload)? decodeResult, + String? paramsTypeName, + String? resultTypeName, + }) { + final resultCodec = decodeResultJson == null + ? null + : PayloadCodec.json( + decode: decodeResultJson, + typeName: resultTypeName ?? '$TResult', + ); + return WorkflowRef( + name: name, + encodeParams: (params) => _encodeVersionedJsonParams( + params, + version: version, + typeName: paramsTypeName ?? '$TParams', + ), + decodeResult: decodeResult ?? resultCodec?.decode, + ); + } + /// Registered workflow name. final String name; @@ -98,6 +125,19 @@ class WorkflowRef { return Map.from(payload); } + static Map _encodeVersionedJsonParams( + T params, { + required int version, + required String typeName, + }) { + final payload = PayloadCodec.encodeVersionedJsonMap( + params, + version: version, + typeName: typeName, + ); + return Map.from(payload); + } + /// Builds a workflow start call from typed arguments. WorkflowStartCall call( TParams params, { diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index 5ddc1c59..677778e9 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -113,6 +113,22 @@ class WorkflowScript { ); } + /// Builds a typed [WorkflowRef] for DTO params that already expose + /// `toJson()` and persist a schema [version] beside the payload. + WorkflowRef refVersionedJson({ + required int version, + T Function(Map payload)? decodeResultJson, + String? paramsTypeName, + String? resultTypeName, + }) { + return definition.refVersionedJson( + version: version, + decodeResultJson: decodeResultJson, + paramsTypeName: paramsTypeName, + resultTypeName: resultTypeName, + ); + } + /// Builds a typed [NoArgsWorkflowRef] for scripts without start params. NoArgsWorkflowRef ref0() { return definition.ref0(); diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index 528faeb1..90e55680 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -143,6 +143,31 @@ void main() { expect(backend.records.single.state, TaskState.queued); }); + test('enqueueCall publishes versioned-json task definitions', () async { + final broker = _RecordingBroker(); + final backend = _RecordingBackend(); + final stem = Stem(broker: broker, backend: backend); + final definition = TaskDefinition<_CodecTaskArgs, Object?>.versionedJson( + name: 'sample.versioned.json.args', + version: 2, + defaultOptions: const TaskOptions(queue: 'typed'), + ); + + final id = await stem.enqueueCall( + definition.call(const _CodecTaskArgs('encoded')), + ); + + expect(id, isNotEmpty); + expect(broker.published.single.envelope.name, 'sample.versioned.json.args'); + expect(broker.published.single.envelope.queue, 'typed'); + expect(broker.published.single.envelope.args, { + PayloadCodec.versionKey: 2, + 'value': 'encoded', + }); + expect(backend.records.single.id, id); + expect(backend.records.single.state, TaskState.queued); + }); + test('enqueueJson publishes DTO args without a manual map', () async { final broker = _RecordingBroker(); final backend = _RecordingBackend(); diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 36dbba39..be08d628 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -179,6 +179,35 @@ void main() { } }); + test('manual workflows can derive versioned-json refs', () async { + final flow = Flow( + name: 'runtime.ref.versioned-json.flow', + build: (builder) { + builder.step('hello', (ctx) async { + final name = ctx.requiredParam('name'); + final version = ctx.requiredParam(PayloadCodec.versionKey); + return 'hello $name v$version'; + }); + }, + ); + final workflowRef = flow.refVersionedJson<_GreetingParams>(version: 2); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + await workflowApp.start(); + + final result = await workflowRef.startAndWait( + workflowApp.runtime, + params: const _GreetingParams(name: 'json'), + timeout: const Duration(seconds: 2), + ); + + expect(result?.value, 'hello json v2'); + } finally { + await workflowApp.shutdown(); + } + }); + test( 'manual workflows can derive json-backed refs with result decoding', () async { diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index aac18432..e22bd5d1 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -813,6 +813,53 @@ void main() { expect(completed?.result, 'user-typed-2'); }); + test('emitEvent resumes flows with versioned-json workflow event refs', () async { + final event = WorkflowEventRef<_UserUpdatedEvent>.versionedJson( + topic: 'user.updated.versioned.ref', + version: 2, + decode: _UserUpdatedEvent.fromVersionedJson, + typeName: '_UserUpdatedEvent', + ); + _UserUpdatedEvent? observedPayload; + + runtime.registerWorkflow( + Flow( + name: 'event.versioned.ref.workflow', + build: (flow) { + flow.step( + 'wait', + (context) async { + final resume = event.waitValue(context); + if (resume == null) { + return null; + } + observedPayload = resume; + return resume.id; + }, + ); + }, + ).definition, + ); + + final runId = await runtime.startWorkflow('event.versioned.ref.workflow'); + await runtime.executeRun(runId); + + final suspended = await store.get(runId); + expect(suspended?.status, WorkflowStatus.suspended); + expect(suspended?.waitTopic, event.topic); + + await event.emit( + runtime, + const _UserUpdatedEvent(id: 'user-versioned-ref-2'), + ); + await runtime.executeRun(runId); + + final completed = await store.get(runId); + expect(completed?.status, WorkflowStatus.completed); + expect(observedPayload?.id, 'user-versioned-ref-2'); + expect(completed?.result, 'user-versioned-ref-2'); + }); + test('emit persists payload before worker resumes execution', () async { runtime.registerWorkflow( Flow( @@ -1647,4 +1694,12 @@ class _UserUpdatedEvent { static _UserUpdatedEvent fromJson(Map json) { return _UserUpdatedEvent(id: json['id'] as String); } + + static _UserUpdatedEvent fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _UserUpdatedEvent(id: json['id'] as String); + } } From d7a7ffb6a3d5d957f7e7d328445262c4c4aa6ecd Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 11:51:36 -0500 Subject: [PATCH 219/302] Add versioned dto result decode helpers --- .site/docs/core-concepts/tasks.md | 6 ++- .site/docs/workflows/starting-and-waiting.md | 4 ++ packages/stem/CHANGELOG.md | 5 +++ packages/stem/README.md | 4 ++ packages/stem/lib/src/core/contracts.dart | 37 ++++++++++++++++++ packages/stem/lib/src/core/task_result.dart | 18 +++++++++ .../stem/lib/src/workflow/core/run_state.dart | 39 +++++++++++++++++++ .../src/workflow/core/workflow_result.dart | 18 +++++++++ .../stem/test/unit/core/contracts_test.dart | 33 ++++++++++++++++ .../stem/test/unit/core/task_result_test.dart | 15 +++++++ .../workflow_metadata_views_test.dart | 30 ++++++++++++++ .../unit/workflow/workflow_result_test.dart | 16 ++++++++ 12 files changed, 223 insertions(+), 2 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 0263655b..c463c937 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -57,10 +57,12 @@ For low-level DTO waits through `Stem.waitForTask`, prefer `decodeJson:` over a manual raw-payload cast. If you already have a raw `TaskStatus`, use `status.payloadJson(...)` or `status.payloadAs(codec: ...)` to decode the whole payload DTO without a -separate cast/closure. +separate cast/closure. Use `status.payloadVersionedJson(...)` when the stored +payload carries an explicit `__stemPayloadVersion`. If you already have a raw `TaskResult`, use `result.payloadJson(...)` or `result.payloadAs(codec: ...)` to decode the stored task result DTO -without another cast/closure. +without another cast/closure. Use `result.payloadVersionedJson(...)` for the +same versioned DTO path on persisted task results. If your manual task args are DTOs, prefer `TaskDefinition.json(...)` when the type already has `toJson()`. Use `TaskDefinition.versionedJson(...)` diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index efdd4cac..436d6f1e 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -75,6 +75,10 @@ If a manual flow or script returns a DTO, prefer `Flow.json(...)` or `WorkflowScript.json(...)` in the common `toJson()` / `Type.fromJson(...)` case. Use `Flow.codec(...)` or `WorkflowScript.codec(...)` when the result needs a custom payload codec. +When the persisted workflow result or suspension payload carries an explicit +`__stemPayloadVersion`, use `workflowResult.payloadVersionedJson(...)`, +`runState.resultVersionedJson(...)`, or +`runState.suspensionPayloadVersionedJson(...)` on the low-level snapshots. For workflows without start params, start directly from the flow or script itself with `start(...)`, `startAndWait(...)`, or `prepareStart()`. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 79e57109..8d9be2c4 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -18,6 +18,11 @@ - Added direct `versionedJson(...)` shortcuts for manual task definitions, workflow refs, and workflow event refs so evolving DTO payloads do not need a separate `PayloadCodec.versionedJson(...)` constant in the common case. +- Added versioned DTO decode helpers across low-level result snapshots: + `TaskStatus.payloadVersionedJson(...)`, `TaskResult.payloadVersionedJson(...)`, + `WorkflowResult.payloadVersionedJson(...)`, `GroupStatus.resultVersionedJson(...)`, + `RunState.resultVersionedJson(...)`, and + `RunState.suspensionPayloadVersionedJson(...)`. - Added low-level DTO shortcuts for name-based dispatch: `TaskEnqueuer.enqueueJson(...)`, `WorkflowRuntime.startWorkflowJson(...)`, `StemWorkflowApp.startWorkflowJson(...)`, `WorkflowRuntime.emitJson(...)`, diff --git a/packages/stem/README.md b/packages/stem/README.md index a32a66a4..e5bb1d6b 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -277,6 +277,10 @@ When a DTO payload needs an explicit persisted schema version, prefer `PayloadCodec.versionedJson(...)`. It stores `__stemPayloadVersion` beside the JSON payload and passes the persisted version into the decoder so you can keep older payloads readable while newer producers emit the latest shape. +The same pattern now carries through the low-level readback helpers: +`status.payloadVersionedJson(...)`, `result.payloadVersionedJson(...)`, +`workflowResult.payloadVersionedJson(...)`, and +`runState.resultVersionedJson(...)`. You can also build requests fluently from the task definition itself: diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index d2bf0efb..be6b8ef0 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -290,6 +290,24 @@ class TaskStatus { ).decode(stored); } + /// Decodes the entire payload as a typed DTO with a version-aware JSON + /// decoder. + T? payloadVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final stored = payload; + if (stored == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(stored); + } + /// Returns the decoded payload value, or [fallback] when it is absent. T payloadValueOr(T fallback, {PayloadCodec? codec}) { return payloadValue(codec: codec) ?? fallback; @@ -3238,6 +3256,25 @@ class GroupStatus { }); } + /// Decodes each collected child result as a typed DTO with a version-aware + /// JSON decoder. + Map resultVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return Map.unmodifiable({ + for (final entry in results.entries) + entry.key: entry.value.payloadVersionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ), + }); + } + /// The number of completed results. int get completed => results.length; diff --git a/packages/stem/lib/src/core/task_result.dart b/packages/stem/lib/src/core/task_result.dart index 9a07443d..a92a03e2 100644 --- a/packages/stem/lib/src/core/task_result.dart +++ b/packages/stem/lib/src/core/task_result.dart @@ -58,6 +58,24 @@ class TaskResult { ).decode(stored); } + /// Decodes the raw persisted task payload with a version-aware JSON + /// decoder. + TResult? payloadVersionedJson({ + required int version, + required TResult Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final stored = rawPayload; + if (stored == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(stored); + } + /// Raw payload stored by the backend (useful for debugging or manual casts). final Object? rawPayload; diff --git a/packages/stem/lib/src/workflow/core/run_state.dart b/packages/stem/lib/src/workflow/core/run_state.dart index f135310d..b172c27c 100644 --- a/packages/stem/lib/src/workflow/core/run_state.dart +++ b/packages/stem/lib/src/workflow/core/run_state.dart @@ -110,6 +110,23 @@ class RunState { ).decode(stored); } + /// Decodes the final result payload with a version-aware JSON decoder. + TResult? resultVersionedJson({ + required int version, + required TResult Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final stored = result; + if (stored == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(stored); + } + /// Topic that the run is currently waiting on, if any. final String? waitTopic; @@ -201,6 +218,28 @@ class RunState { ).decode(stored); } + /// Decodes the suspension payload with a version-aware JSON decoder, when + /// present. + TPayload? suspensionPayloadVersionedJson({ + required int version, + required TPayload Function( + Map payload, + int version, + ) + decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final stored = suspensionPayload; + if (stored == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(stored); + } + /// Timestamp when a matching event was delivered for this suspension. DateTime? get suspensionDeliveredAt => _dateFromJson(suspensionData?['deliveredAt']); diff --git a/packages/stem/lib/src/workflow/core/workflow_result.dart b/packages/stem/lib/src/workflow/core/workflow_result.dart index a394fd2b..00efee93 100644 --- a/packages/stem/lib/src/workflow/core/workflow_result.dart +++ b/packages/stem/lib/src/workflow/core/workflow_result.dart @@ -81,6 +81,24 @@ class WorkflowResult { ).decode(stored); } + /// Decodes the raw persisted workflow result with a version-aware JSON + /// decoder. + TResult? payloadVersionedJson({ + required int version, + required TResult Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final stored = rawResult; + if (stored == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(stored); + } + /// Untyped payload stored by the workflow, useful for legacy consumers or /// debugging scenarios. final Object? rawResult; diff --git a/packages/stem/test/unit/core/contracts_test.dart b/packages/stem/test/unit/core/contracts_test.dart index d0eb41f8..94accf2f 100644 --- a/packages/stem/test/unit/core/contracts_test.dart +++ b/packages/stem/test/unit/core/contracts_test.dart @@ -195,6 +195,13 @@ void main() { ), isA<_ReceiptPayload>().having((value) => value.id, 'id', 'receipt-1'), ); + expect( + status.payloadVersionedJson<_ReceiptPayload>( + version: 2, + decode: _ReceiptPayload.fromVersionedJson, + ), + isA<_ReceiptPayload>().having((value) => value.id, 'id', 'receipt-1'), + ); }); test('requiredPayloadValue throws when payload is absent', () { @@ -299,6 +306,16 @@ void main() { .having((value) => value.id, 'id', 'receipt-1'), }, ); + expect( + dtoStatus.resultVersionedJson<_GroupReceipt>( + version: 2, + decode: _GroupReceipt.fromVersionedJson, + ), + { + 'task-1': isA<_GroupReceipt>() + .having((value) => value.id, 'id', 'receipt-1'), + }, + ); }); }); @@ -471,6 +488,14 @@ class _GroupReceipt { return _GroupReceipt(id: json['id'] as String); } + factory _GroupReceipt.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _GroupReceipt(id: json['id'] as String); + } + final String id; } @@ -481,5 +506,13 @@ class _ReceiptPayload { return _ReceiptPayload(id: json['id'] as String); } + factory _ReceiptPayload.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _ReceiptPayload(id: json['id'] as String); + } + final String id; } diff --git a/packages/stem/test/unit/core/task_result_test.dart b/packages/stem/test/unit/core/task_result_test.dart index f160c64a..d128d14d 100644 --- a/packages/stem/test/unit/core/task_result_test.dart +++ b/packages/stem/test/unit/core/task_result_test.dart @@ -83,6 +83,13 @@ void main() { ), isA<_TaskReceipt>().having((value) => value.id, 'id', 'receipt-1'), ); + expect( + result.payloadVersionedJson<_TaskReceipt>( + version: 2, + decode: _TaskReceipt.fromVersionedJson, + ), + isA<_TaskReceipt>().having((value) => value.id, 'id', 'receipt-1'), + ); }); test('TaskResult.requiredValue throws when value is absent', () { @@ -116,5 +123,13 @@ class _TaskReceipt { return _TaskReceipt(id: json['id'] as String); } + factory _TaskReceipt.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _TaskReceipt(id: json['id'] as String); + } + final String id; } diff --git a/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart b/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart index ac5fd344..81735f7f 100644 --- a/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart +++ b/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart @@ -59,6 +59,17 @@ void main() { 'inv-1', ), ); + expect( + state.suspensionPayloadVersionedJson<_InvoicePayload>( + version: 2, + decode: _InvoicePayload.fromVersionedJson, + ), + isA<_InvoicePayload>().having( + (value) => value.invoiceId, + 'invoiceId', + 'inv-1', + ), + ); }); test('exposes runtime queue and serialization metadata', () { @@ -120,6 +131,17 @@ void main() { 'inv-2', ), ); + expect( + state.resultVersionedJson<_InvoicePayload>( + version: 2, + decode: _InvoicePayload.fromVersionedJson, + ), + isA<_InvoicePayload>().having( + (value) => value.invoiceId, + 'invoiceId', + 'inv-2', + ), + ); }); }); @@ -297,5 +319,13 @@ class _InvoicePayload { return _InvoicePayload(invoiceId: json['invoiceId'] as String); } + factory _InvoicePayload.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _InvoicePayload(invoiceId: json['invoiceId'] as String); + } + final String invoiceId; } diff --git a/packages/stem/test/unit/workflow/workflow_result_test.dart b/packages/stem/test/unit/workflow/workflow_result_test.dart index 6debabc6..24ada74c 100644 --- a/packages/stem/test/unit/workflow/workflow_result_test.dart +++ b/packages/stem/test/unit/workflow/workflow_result_test.dart @@ -79,6 +79,14 @@ void main() { isA<_WorkflowReceipt>() .having((value) => value.id, 'id', 'receipt-1'), ); + expect( + result.payloadVersionedJson<_WorkflowReceipt>( + version: 2, + decode: _WorkflowReceipt.fromVersionedJson, + ), + isA<_WorkflowReceipt>() + .having((value) => value.id, 'id', 'receipt-1'), + ); }); test('WorkflowResult.requiredValue throws when value is absent', () { @@ -118,5 +126,13 @@ class _WorkflowReceipt { return _WorkflowReceipt(id: json['id'] as String); } + factory _WorkflowReceipt.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _WorkflowReceipt(id: json['id'] as String); + } + final String id; } From 9f0e817de02eb00014156af97ca24201d7bbc633 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 11:55:56 -0500 Subject: [PATCH 220/302] Add versioned workflow inspection decode helpers --- .site/docs/workflows/starting-and-waiting.md | 9 ++- packages/stem/CHANGELOG.md | 11 ++-- packages/stem/README.md | 9 ++- .../workflow/core/workflow_step_entry.dart | 18 +++++ .../src/workflow/core/workflow_watcher.dart | 44 +++++++++++++ .../src/workflow/runtime/workflow_views.dart | 56 ++++++++++++++++ .../workflow_metadata_views_test.dart | 66 +++++++++++++++++++ 7 files changed, 203 insertions(+), 10 deletions(-) diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 436d6f1e..b718810d 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -98,15 +98,18 @@ If you already have a raw `WorkflowResult`, use stored workflow result without another cast/closure. If you are inspecting the underlying `RunState` directly, use `state.resultJson(...)`, `state.resultAs(codec: ...)`, -`state.suspensionPayloadJson(...)`, or +`state.resultVersionedJson(...)`, `state.suspensionPayloadJson(...)`, +`state.suspensionPayloadVersionedJson(...)`, or `state.suspensionPayloadAs(codec: ...)` instead of manual raw-map casts. Workflow run detail views expose the same convenience surface via `runView.resultJson(...)`, `runView.resultAs(codec: ...)`, -`runView.suspensionPayloadJson(...)`, and +`runView.resultVersionedJson(...)`, `runView.suspensionPayloadJson(...)`, +`runView.suspensionPayloadVersionedJson(...)`, and `runView.suspensionPayloadAs(codec: ...)`. Checkpoint entries from `viewCheckpoints(...)` and `WorkflowCheckpointView.fromEntry(...)` expose the same surface via -`entry.valueJson(...)` and `entry.valueAs(codec: ...)`. +`entry.valueJson(...)`, `entry.valueVersionedJson(...)`, and +`entry.valueAs(codec: ...)`. Use the returned `WorkflowResult` when you need: diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 8d9be2c4..8fb06100 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -68,10 +68,13 @@ and checkpoint inspection paths can decode DTO payloads without manual casts. - Added `WorkflowRunView.resultJson(...)`, - `WorkflowRunView.suspensionPayloadJson(...)`, and - `WorkflowCheckpointView.valueJson(...)` plus their `...As(codec: ...)` - counterparts so dashboard/CLI workflow detail views can decode DTO payloads - without manual casts. + `WorkflowRunView.resultVersionedJson(...)`, + `WorkflowRunView.suspensionPayloadJson(...)`, + `WorkflowRunView.suspensionPayloadVersionedJson(...)`, and + `WorkflowCheckpointView.valueJson(...)` / + `valueVersionedJson(...)` plus their `...As(codec: ...)` counterparts so + dashboard/CLI workflow detail views can decode DTO payloads without manual + casts. - Added `WorkflowStepEvent.resultJson(...)` and `resultAs(codec: ...)` so workflow introspection consumers can decode DTO checkpoint results without manual casts. diff --git a/packages/stem/README.md b/packages/stem/README.md index e5bb1d6b..0dbd94d8 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -925,15 +925,18 @@ If you already have a raw `WorkflowResult`, use stored workflow result without another cast/closure. If you are inspecting the underlying `RunState` directly, use `state.resultJson(...)`, `state.resultAs(codec: ...)`, -`state.suspensionPayloadJson(...)`, or +`state.resultVersionedJson(...)`, `state.suspensionPayloadJson(...)`, +`state.suspensionPayloadVersionedJson(...)`, or `state.suspensionPayloadAs(codec: ...)` instead of manual raw-map casts. Workflow run detail views expose the same convenience surface via `runView.resultJson(...)`, `runView.resultAs(codec: ...)`, -`runView.suspensionPayloadJson(...)`, and +`runView.resultVersionedJson(...)`, `runView.suspensionPayloadJson(...)`, +`runView.suspensionPayloadVersionedJson(...)`, and `runView.suspensionPayloadAs(codec: ...)`. Checkpoint entries from `viewCheckpoints(...)` and `WorkflowCheckpointView.fromEntry(...)` expose the same surface via -`entry.valueJson(...)` and `entry.valueAs(codec: ...)`. +`entry.valueJson(...)`, `entry.valueVersionedJson(...)`, and +`entry.valueAs(codec: ...)`. Workflow introspection events expose matching helpers via `event.resultJson(...)` and `event.resultAs(codec: ...)`. For lower-level suspension directives, prefer `step.sleepJson(...)`, diff --git a/packages/stem/lib/src/workflow/core/workflow_step_entry.dart b/packages/stem/lib/src/workflow/core/workflow_step_entry.dart index 71e3cf3e..f1b4aafa 100644 --- a/packages/stem/lib/src/workflow/core/workflow_step_entry.dart +++ b/packages/stem/lib/src/workflow/core/workflow_step_entry.dart @@ -52,6 +52,24 @@ class WorkflowStepEntry { ).decode(stored); } + /// Decodes the persisted checkpoint value with a version-aware JSON decoder, + /// when present. + TValue? valueVersionedJson({ + required int version, + required TValue Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final stored = value; + if (stored == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(stored); + } + /// Base step name without any auto-version suffix. String get baseName { final hashIndex = name.indexOf('#'); diff --git a/packages/stem/lib/src/workflow/core/workflow_watcher.dart b/packages/stem/lib/src/workflow/core/workflow_watcher.dart index 1ae5bb10..db45e4f2 100644 --- a/packages/stem/lib/src/workflow/core/workflow_watcher.dart +++ b/packages/stem/lib/src/workflow/core/workflow_watcher.dart @@ -75,6 +75,28 @@ class WorkflowWatcher { ).decode(stored); } + /// Decodes the captured watcher payload with a version-aware JSON decoder, + /// when present. + TPayload? payloadVersionedJson({ + required int version, + required TPayload Function( + Map payload, + int version, + ) + decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final stored = payload; + if (stored == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(stored); + } + /// Timestamp when suspension was recorded. DateTime? get suspendedAt => _dateFromJson(data['suspendedAt']); @@ -162,6 +184,28 @@ class WorkflowWatcherResolution { ).decode(stored); } + /// Decodes the resume payload with a version-aware JSON decoder, when + /// present. + TPayload? payloadVersionedJson({ + required int version, + required TPayload Function( + Map payload, + int version, + ) + decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final stored = payload; + if (stored == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(stored); + } + /// Timestamp when event delivery was recorded. DateTime? get deliveredAt => _dateFromJson(resumeData['deliveredAt']); diff --git a/packages/stem/lib/src/workflow/runtime/workflow_views.dart b/packages/stem/lib/src/workflow/runtime/workflow_views.dart index 52fd2099..65ab6f64 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_views.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_views.dart @@ -78,6 +78,23 @@ class WorkflowRunView { ).decode(stored); } + /// Decodes the final result payload with a version-aware JSON decoder. + TResult? resultVersionedJson({ + required int version, + required TResult Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final stored = result; + if (stored == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(stored); + } + /// Last error payload, if present. final Map? lastError; @@ -115,6 +132,28 @@ class WorkflowRunView { ).decode(stored); } + /// Decodes the suspension payload with a version-aware JSON decoder, when + /// present. + TPayload? suspensionPayloadVersionedJson({ + required int version, + required TPayload Function( + Map payload, + int version, + ) + decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final stored = suspensionPayload; + if (stored == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(stored); + } + /// Serializes this view into JSON. Map toJson() { return { @@ -209,6 +248,23 @@ class WorkflowCheckpointView { ).decode(stored); } + /// Decodes the persisted checkpoint value with a version-aware JSON decoder. + TValue? valueVersionedJson({ + required int version, + required TValue Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final stored = value; + if (stored == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(stored); + } + /// Serializes this view into JSON. Map toJson() { return { diff --git a/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart b/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart index 81735f7f..de9a7962 100644 --- a/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart +++ b/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart @@ -190,6 +190,17 @@ void main() { 'inv-1', ), ); + expect( + watcher.payloadVersionedJson<_InvoicePayload>( + version: 2, + decode: _InvoicePayload.fromVersionedJson, + ), + isA<_InvoicePayload>().having( + (value) => value.invoiceId, + 'invoiceId', + 'inv-1', + ), + ); expect(watcher.suspendedAt, equals(DateTime.utc(2026, 2, 25, 0, 1))); expect( watcher.requestedResumeAt, @@ -214,6 +225,17 @@ void main() { 'inv-1', ), ); + expect( + resolution.payloadVersionedJson<_InvoicePayload>( + version: 2, + decode: _InvoicePayload.fromVersionedJson, + ), + isA<_InvoicePayload>().having( + (value) => value.invoiceId, + 'invoiceId', + 'inv-1', + ), + ); expect( resolution.deliveredAt, equals(DateTime.utc(2026, 2, 25, 0, 1, 30)), @@ -246,6 +268,17 @@ void main() { 'inv-3', ), ); + expect( + step.valueVersionedJson<_InvoicePayload>( + version: 2, + decode: _InvoicePayload.fromVersionedJson, + ), + isA<_InvoicePayload>().having( + (value) => value.invoiceId, + 'invoiceId', + 'inv-3', + ), + ); expect(plain.baseName, equals('finalize')); expect(plain.iteration, isNull); }); @@ -276,6 +309,17 @@ void main() { 'inv-4', ), ); + expect( + view.resultVersionedJson<_InvoicePayload>( + version: 2, + decode: _InvoicePayload.fromVersionedJson, + ), + isA<_InvoicePayload>().having( + (value) => value.invoiceId, + 'invoiceId', + 'inv-4', + ), + ); expect( view.suspensionPayloadJson<_InvoicePayload>( decode: _InvoicePayload.fromJson, @@ -286,6 +330,17 @@ void main() { 'inv-5', ), ); + expect( + view.suspensionPayloadVersionedJson<_InvoicePayload>( + version: 2, + decode: _InvoicePayload.fromVersionedJson, + ), + isA<_InvoicePayload>().having( + (value) => value.invoiceId, + 'invoiceId', + 'inv-5', + ), + ); }); test('decodes checkpoint values as DTOs', () { @@ -308,6 +363,17 @@ void main() { 'inv-6', ), ); + expect( + view.valueVersionedJson<_InvoicePayload>( + version: 2, + decode: _InvoicePayload.fromVersionedJson, + ), + isA<_InvoicePayload>().having( + (value) => value.invoiceId, + 'invoiceId', + 'inv-6', + ), + ); }); }); } From dcc4a3d656960ce5cd71c92b9f34734ec290bb83 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 12:05:44 -0500 Subject: [PATCH 221/302] Add versioned signal decode helpers --- .site/docs/core-concepts/observability.md | 4 +++- .site/docs/core-concepts/tasks.md | 5 ++-- packages/stem/CHANGELOG.md | 3 +++ packages/stem/README.md | 9 +++++--- .../stem/lib/src/core/task_invocation.dart | 23 +++++++++++++++++++ packages/stem/lib/src/signals/payloads.dart | 21 +++++++++++++++++ .../test/unit/core/task_invocation_test.dart | 16 +++++++++++++ .../stem/test/unit/signals/payloads_test.dart | 20 ++++++++++++++++ 8 files changed, 95 insertions(+), 6 deletions(-) diff --git a/.site/docs/core-concepts/observability.md b/.site/docs/core-concepts/observability.md index 44d69ffe..d310bf93 100644 --- a/.site/docs/core-concepts/observability.md +++ b/.site/docs/core-concepts/observability.md @@ -61,7 +61,9 @@ control-plane commands. When you inspect `TaskPostrunPayload` or `TaskSuccessPayload` directly, prefer `payload.resultJson(...)` or `payload.resultAs(codec: ...)` over manual `payload.result as Map` casts. -For workflow lifecycle signals, prefer `payload.metadataJson('key', ...)` or +For workflow lifecycle signals, prefer +`payload.metadataJson('key', ...)`, +`payload.metadataVersionedJson('key', ...)`, or `payload.metadataAs('key', codec: ...)` over manual `payload.metadata['key'] as Map` casts. diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index c463c937..ba47129b 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -139,8 +139,9 @@ every retry signal and shows how the strategy interacts with broker timings. hand-built maps. - `context.retry(...)` – request an immediate retry with optional per-call retry policy overrides. -- when you inspect a raw `ProgressSignal`, prefer `signal.dataJson('key', ...)` - or `signal.dataValue('key')` over manual `signal.data?['key']` casts. +- when you inspect a raw `ProgressSignal`, prefer + `signal.dataJson('key', ...)`, `signal.dataVersionedJson('key', ...)`, or + `signal.dataValue('key')` over manual `signal.data?['key']` casts. Use the context to build idempotent handlers. Re-enqueue work, cancel jobs, or store audit details in `context.meta`. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 8fb06100..77c52124 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -149,6 +149,9 @@ - Added `WorkflowExecutionContext` as the shared typed execution context for flow steps and script checkpoints, and taught `stem_builder` to accept that shared context type directly in annotated workflow methods. +- Added `WorkflowRunPayload.metadataVersionedJson(...)` and + `ProgressSignal.dataVersionedJson(...)` so signal payloads can decode + versioned DTO metadata without manual casts. - Simplified the manual JSON helper path so `TaskDefinition.json(...)` and `WorkflowRef.json(...)` no longer require unused producer-side `decodeArgs`/`decodeParams` callbacks just to publish DTO payloads. diff --git a/packages/stem/README.md b/packages/stem/README.md index 0dbd94d8..6d5998e4 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -328,8 +328,9 @@ class ParentTask implements TaskHandler { ``` If you inspect raw task progress signals, prefer -`signal.dataJson('key', ...)`, `signal.dataAs('key', codec: ...)`, or -`signal.dataValue('key')` over manual `signal.data?['key']` casts. +`signal.dataJson('key', ...)`, `signal.dataVersionedJson('key', ...)`, +`signal.dataAs('key', codec: ...)`, or `signal.dataValue('key')` over +manual `signal.data?['key']` casts. Shared `TaskExecutionContext` implementations also expose `context.retry(...)`, so typed annotated tasks can request retries without depending on a concrete task runtime class. @@ -947,7 +948,9 @@ Task lifecycle signals expose matching result helpers on and `payload.resultAs(codec: ...)`. Workflow lifecycle signals expose matching metadata helpers on `WorkflowRunPayload` via `payload.metadataJson('key', ...)`, -`payload.metadataAs('key', codec: ...)`, and `payload.metadataValue('key')`. +`payload.metadataVersionedJson('key', ...)`, +`payload.metadataAs('key', codec: ...)`, and +`payload.metadataValue('key')`. Low-level `FlowStepControl` objects expose matching suspension metadata helpers via `control.dataJson(...)` and `control.dataAs(codec: ...)`. diff --git a/packages/stem/lib/src/core/task_invocation.dart b/packages/stem/lib/src/core/task_invocation.dart index 3c900574..417a81cf 100644 --- a/packages/stem/lib/src/core/task_invocation.dart +++ b/packages/stem/lib/src/core/task_invocation.dart @@ -126,6 +126,29 @@ class ProgressSignal extends TaskInvocationSignal { typeName: typeName, ); } + + /// Decodes the progress metadata value for [key] as a typed DTO from + /// version-aware JSON. + T? dataVersionedJson( + String key, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final payload = data; + if (payload == null) return null; + return payload.valueJson( + key, + decode: (json) => PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(json), + typeName: typeName, + ); + } } /// Request to enqueue a task from an isolate. diff --git a/packages/stem/lib/src/signals/payloads.dart b/packages/stem/lib/src/signals/payloads.dart index 48d65b80..c074de09 100644 --- a/packages/stem/lib/src/signals/payloads.dart +++ b/packages/stem/lib/src/signals/payloads.dart @@ -623,6 +623,27 @@ class WorkflowRunPayload implements StemEvent { ); } + /// Decodes the metadata value for [key] as a typed DTO with a version-aware + /// JSON decoder. + T? metadataVersionedJson( + String key, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return metadata.valueJson( + key, + decode: (json) => PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(json), + typeName: typeName, + ); + } + /// Returns the decoded metadata value for [key], or [fallback] when absent. T metadataValueOr(String key, T fallback, {PayloadCodec? codec}) { return metadata.valueOr(key, fallback, codec: codec); diff --git a/packages/stem/test/unit/core/task_invocation_test.dart b/packages/stem/test/unit/core/task_invocation_test.dart index 9ce9db3d..19101c70 100644 --- a/packages/stem/test/unit/core/task_invocation_test.dart +++ b/packages/stem/test/unit/core/task_invocation_test.dart @@ -208,6 +208,14 @@ void main() { ), isA<_ProgressUpdate>().having((value) => value.stage, 'stage', 'warming'), ); + expect( + signal.dataVersionedJson<_ProgressUpdate>( + 'update', + version: 2, + decode: _ProgressUpdate.fromVersionedJson, + ), + isA<_ProgressUpdate>().having((value) => value.stage, 'stage', 'warming'), + ); }); test('TaskInvocationContext.local merges headers/meta and lineage', () async { @@ -624,6 +632,14 @@ class _ProgressUpdate { return _ProgressUpdate(stage: json['stage'] as String); } + factory _ProgressUpdate.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _ProgressUpdate(stage: json['stage'] as String); + } + final String stage; Map toJson() => {'stage': stage}; diff --git a/packages/stem/test/unit/signals/payloads_test.dart b/packages/stem/test/unit/signals/payloads_test.dart index 5a28952e..d7a071c4 100644 --- a/packages/stem/test/unit/signals/payloads_test.dart +++ b/packages/stem/test/unit/signals/payloads_test.dart @@ -143,6 +143,18 @@ void main() { isTrue, ), ); + expect( + payload.metadataVersionedJson<_WorkflowRunMetadata>( + 'approval', + version: 2, + decode: _WorkflowRunMetadata.fromVersionedJson, + ), + isA<_WorkflowRunMetadata>().having( + (value) => value.approved, + 'approved', + isTrue, + ), + ); }); } @@ -163,5 +175,13 @@ class _WorkflowRunMetadata { return _WorkflowRunMetadata(approved: json['approved'] as bool); } + factory _WorkflowRunMetadata.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _WorkflowRunMetadata(approved: json['approved'] as bool); + } + final bool approved; } From 04834adc5f37780939493e6146bac765f8c0fda5 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 12:07:37 -0500 Subject: [PATCH 222/302] Add versioned task signal result helpers --- .site/docs/core-concepts/observability.md | 3 +- packages/stem/CHANGELOG.md | 12 +++---- packages/stem/README.md | 5 +-- packages/stem/lib/src/signals/payloads.dart | 34 +++++++++++++++++++ .../stem/test/unit/signals/payloads_test.dart | 22 ++++++++++++ 5 files changed, 67 insertions(+), 9 deletions(-) diff --git a/.site/docs/core-concepts/observability.md b/.site/docs/core-concepts/observability.md index d310bf93..151fa52c 100644 --- a/.site/docs/core-concepts/observability.md +++ b/.site/docs/core-concepts/observability.md @@ -59,7 +59,8 @@ control-plane commands. ``` When you inspect `TaskPostrunPayload` or `TaskSuccessPayload` directly, prefer -`payload.resultJson(...)` or `payload.resultAs(codec: ...)` over manual +`payload.resultJson(...)`, `payload.resultVersionedJson(...)`, or +`payload.resultAs(codec: ...)` over manual `payload.result as Map` casts. For workflow lifecycle signals, prefer `payload.metadataJson('key', ...)`, diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 77c52124..bc5051df 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -78,12 +78,12 @@ - Added `WorkflowStepEvent.resultJson(...)` and `resultAs(codec: ...)` so workflow introspection consumers can decode DTO checkpoint results without manual casts. -- Added `TaskPostrunPayload.resultJson(...)` and `resultAs(codec: ...)` so - task lifecycle signal consumers can decode DTO task results without manual - casts. -- Added `TaskSuccessPayload.resultJson(...)` and `resultAs(codec: ...)` so - success-only task signal consumers can decode DTO task results without - manual casts. +- Added `TaskPostrunPayload.resultJson(...)`, + `resultVersionedJson(...)`, and `resultAs(codec: ...)` so task lifecycle + signal consumers can decode DTO task results without manual casts. +- Added `TaskSuccessPayload.resultJson(...)`, + `resultVersionedJson(...)`, and `resultAs(codec: ...)` so success-only + task signal consumers can decode DTO task results without manual casts. - Added `WorkflowRunPayload.metadataValue(...)`, `requiredMetadataValue(...)`, `metadataJson(...)`, and `metadataAs(codec: ...)` so workflow lifecycle signal consumers can decode diff --git a/packages/stem/README.md b/packages/stem/README.md index 6d5998e4..5247b5f9 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -944,8 +944,9 @@ For lower-level suspension directives, prefer `step.sleepJson(...)`, `step.awaitEventJson(...)`, and `FlowStepControl.awaitTopicJson(...)` over hand-built maps. Task lifecycle signals expose matching result helpers on -`TaskPostrunPayload` and `TaskSuccessPayload` via `payload.resultJson(...)` -and `payload.resultAs(codec: ...)`. +`TaskPostrunPayload` and `TaskSuccessPayload` via +`payload.resultJson(...)`, `payload.resultVersionedJson(...)`, and +`payload.resultAs(codec: ...)`. Workflow lifecycle signals expose matching metadata helpers on `WorkflowRunPayload` via `payload.metadataJson('key', ...)`, `payload.metadataVersionedJson('key', ...)`, diff --git a/packages/stem/lib/src/signals/payloads.dart b/packages/stem/lib/src/signals/payloads.dart index c074de09..38505fbf 100644 --- a/packages/stem/lib/src/signals/payloads.dart +++ b/packages/stem/lib/src/signals/payloads.dart @@ -233,6 +233,23 @@ class TaskPostrunPayload implements StemEvent { ).decode(stored); } + /// Decodes the task result with a version-aware JSON decoder. + TResult? resultVersionedJson({ + required int version, + required TResult Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final stored = result; + if (stored == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(stored); + } + /// The final state of the task. final TaskState state; @@ -357,6 +374,23 @@ class TaskSuccessPayload implements StemEvent { ).decode(stored); } + /// Decodes the task result with a version-aware JSON decoder. + TResult? resultVersionedJson({ + required int version, + required TResult Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final stored = result; + if (stored == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(stored); + } + final DateTime _occurredAt; /// The unique identifier for the task. diff --git a/packages/stem/test/unit/signals/payloads_test.dart b/packages/stem/test/unit/signals/payloads_test.dart index d7a071c4..6f204c63 100644 --- a/packages/stem/test/unit/signals/payloads_test.dart +++ b/packages/stem/test/unit/signals/payloads_test.dart @@ -57,6 +57,13 @@ void main() { ), isA<_TaskResultPayload>().having((value) => value.ok, 'ok', isTrue), ); + expect( + postrun.resultVersionedJson<_TaskResultPayload>( + version: 2, + decode: _TaskResultPayload.fromVersionedJson, + ), + isA<_TaskResultPayload>().having((value) => value.ok, 'ok', isTrue), + ); final retry = TaskRetryPayload( envelope: envelope, @@ -85,6 +92,13 @@ void main() { ), isA<_TaskResultPayload>().having((value) => value.ok, 'ok', isTrue), ); + expect( + success.resultVersionedJson<_TaskResultPayload>( + version: 2, + decode: _TaskResultPayload.fromVersionedJson, + ), + isA<_TaskResultPayload>().having((value) => value.ok, 'ok', isTrue), + ); }); test('control command payload timestamps are frozen at creation', () { @@ -165,6 +179,14 @@ class _TaskResultPayload { return _TaskResultPayload(ok: json['ok'] as bool); } + factory _TaskResultPayload.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _TaskResultPayload(ok: json['ok'] as bool); + } + final bool ok; } From d740ada28f252668917112c125f7e82900ccfacc Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 12:08:55 -0500 Subject: [PATCH 223/302] Add versioned workflow event result helpers --- .site/docs/core-concepts/observability.md | 3 ++- packages/stem/CHANGELOG.md | 7 ++++--- packages/stem/README.md | 3 ++- .../runtime/workflow_introspection.dart | 18 ++++++++++++++++++ .../stem/test/unit/core/stem_event_test.dart | 19 +++++++++++++++++++ 5 files changed, 45 insertions(+), 5 deletions(-) diff --git a/.site/docs/core-concepts/observability.md b/.site/docs/core-concepts/observability.md index 151fa52c..f84924a4 100644 --- a/.site/docs/core-concepts/observability.md +++ b/.site/docs/core-concepts/observability.md @@ -91,7 +91,8 @@ class LoggingWorkflowIntrospectionSink implements WorkflowIntrospectionSink { ``` When a completed step or checkpoint carries a DTO payload, prefer -`event.resultJson(...)` or `event.resultAs(codec: ...)` over manual +`event.resultJson(...)`, `event.resultVersionedJson(...)`, or +`event.resultAs(codec: ...)` over manual `event.result as Map` casts. ## Logging diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index bc5051df..ef6c5297 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -75,9 +75,10 @@ `valueVersionedJson(...)` plus their `...As(codec: ...)` counterparts so dashboard/CLI workflow detail views can decode DTO payloads without manual casts. -- Added `WorkflowStepEvent.resultJson(...)` and `resultAs(codec: ...)` so - workflow introspection consumers can decode DTO checkpoint results without - manual casts. +- Added `WorkflowStepEvent.resultJson(...)`, + `resultVersionedJson(...)`, and `resultAs(codec: ...)` so workflow + introspection consumers can decode DTO checkpoint results without manual + casts. - Added `TaskPostrunPayload.resultJson(...)`, `resultVersionedJson(...)`, and `resultAs(codec: ...)` so task lifecycle signal consumers can decode DTO task results without manual casts. diff --git a/packages/stem/README.md b/packages/stem/README.md index 5247b5f9..6dcbb67a 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -939,7 +939,8 @@ Checkpoint entries from `viewCheckpoints(...)` and `entry.valueJson(...)`, `entry.valueVersionedJson(...)`, and `entry.valueAs(codec: ...)`. Workflow introspection events expose matching helpers via -`event.resultJson(...)` and `event.resultAs(codec: ...)`. +`event.resultJson(...)`, `event.resultVersionedJson(...)`, and +`event.resultAs(codec: ...)`. For lower-level suspension directives, prefer `step.sleepJson(...)`, `step.awaitEventJson(...)`, and `FlowStepControl.awaitTopicJson(...)` over hand-built maps. diff --git a/packages/stem/lib/src/workflow/runtime/workflow_introspection.dart b/packages/stem/lib/src/workflow/runtime/workflow_introspection.dart index 8187565f..a47abcc6 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_introspection.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_introspection.dart @@ -78,6 +78,24 @@ class WorkflowStepEvent implements StemEvent { ).decode(stored); } + /// Decodes the step result payload with a version-aware JSON decoder, when + /// present. + TResult? resultVersionedJson({ + required int version, + required TResult Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final stored = result; + if (stored == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(stored); + } + /// Optional error message for failed steps. final String? error; diff --git a/packages/stem/test/unit/core/stem_event_test.dart b/packages/stem/test/unit/core/stem_event_test.dart index d3aa2755..5874447d 100644 --- a/packages/stem/test/unit/core/stem_event_test.dart +++ b/packages/stem/test/unit/core/stem_event_test.dart @@ -61,6 +61,17 @@ void main() { 'ch_123', ), ); + expect( + event.resultVersionedJson<_ChargeResult>( + version: 2, + decode: _ChargeResult.fromVersionedJson, + ), + isA<_ChargeResult>().having( + (value) => value.chargeId, + 'chargeId', + 'ch_123', + ), + ); }); }); } @@ -72,5 +83,13 @@ class _ChargeResult { return _ChargeResult(chargeId: json['chargeId'] as String); } + factory _ChargeResult.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _ChargeResult(chargeId: json['chargeId'] as String); + } + final String chargeId; } From 3fdf699520ba5aa6f3728a2d4361002207409d0b Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 12:10:17 -0500 Subject: [PATCH 224/302] Add versioned queue event decode helpers --- .site/docs/core-concepts/queue-events.md | 5 ++-- packages/stem/CHANGELOG.md | 7 +++--- packages/stem/lib/src/core/queue_events.dart | 16 +++++++++++++ .../test/unit/core/queue_events_test.dart | 23 ++++++++++++++++++- 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/.site/docs/core-concepts/queue-events.md b/.site/docs/core-concepts/queue-events.md index e7fdd987..6e8f2d3c 100644 --- a/.site/docs/core-concepts/queue-events.md +++ b/.site/docs/core-concepts/queue-events.md @@ -23,8 +23,9 @@ Use this when you need lightweight event streams for domain notifications All events are delivered as `QueueCustomEvent`, which implements `StemEvent`. Use `event.payloadValue(...)` / `event.requiredPayloadValue(...)` to read typed payload fields instead of repeating raw `payload['key']` casts. -If one queue event maps to one DTO, use `event.payloadJson(...)` or -`event.payloadAs(codec: ...)` to decode the whole payload in one step. +If one queue event maps to one DTO, use `event.payloadJson(...)`, +`event.payloadVersionedJson(...)`, or `event.payloadAs(codec: ...)` to decode +the whole payload in one step. ## Producer + Listener diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index ef6c5297..c18b1e39 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -35,9 +35,10 @@ `payloadValueOr(...)`, and `requiredPayloadValue(...)` so queue-event consumers can decode typed payload fields without raw `payload['key']` casts. -- Added `QueueCustomEvent.payloadJson(...)` and `payloadAs(codec: ...)` so - queue-event consumers can decode whole DTO payloads without rebuilding them - field by field. +- Added `QueueCustomEvent.payloadJson(...)`, + `payloadVersionedJson(...)`, and `payloadAs(codec: ...)` so queue-event + consumers can decode whole DTO payloads without rebuilding them field by + field. - Added `decodeJson:` shortcuts to the low-level `Stem.waitForTask` and `StemWorkflowApp.waitForCompletion` wait APIs, and propagated the same task wait shortcut through `StemApp` and diff --git a/packages/stem/lib/src/core/queue_events.dart b/packages/stem/lib/src/core/queue_events.dart index ded6e4ac..db1076b4 100644 --- a/packages/stem/lib/src/core/queue_events.dart +++ b/packages/stem/lib/src/core/queue_events.dart @@ -67,6 +67,22 @@ class QueueCustomEvent implements StemEvent { ).decode(payload); } + /// Decodes the entire payload as a typed DTO with a version-aware JSON + /// decoder. + T payloadVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(payload); + } + /// Returns the decoded payload value for [key], or [fallback] when absent. T payloadValueOr(String key, T fallback, {PayloadCodec? codec}) { return payload.valueOr(key, fallback, codec: codec); diff --git a/packages/stem/test/unit/core/queue_events_test.dart b/packages/stem/test/unit/core/queue_events_test.dart index 4d150ef6..7745edc4 100644 --- a/packages/stem/test/unit/core/queue_events_test.dart +++ b/packages/stem/test/unit/core/queue_events_test.dart @@ -151,7 +151,8 @@ void main() { }); test( - 'emitVersionedJson publishes DTO payloads with a persisted schema version', + 'emitVersionedJson publishes DTO payloads with a persisted schema ' + 'version', () async { final listener = QueueEvents( broker: broker, @@ -180,6 +181,15 @@ void main() { 'orderId': 'o-3', 'status': 'versioned', }); + expect( + event.payloadVersionedJson<_QueueEventPayload>( + version: 2, + decode: _QueueEventPayload.fromVersionedJson, + ), + isA<_QueueEventPayload>() + .having((value) => value.orderId, 'orderId', 'o-3') + .having((value) => value.status, 'status', 'versioned'), + ); }, ); @@ -215,6 +225,17 @@ class _QueueEventPayload { ); } + factory _QueueEventPayload.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _QueueEventPayload( + orderId: json['orderId'] as String, + status: json['status'] as String, + ); + } + final String orderId; final String status; From 7f59e438c84253a47af883a9064c9a33096cc7e4 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 12:11:22 -0500 Subject: [PATCH 225/302] Add versioned flow control decode helpers --- .../workflows/context-and-serialization.md | 5 +++-- packages/stem/CHANGELOG.md | 6 +++--- packages/stem/README.md | 3 ++- .../stem/lib/src/workflow/core/flow_step.dart | 18 ++++++++++++++++++ .../test/unit/workflow/flow_step_test.dart | 19 +++++++++++++++++++ 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index 6997002c..392f4eac 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -55,8 +55,9 @@ Depending on the context type, you can access: - `sleepJson(...)`, `awaitEventJson(...)`, and `FlowStepControl.awaitTopicJson(...)` when lower-level suspension directives still need DTO metadata without a separate codec constant -- `control.dataJson(...)` / `control.dataAs(codec: ...)` when you inspect a - lower-level `FlowStepControl` directly +- `control.dataJson(...)`, `control.dataVersionedJson(...)`, or + `control.dataAs(codec: ...)` when you inspect a lower-level + `FlowStepControl` directly - `takeResumeData()` for event-driven resumes - `takeResumeValue(codec: ...)` for typed event-driven resumes - `takeResumeJson(...)` for DTO event-driven resumes without a separate diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index c18b1e39..a305693f 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -105,9 +105,9 @@ - Updated task docs/snippets to prefer `TaskExecutionContext` for shared enqueue/workflow/event examples, leaving `TaskContext` and `TaskInvocationContext` only where the runtime distinction matters. -- Added `FlowStepControl.dataJson(...)` and `dataAs(codec: ...)` so - lower-level suspension control objects can decode DTO metadata without - manual casts. +- Added `FlowStepControl.dataJson(...)`, `dataVersionedJson(...)`, and + `dataAs(codec: ...)` so lower-level suspension control objects can decode + DTO metadata without manual casts. - Added `GroupStatus.resultValues()`, `resultJson(...)`, and `resultAs(codec: ...)` so canvas/group status inspection can decode typed child results without manually mapping raw `TaskStatus.payload` values. diff --git a/packages/stem/README.md b/packages/stem/README.md index 6dcbb67a..f06ddb15 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -954,7 +954,8 @@ Workflow lifecycle signals expose matching metadata helpers on `payload.metadataAs('key', codec: ...)`, and `payload.metadataValue('key')`. Low-level `FlowStepControl` objects expose matching suspension metadata -helpers via `control.dataJson(...)` and `control.dataAs(codec: ...)`. +helpers via `control.dataJson(...)`, `control.dataVersionedJson(...)`, and +`control.dataAs(codec: ...)`. In the example above, these calls inside `run(...)`: diff --git a/packages/stem/lib/src/workflow/core/flow_step.dart b/packages/stem/lib/src/workflow/core/flow_step.dart index abe30ec5..24420422 100644 --- a/packages/stem/lib/src/workflow/core/flow_step.dart +++ b/packages/stem/lib/src/workflow/core/flow_step.dart @@ -242,6 +242,24 @@ class FlowStepControl { typeName: typeName, ).decode(stored); } + + /// Decodes the suspension metadata with a version-aware JSON decoder, when + /// present. + TData? dataVersionedJson({ + required int version, + required TData Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final stored = data; + if (stored == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(stored); + } } /// Enumerates the suspension control types. diff --git a/packages/stem/test/unit/workflow/flow_step_test.dart b/packages/stem/test/unit/workflow/flow_step_test.dart index b693b662..742fee7e 100644 --- a/packages/stem/test/unit/workflow/flow_step_test.dart +++ b/packages/stem/test/unit/workflow/flow_step_test.dart @@ -44,6 +44,17 @@ void main() { 'sleeping', ), ); + expect( + sleep.dataVersionedJson<_SuspensionPayload>( + version: 2, + decode: _SuspensionPayload.fromVersionedJson, + ), + isA<_SuspensionPayload>().having( + (value) => value.stage, + 'stage', + 'sleeping', + ), + ); expect(wait.data, equals(const {'stage': 'waiting'})); expect(wait.deadline, DateTime.parse('2025-01-01T00:00:00Z')); }); @@ -56,6 +67,14 @@ class _SuspensionPayload { return _SuspensionPayload(stage: json['stage'] as String); } + factory _SuspensionPayload.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _SuspensionPayload(stage: json['stage'] as String); + } + final String stage; Map toJson() => {'stage': stage}; From 39c9857a22e0c84ab65fc5022881bc4d97b34976 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 12:20:56 -0500 Subject: [PATCH 226/302] Add versioned context payload helpers --- .../workflows/context-and-serialization.md | 8 +++--- packages/stem/CHANGELOG.md | 9 ++++--- packages/stem/README.md | 4 +++ packages/stem/lib/src/core/contracts.dart | 15 +++++++++++ .../core/workflow_execution_context.dart | 16 ++++++++++++ .../core/workflow_script_context.dart | 16 ++++++++++++ .../test/unit/core/task_invocation_test.dart | 9 +++++++ .../unit/workflow/workflow_resume_test.dart | 26 +++++++++++++++++++ 8 files changed, 95 insertions(+), 8 deletions(-) diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index 392f4eac..d0fa9a6d 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -36,8 +36,8 @@ Depending on the context type, you can access: - workflow params and previous results - `param()` / `requiredParam()` for typed access to workflow start params -- `paramsAs(codec: ...)` / `paramsJson()` for decoding the full workflow - start payload as one DTO +- `paramsAs(codec: ...)`, `paramsJson()`, or `paramsVersionedJson()` + for decoding the full workflow start payload as one DTO - `paramJson()` / `requiredParamJson()` for nested DTO params without a separate codec constant - `paramListJson()` / `requiredParamListJson()` for lists of nested DTO @@ -68,8 +68,8 @@ Depending on the context type, you can access: `ref.startAndWait(context, params: value)` - direct task enqueue APIs because `WorkflowExecutionContext` and `TaskExecutionContext` both implement `TaskEnqueuer` -- `argsAs(codec: ...)` / `argsJson()` for decoding the full task-arg - payload as one DTO inside manual task handlers +- `argsAs(codec: ...)`, `argsJson()`, or `argsVersionedJson()` for + decoding the full task-arg payload as one DTO inside manual task handlers - task metadata like `id`, `attempt`, `meta` Child workflow starts belong in durable boundaries: diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index a305693f..e410786f 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,10 +2,11 @@ ## 0.1.1 -- Added `TaskExecutionContext.argsAs(...)` / `argsJson(...)`, - `WorkflowExecutionContext.paramsAs(...)` / `paramsJson(...)`, and the same - full-payload helpers on `WorkflowScriptContext`, so manual task/workflow - code can decode an entire DTO input without field-by-field map plumbing. +- Added `TaskExecutionContext.argsAs(...)`, `argsJson(...)`, + `argsVersionedJson(...)`, `WorkflowExecutionContext.paramsAs(...)`, + `paramsJson(...)`, `paramsVersionedJson(...)`, and the same full-payload + helpers on `WorkflowScriptContext`, so manual task/workflow code can decode + an entire DTO input without field-by-field map plumbing. - Added `PayloadCodec.versionedJson(...)` so DTO payload codecs can persist a schema version beside the JSON payload and decode older shapes explicitly. - Added versioned low-level DTO shortcuts: diff --git a/packages/stem/README.md b/packages/stem/README.md index f06ddb15..bd8bebcb 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -243,6 +243,10 @@ final taskArgs = context.argsJson( final workflowParams = ctx.paramsAs( codec: approvalDraftCodec, ); +final versionedParams = ctx.paramsVersionedJson( + version: 2, + decode: ApprovalDraft.fromVersionedJson, +); ``` For typed task calls, the definition and call objects now expose the common diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index be6b8ef0..e795fc4c 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -1776,6 +1776,21 @@ extension TaskInputContextArgs on TaskInputContext { ).decode(args); } + /// Decodes the full task-argument payload as a version-aware DTO. + T argsVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(args); + } + /// Returns the decoded task arg for [key], or `null`. T? arg(String key, {PayloadCodec? codec}) { return args.value(key, codec: codec); diff --git a/packages/stem/lib/src/workflow/core/workflow_execution_context.dart b/packages/stem/lib/src/workflow/core/workflow_execution_context.dart index 773a0d0a..0f453f08 100644 --- a/packages/stem/lib/src/workflow/core/workflow_execution_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_execution_context.dart @@ -60,6 +60,22 @@ extension WorkflowExecutionContextParams on WorkflowExecutionContext { ).decode(params); } + /// Decodes the full workflow start-parameter payload as a version-aware + /// DTO. + T paramsVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(params); + } + /// Returns the decoded workflow parameter for [key], or `null`. T? param(String key, {PayloadCodec? codec}) { return params.value(key, codec: codec); diff --git a/packages/stem/lib/src/workflow/core/workflow_script_context.dart b/packages/stem/lib/src/workflow/core/workflow_script_context.dart index 555823f2..3bef18db 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script_context.dart @@ -79,6 +79,22 @@ extension WorkflowScriptContextParams on WorkflowScriptContext { ).decode(params); } + /// Decodes the full workflow start-parameter payload as a version-aware + /// DTO. + T paramsVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(params); + } + /// Returns the decoded workflow parameter for [key], or `null`. T? param(String key, {PayloadCodec? codec}) { return params.value(key, codec: codec); diff --git a/packages/stem/test/unit/core/task_invocation_test.dart b/packages/stem/test/unit/core/task_invocation_test.dart index 19101c70..f953d421 100644 --- a/packages/stem/test/unit/core/task_invocation_test.dart +++ b/packages/stem/test/unit/core/task_invocation_test.dart @@ -165,6 +165,15 @@ void main() { context.argsAs<_ProgressUpdate>(codec: _progressUpdateCodec).stage, 'warming', ); + expect( + context + .argsVersionedJson<_ProgressUpdate>( + version: 2, + decode: _ProgressUpdate.fromVersionedJson, + ) + .stage, + 'warming', + ); }); test( diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart index f10617b1..80ac3faa 100644 --- a/packages/stem/test/unit/workflow/workflow_resume_test.dart +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -156,12 +156,30 @@ void main() { .message, 'approved', ); + expect( + flowContext + .paramsVersionedJson<_ResumePayload>( + version: 2, + decode: _ResumePayload.fromVersionedJson, + ) + .message, + 'approved', + ); expect( scriptContext .paramsAs<_ResumePayload>(codec: _resumePayloadCodec) .message, 'queued', ); + expect( + scriptContext + .paramsVersionedJson<_ResumePayload>( + version: 2, + decode: _ResumePayload.fromVersionedJson, + ) + .message, + 'queued', + ); }, ); @@ -874,6 +892,14 @@ class _ResumePayload { return _ResumePayload(message: json['message']! as String); } + factory _ResumePayload.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _ResumePayload(message: json['message']! as String); + } + final String message; Map toJson() => {'message': message}; From 4485e25191b639fff876cbee5b54c9e02c6bb82f Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 12:23:10 -0500 Subject: [PATCH 227/302] Add versioned payload map readers --- packages/stem/CHANGELOG.md | 5 + packages/stem/lib/src/core/payload_map.dart | 122 ++++++++++++++++++ .../stem/test/unit/core/payload_map_test.dart | 54 ++++++++ 3 files changed, 181 insertions(+) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index e410786f..dd450318 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -132,6 +132,11 @@ `ctx.requiredParamJson(...)`, and shared `valueJson(...)` / `requiredValueJson(...)` helpers so nested DTO payload fields no longer need a separate `PayloadCodec` constant. +- Added `valueVersionedJson(...)`, `requiredValueVersionedJson(...)`, + `valueListVersionedJson(...)`, and + `requiredValueListVersionedJson(...)` to the shared payload-map helpers so + nested versioned DTO payloads no longer need custom per-surface decode + plumbing. - Added `previousJson(...)`, `requiredPreviousJson(...)`, `takeResumeJson(...)`, `waitForEventValueJson(...)`, and `waitForEventJson(...)` so workflow steps and checkpoints can decode prior diff --git a/packages/stem/lib/src/core/payload_map.dart b/packages/stem/lib/src/core/payload_map.dart index 1fbd56a7..fd6b3bb9 100644 --- a/packages/stem/lib/src/core/payload_map.dart +++ b/packages/stem/lib/src/core/payload_map.dart @@ -42,6 +42,25 @@ extension PayloadMapX on Map { ).decode(payload); } + /// Decodes the value for [key] as a typed DTO with a version-aware JSON + /// decoder. + T? valueVersionedJson( + String key, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final payload = this[key]; + if (payload == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(payload); + } + /// Decodes the value for [key] as a typed DTO, or [fallback] when absent. T valueJsonOr( String key, @@ -73,6 +92,47 @@ extension PayloadMapX on Map { ) as T; } + /// Decodes the value for [key] as a version-aware typed DTO, or [fallback] + /// when absent. + T valueVersionedJsonOr( + String key, + T fallback, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return valueVersionedJson( + key, + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ) ?? + fallback; + } + + /// Decodes the value for [key] as a version-aware typed DTO, throwing when + /// absent. + T requiredValueVersionedJson( + String key, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + if (!containsKey(key) || this[key] == null) { + throw StateError("Missing required payload key '$key'."); + } + return valueVersionedJson( + key, + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ) as T; + } + /// Returns the decoded list value for [key], or `null` when it is absent. /// /// When [codec] is supplied, each stored durable payload is decoded through @@ -122,6 +182,27 @@ extension PayloadMapX on Map { return List.unmodifiable(values.map(codec.decode)); } + /// Returns the decoded version-aware DTO list value for [key], or `null` + /// when it is absent. + List? valueListVersionedJson( + String key, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final payload = this[key]; + if (payload == null) return null; + final values = payload as List; + final codec = PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ); + return List.unmodifiable(values.map(codec.decode)); + } + /// Returns the decoded DTO list value for [key], or [fallback] when absent. List valueListJsonOr( String key, @@ -152,4 +233,45 @@ extension PayloadMapX on Map { typeName: typeName, )!; } + + /// Returns the decoded version-aware DTO list value for [key], or + /// [fallback] when absent. + List valueListVersionedJsonOr( + String key, + List fallback, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return valueListVersionedJson( + key, + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ) ?? + fallback; + } + + /// Returns the decoded version-aware DTO list value for [key], throwing when + /// absent. + List requiredValueListVersionedJson( + String key, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + if (!containsKey(key) || this[key] == null) { + throw StateError("Missing required payload key '$key'."); + } + return valueListVersionedJson( + key, + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + )!; + } } diff --git a/packages/stem/test/unit/core/payload_map_test.dart b/packages/stem/test/unit/core/payload_map_test.dart index 1a77dc0b..99467711 100644 --- a/packages/stem/test/unit/core/payload_map_test.dart +++ b/packages/stem/test/unit/core/payload_map_test.dart @@ -58,6 +58,23 @@ void main() { expect(draft?.documentId, 'doc-42'); }); + test('valueVersionedJson decodes DTO values without a codec constant', () { + final payload = { + 'draft': const { + PayloadCodec.versionKey: 2, + 'documentId': 'doc-42', + }, + }; + + final draft = payload.valueVersionedJson<_ApprovalDraft>( + 'draft', + version: 2, + decode: _ApprovalDraft.fromVersionedJson, + ); + + expect(draft?.documentId, 'doc-42'); + }); + test('requiredValueJson throws for missing payload keys', () { const payload = {'name': 'Stem'}; @@ -144,6 +161,35 @@ void main() { ); }); + test( + 'valueListVersionedJson decodes DTO lists without a codec constant', + () { + final payload = { + 'drafts': const [ + { + PayloadCodec.versionKey: 2, + 'documentId': 'doc-42', + }, + { + PayloadCodec.versionKey: 2, + 'documentId': 'doc-99', + }, + ], + }; + + final drafts = payload.valueListVersionedJson<_ApprovalDraft>( + 'drafts', + version: 2, + decode: _ApprovalDraft.fromVersionedJson, + ); + + expect( + drafts?.map((draft) => draft.documentId).toList(), + ['doc-42', 'doc-99'], + ); + }, + ); + test('requiredValueListJson throws for missing payload keys', () { const payload = {'name': 'Stem'}; @@ -175,6 +221,14 @@ class _ApprovalDraft { return _ApprovalDraft(documentId: json['documentId'] as String); } + factory _ApprovalDraft.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _ApprovalDraft(documentId: json['documentId'] as String); + } + final String documentId; Map toJson() => {'documentId': documentId}; From 5a2acde0da793c330126f96971a85b3287eb69a2 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 12:26:04 -0500 Subject: [PATCH 228/302] Add versioned nested context readers --- .../workflows/context-and-serialization.md | 10 +- packages/stem/CHANGELOG.md | 4 + packages/stem/README.md | 5 + packages/stem/lib/src/core/contracts.dart | 109 +++++++++++++++++ .../core/workflow_execution_context.dart | 112 ++++++++++++++++++ .../core/workflow_script_context.dart | 112 ++++++++++++++++++ .../test/unit/core/task_invocation_test.dart | 19 ++- .../unit/workflow/workflow_resume_test.dart | 38 +++++- 8 files changed, 403 insertions(+), 6 deletions(-) diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index d0fa9a6d..c8b5ba0c 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -38,10 +38,12 @@ Depending on the context type, you can access: params - `paramsAs(codec: ...)`, `paramsJson()`, or `paramsVersionedJson()` for decoding the full workflow start payload as one DTO -- `paramJson()` / `requiredParamJson()` for nested DTO params without a +- `paramJson()`, `paramVersionedJson()`, or + `requiredParamJson()` for nested DTO params without a separate codec + constant +- `paramListJson()`, `paramListVersionedJson()`, or + `requiredParamListJson()` for lists of nested DTO params without a separate codec constant -- `paramListJson()` / `requiredParamListJson()` for lists of nested DTO - params without a separate codec constant - `previousValue()` / `requiredPreviousValue()` for typed access to the prior step or checkpoint result - `previousJson()` / `requiredPreviousJson()` for prior DTO results @@ -70,6 +72,8 @@ Depending on the context type, you can access: `TaskExecutionContext` both implement `TaskEnqueuer` - `argsAs(codec: ...)`, `argsJson()`, or `argsVersionedJson()` for decoding the full task-arg payload as one DTO inside manual task handlers +- `argJson()`, `argVersionedJson()`, `argListJson()`, or + `argListVersionedJson()` when only one nested arg entry needs DTO decode - task metadata like `id`, `attempt`, `meta` Child workflow starts belong in durable boundaries: diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index dd450318..f379ea32 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -7,6 +7,10 @@ `paramsJson(...)`, `paramsVersionedJson(...)`, and the same full-payload helpers on `WorkflowScriptContext`, so manual task/workflow code can decode an entire DTO input without field-by-field map plumbing. +- Added `argVersionedJson(...)`, `argListVersionedJson(...)`, + `paramVersionedJson(...)`, and `paramListVersionedJson(...)` on the shared + task/workflow context helpers so nested versioned DTO fields no longer + require dropping down to raw payload-map helpers. - Added `PayloadCodec.versionedJson(...)` so DTO payload codecs can persist a schema version beside the JSON payload and decode older shapes explicitly. - Added versioned low-level DTO shortcuts: diff --git a/packages/stem/README.md b/packages/stem/README.md index bd8bebcb..9366cefd 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -247,6 +247,11 @@ final versionedParams = ctx.paramsVersionedJson( version: 2, decode: ApprovalDraft.fromVersionedJson, ); +final nestedDraft = ctx.paramVersionedJson( + 'draft', + version: 2, + decode: ApprovalDraft.fromVersionedJson, +); ``` For typed task calls, the definition and call objects now expose the common diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index e795fc4c..9bafc330 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -1847,6 +1847,60 @@ extension TaskInputContextArgs on TaskInputContext { ); } + /// Returns the decoded version-aware task arg DTO for [key], or `null`. + T? argVersionedJson( + String key, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return args.valueVersionedJson( + key, + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ); + } + + /// Returns the decoded version-aware task arg DTO for [key], or [fallback]. + T argVersionedJsonOr( + String key, + T fallback, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return args.valueVersionedJsonOr( + key, + fallback, + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ); + } + + /// Returns the decoded version-aware task arg DTO for [key], throwing when + /// absent. + T requiredArgVersionedJson( + String key, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return args.requiredValueVersionedJson( + key, + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ); + } + /// Returns the decoded task arg DTO list for [key], or `null`. List? argListJson( String key, { @@ -1887,6 +1941,61 @@ extension TaskInputContextArgs on TaskInputContext { typeName: typeName, ); } + + /// Returns the decoded version-aware task arg DTO list for [key], or `null`. + List? argListVersionedJson( + String key, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return args.valueListVersionedJson( + key, + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ); + } + + /// Returns the decoded version-aware task arg DTO list for [key], or + /// [fallback]. + List argListVersionedJsonOr( + String key, + List fallback, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return args.valueListVersionedJsonOr( + key, + fallback, + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ); + } + + /// Returns the decoded version-aware task arg DTO list for [key], throwing + /// when absent. + List requiredArgListVersionedJson( + String key, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return args.requiredValueListVersionedJson( + key, + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ); + } } /// Shared execution surface for task handlers and isolate entrypoints. diff --git a/packages/stem/lib/src/workflow/core/workflow_execution_context.dart b/packages/stem/lib/src/workflow/core/workflow_execution_context.dart index 0f453f08..6ae2b0d1 100644 --- a/packages/stem/lib/src/workflow/core/workflow_execution_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_execution_context.dart @@ -133,6 +133,62 @@ extension WorkflowExecutionContextParams on WorkflowExecutionContext { ); } + /// Returns the decoded version-aware workflow parameter DTO for [key], or + /// `null`. + T? paramVersionedJson( + String key, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return params.valueVersionedJson( + key, + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ); + } + + /// Returns the decoded version-aware workflow parameter DTO for [key], or + /// [fallback]. + T paramVersionedJsonOr( + String key, + T fallback, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return params.valueVersionedJsonOr( + key, + fallback, + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ); + } + + /// Returns the decoded version-aware workflow parameter DTO for [key], + /// throwing when absent. + T requiredParamVersionedJson( + String key, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return params.requiredValueVersionedJson( + key, + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ); + } + /// Returns the decoded workflow parameter DTO list for [key], or `null`. List? paramListJson( String key, { @@ -174,6 +230,62 @@ extension WorkflowExecutionContextParams on WorkflowExecutionContext { typeName: typeName, ); } + + /// Returns the decoded version-aware workflow parameter DTO list for [key], + /// or `null`. + List? paramListVersionedJson( + String key, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return params.valueListVersionedJson( + key, + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ); + } + + /// Returns the decoded version-aware workflow parameter DTO list for [key], + /// or [fallback]. + List paramListVersionedJsonOr( + String key, + List fallback, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return params.valueListVersionedJsonOr( + key, + fallback, + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ); + } + + /// Returns the decoded version-aware workflow parameter DTO list for [key], + /// throwing when absent. + List requiredParamListVersionedJson( + String key, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return params.requiredValueListVersionedJson( + key, + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ); + } } /// Typed read helpers for prior workflow step and checkpoint values. diff --git a/packages/stem/lib/src/workflow/core/workflow_script_context.dart b/packages/stem/lib/src/workflow/core/workflow_script_context.dart index 3bef18db..10729032 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script_context.dart @@ -152,6 +152,62 @@ extension WorkflowScriptContextParams on WorkflowScriptContext { ); } + /// Returns the decoded version-aware workflow parameter DTO for [key], or + /// `null`. + T? paramVersionedJson( + String key, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return params.valueVersionedJson( + key, + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ); + } + + /// Returns the decoded version-aware workflow parameter DTO for [key], or + /// [fallback]. + T paramVersionedJsonOr( + String key, + T fallback, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return params.valueVersionedJsonOr( + key, + fallback, + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ); + } + + /// Returns the decoded version-aware workflow parameter DTO for [key], + /// throwing when absent. + T requiredParamVersionedJson( + String key, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return params.requiredValueVersionedJson( + key, + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ); + } + /// Returns the decoded workflow parameter DTO list for [key], or `null`. List? paramListJson( String key, { @@ -193,6 +249,62 @@ extension WorkflowScriptContextParams on WorkflowScriptContext { typeName: typeName, ); } + + /// Returns the decoded version-aware workflow parameter DTO list for [key], + /// or `null`. + List? paramListVersionedJson( + String key, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return params.valueListVersionedJson( + key, + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ); + } + + /// Returns the decoded version-aware workflow parameter DTO list for [key], + /// or [fallback]. + List paramListVersionedJsonOr( + String key, + List fallback, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return params.valueListVersionedJsonOr( + key, + fallback, + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ); + } + + /// Returns the decoded version-aware workflow parameter DTO list for [key], + /// throwing when absent. + List requiredParamListVersionedJson( + String key, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return params.requiredValueListVersionedJson( + key, + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ); + } } /// Context provided to each script checkpoint invocation. Mirrors diff --git a/packages/stem/test/unit/core/task_invocation_test.dart b/packages/stem/test/unit/core/task_invocation_test.dart index f953d421..13ec41e9 100644 --- a/packages/stem/test/unit/core/task_invocation_test.dart +++ b/packages/stem/test/unit/core/task_invocation_test.dart @@ -148,7 +148,14 @@ void main() { test('TaskExecutionContext decodes whole task arg DTOs', () { final TaskExecutionContext context = TaskInvocationContext.local( id: 'task-1a', - args: const {'stage': 'warming'}, + args: const { + PayloadCodec.versionKey: 2, + 'stage': 'warming', + 'update': { + PayloadCodec.versionKey: 2, + 'stage': 'warming', + }, + }, headers: const {}, meta: const {}, attempt: 0, @@ -174,6 +181,16 @@ void main() { .stage, 'warming', ); + expect( + context + .argVersionedJson<_ProgressUpdate>( + 'update', + version: 2, + decode: _ProgressUpdate.fromVersionedJson, + ) + ?.stage, + 'warming', + ); }); test( diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart index 80ac3faa..6f5dc45e 100644 --- a/packages/stem/test/unit/workflow/workflow_resume_test.dart +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -140,12 +140,26 @@ void main() { workflow: 'demo', runId: 'run-1', stepName: 'draft', - params: const {'message': 'approved'}, + params: const { + PayloadCodec.versionKey: 2, + 'message': 'approved', + 'payload': { + PayloadCodec.versionKey: 2, + 'message': 'approved', + }, + }, previousResult: null, stepIndex: 0, ); final scriptContext = _FakeWorkflowScriptContext( - params: const {'message': 'queued'}, + params: const { + PayloadCodec.versionKey: 2, + 'message': 'queued', + 'payload': { + PayloadCodec.versionKey: 2, + 'message': 'queued', + }, + }, ); expect( @@ -165,6 +179,16 @@ void main() { .message, 'approved', ); + expect( + flowContext + .paramVersionedJson<_ResumePayload>( + 'payload', + version: 2, + decode: _ResumePayload.fromVersionedJson, + ) + ?.message, + 'approved', + ); expect( scriptContext .paramsAs<_ResumePayload>(codec: _resumePayloadCodec) @@ -180,6 +204,16 @@ void main() { .message, 'queued', ); + expect( + scriptContext + .paramVersionedJson<_ResumePayload>( + 'payload', + version: 2, + decode: _ResumePayload.fromVersionedJson, + ) + ?.message, + 'queued', + ); }, ); From 993c18b3945ebe1a276c4b810c5ee832a6235764 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 12:29:12 -0500 Subject: [PATCH 229/302] Add versioned suspension payload helpers --- .../workflows/context-and-serialization.md | 3 +- .../docs/workflows/suspensions-and-events.md | 3 +- packages/stem/CHANGELOG.md | 3 +- packages/stem/README.md | 5 ++- .../lib/src/workflow/core/flow_context.dart | 40 +++++++++++++++++++ .../stem/lib/src/workflow/core/flow_step.dart | 37 +++++++++++++++++ .../core/workflow_script_context.dart | 40 +++++++++++++++++++ .../test/unit/workflow/flow_context_test.dart | 20 ++++++++++ .../test/unit/workflow/flow_step_test.dart | 20 ++++++++++ .../unit/workflow/workflow_resume_test.dart | 25 ++++++++++-- 10 files changed, 187 insertions(+), 9 deletions(-) diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index c8b5ba0c..78ad171f 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -54,7 +54,8 @@ Depending on the context type, you can access: constant - `event.awaitOn(step)` when a flow deliberately wants the lower-level `FlowStepControl` suspend-first path on a typed event ref -- `sleepJson(...)`, `awaitEventJson(...)`, and `FlowStepControl.awaitTopicJson(...)` +- `sleepJson(...)`, `sleepVersionedJson(...)`, `awaitEventJson(...)`, + `awaitEventVersionedJson(...)`, and `FlowStepControl.awaitTopicJson(...)` when lower-level suspension directives still need DTO metadata without a separate codec constant - `control.dataJson(...)`, `control.dataVersionedJson(...)`, or diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index 9fee8ef1..00ba15cd 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -75,7 +75,8 @@ Pair that with `await event.wait(ctx)`. If you are writing a flow and deliberately want the lower-level `FlowStepControl` path, use `event.awaitOn(step)` instead of dropping back to a raw topic string. For low-level sleep/event directives that still need DTO metadata, use -`step.sleepJson(...)`, `step.awaitEventJson(...)`, or +`step.sleepJson(...)`, `step.sleepVersionedJson(...)`, +`step.awaitEventJson(...)`, `step.awaitEventVersionedJson(...)`, or `FlowStepControl.awaitTopicJson(...)` instead of hand-built maps. ## Inspect waiting runs diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index f379ea32..b5264d6d 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -152,7 +152,8 @@ - Added `TaskContext.progressJson(...)` and `TaskInvocationContext.progressJson(...)` so task progress updates can emit DTO payloads without hand-built maps. -- Added `sleepJson(...)`, `awaitEventJson(...)`, and +- Added `sleepJson(...)`, `sleepVersionedJson(...)`, + `awaitEventJson(...)`, `awaitEventVersionedJson(...)`, and `FlowStepControl.awaitTopicJson(...)` so lower-level flow/script suspension directives can carry DTO metadata without hand-built maps. - Added `valueList()`, `valueListOr(...)`, and `requiredValueList(...)` to diff --git a/packages/stem/README.md b/packages/stem/README.md index 9366cefd..70dfc132 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -951,8 +951,9 @@ Workflow introspection events expose matching helpers via `event.resultJson(...)`, `event.resultVersionedJson(...)`, and `event.resultAs(codec: ...)`. For lower-level suspension directives, prefer `step.sleepJson(...)`, -`step.awaitEventJson(...)`, and `FlowStepControl.awaitTopicJson(...)` over -hand-built maps. +`step.sleepVersionedJson(...)`, `step.awaitEventJson(...)`, +`step.awaitEventVersionedJson(...)`, and +`FlowStepControl.awaitTopicJson(...)` over hand-built maps. Task lifecycle signals expose matching result helpers on `TaskPostrunPayload` and `TaskSuccessPayload` via `payload.resultJson(...)`, `payload.resultVersionedJson(...)`, and diff --git a/packages/stem/lib/src/workflow/core/flow_context.dart b/packages/stem/lib/src/workflow/core/flow_context.dart index e718d512..b39266ca 100644 --- a/packages/stem/lib/src/workflow/core/flow_context.dart +++ b/packages/stem/lib/src/workflow/core/flow_context.dart @@ -117,6 +117,25 @@ class FlowContext implements WorkflowExecutionContext { ); } + /// Suspends the workflow for [duration] with a versioned DTO payload. + FlowStepControl sleepVersionedJson( + Duration duration, + T value, { + required int version, + String? typeName, + }) { + return sleep( + duration, + data: Map.from( + PayloadCodec.encodeVersionedJsonMap( + value, + version: version, + typeName: typeName, + ), + ), + ); + } + /// Suspends the workflow until an event with [topic] is emitted. /// /// When the event bus resumes the run, the payload is made available via @@ -151,6 +170,27 @@ class FlowContext implements WorkflowExecutionContext { ); } + /// Suspends the workflow until [topic] arrives with a versioned DTO payload. + FlowStepControl awaitEventVersionedJson( + String topic, + T value, { + required int version, + DateTime? deadline, + String? typeName, + }) { + return awaitEvent( + topic, + deadline: deadline, + data: Map.from( + PayloadCodec.encodeVersionedJsonMap( + value, + version: version, + typeName: typeName, + ), + ), + ); + } + @override void suspendFor(Duration duration, {Map? data}) { sleep(duration, data: data); diff --git a/packages/stem/lib/src/workflow/core/flow_step.dart b/packages/stem/lib/src/workflow/core/flow_step.dart index 24420422..d7194624 100644 --- a/packages/stem/lib/src/workflow/core/flow_step.dart +++ b/packages/stem/lib/src/workflow/core/flow_step.dart @@ -194,6 +194,23 @@ class FlowStepControl { ), ); + /// Suspend the run until [duration] elapses with a versioned DTO payload. + static FlowStepControl sleepVersionedJson( + Duration duration, + T value, { + required int version, + String? typeName, + }) => FlowStepControl.sleep( + duration, + data: Map.from( + PayloadCodec.encodeVersionedJsonMap( + value, + version: version, + typeName: typeName, + ), + ), + ); + /// Suspend the run until an event with [topic] arrives with a DTO payload. static FlowStepControl awaitTopicJson( String topic, @@ -208,6 +225,26 @@ class FlowStepControl { ), ); + /// Suspend the run until an event with [topic] arrives with a versioned DTO + /// payload. + static FlowStepControl awaitTopicVersionedJson( + String topic, + T value, { + required int version, + DateTime? deadline, + String? typeName, + }) => FlowStepControl.awaitTopic( + topic, + deadline: deadline, + data: Map.from( + PayloadCodec.encodeVersionedJsonMap( + value, + version: version, + typeName: typeName, + ), + ), + ); + /// Control type emitted by the step. final FlowControlType type; diff --git a/packages/stem/lib/src/workflow/core/workflow_script_context.dart b/packages/stem/lib/src/workflow/core/workflow_script_context.dart index 10729032..6fe28d98 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script_context.dart @@ -44,6 +44,25 @@ extension WorkflowScriptStepSuspensionJson on WorkflowScriptStepContext { ); } + /// Suspends the workflow for [duration] with a versioned DTO payload. + Future sleepVersionedJson( + Duration duration, + T value, { + required int version, + String? typeName, + }) { + return sleep( + duration, + data: Map.from( + PayloadCodec.encodeVersionedJsonMap( + value, + version: version, + typeName: typeName, + ), + ), + ); + } + /// Suspends the workflow until [topic] arrives with a DTO payload. Future awaitEventJson( String topic, @@ -59,6 +78,27 @@ extension WorkflowScriptStepSuspensionJson on WorkflowScriptStepContext { ), ); } + + /// Suspends the workflow until [topic] arrives with a versioned DTO payload. + Future awaitEventVersionedJson( + String topic, + T value, { + required int version, + DateTime? deadline, + String? typeName, + }) { + return awaitEvent( + topic, + deadline: deadline, + data: Map.from( + PayloadCodec.encodeVersionedJsonMap( + value, + version: version, + typeName: typeName, + ), + ), + ); + } } /// Typed read helpers for workflow start parameters in script run methods. diff --git a/packages/stem/test/unit/workflow/flow_context_test.dart b/packages/stem/test/unit/workflow/flow_context_test.dart index d2070ca5..621c10f3 100644 --- a/packages/stem/test/unit/workflow/flow_context_test.dart +++ b/packages/stem/test/unit/workflow/flow_context_test.dart @@ -1,3 +1,4 @@ +import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/workflow/core/flow_context.dart'; import 'package:stem/src/workflow/core/flow_step.dart'; import 'package:stem/src/workflow/core/workflow_clock.dart'; @@ -70,10 +71,29 @@ void main() { const _SuspensionPayload(stage: 'waiting'), deadline: DateTime.parse('2025-01-01T00:00:00Z'), ); + final versionedSleep = context.sleepVersionedJson( + const Duration(seconds: 4), + const _SuspensionPayload(stage: 'versioned-sleep'), + version: 2, + ); + final versionedWait = context.awaitEventVersionedJson( + 'topic.versioned', + const _SuspensionPayload(stage: 'versioned-wait'), + version: 2, + deadline: DateTime.parse('2025-01-01T00:00:01Z'), + ); expect(sleep.data, equals(const {'stage': 'sleeping'})); expect(wait.data, equals(const {'stage': 'waiting'})); expect(wait.deadline, DateTime.parse('2025-01-01T00:00:00Z')); + expect(versionedSleep.data, { + PayloadCodec.versionKey: 2, + 'stage': 'versioned-sleep', + }); + expect(versionedWait.data, { + PayloadCodec.versionKey: 2, + 'stage': 'versioned-wait', + }); }); test( diff --git a/packages/stem/test/unit/workflow/flow_step_test.dart b/packages/stem/test/unit/workflow/flow_step_test.dart index 742fee7e..729d0f38 100644 --- a/packages/stem/test/unit/workflow/flow_step_test.dart +++ b/packages/stem/test/unit/workflow/flow_step_test.dart @@ -1,3 +1,4 @@ +import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/workflow/core/flow_step.dart'; import 'package:test/test.dart'; @@ -34,6 +35,17 @@ void main() { const _SuspensionPayload(stage: 'waiting'), deadline: DateTime.parse('2025-01-01T00:00:00Z'), ); + final versionedSleep = FlowStepControl.sleepVersionedJson( + const Duration(seconds: 6), + const _SuspensionPayload(stage: 'versioned-sleep'), + version: 2, + ); + final versionedWait = FlowStepControl.awaitTopicVersionedJson( + 'versioned-topic', + const _SuspensionPayload(stage: 'versioned-wait'), + version: 2, + deadline: DateTime.parse('2025-01-01T00:00:01Z'), + ); expect(sleep.data, equals(const {'stage': 'sleeping'})); expect( @@ -57,6 +69,14 @@ void main() { ); expect(wait.data, equals(const {'stage': 'waiting'})); expect(wait.deadline, DateTime.parse('2025-01-01T00:00:00Z')); + expect(versionedSleep.data, { + PayloadCodec.versionKey: 2, + 'stage': 'versioned-sleep', + }); + expect(versionedWait.data, { + PayloadCodec.versionKey: 2, + 'stage': 'versioned-wait', + }); }); } diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart index 6f5dc45e..1d10c27b 100644 --- a/packages/stem/test/unit/workflow/workflow_resume_test.dart +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -603,11 +603,28 @@ void main() { const _SuspensionPayload(stage: 'waiting'), deadline: DateTime.parse('2025-01-01T00:00:00Z'), ); + await context.sleepVersionedJson( + const Duration(seconds: 3), + const _SuspensionPayload(stage: 'versioned-sleep'), + version: 2, + ); + await context.awaitEventVersionedJson( + 'topic.versioned', + const _SuspensionPayload(stage: 'versioned-wait'), + version: 2, + deadline: DateTime.parse('2025-01-01T00:00:01Z'), + ); - expect(context.sleepCalls, equals([const Duration(seconds: 2)])); - expect(context.awaitedTopics, equals(['topic'])); - expect(context.awaitedData, equals(const {'stage': 'waiting'})); - expect(context.awaitedDeadline, DateTime.parse('2025-01-01T00:00:00Z')); + expect( + context.sleepCalls, + equals([const Duration(seconds: 2), const Duration(seconds: 3)]), + ); + expect(context.awaitedTopics, equals(['topic', 'topic.versioned'])); + expect(context.awaitedData, { + PayloadCodec.versionKey: 2, + 'stage': 'versioned-wait', + }); + expect(context.awaitedDeadline, DateTime.parse('2025-01-01T00:00:01Z')); }, ); From 7692a0be6bb6e75d8cb014f639724a8a833266e1 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 12:35:17 -0500 Subject: [PATCH 230/302] Add versioned low-level wait decode helpers --- .site/docs/core-concepts/tasks.md | 3 +- .site/docs/workflows/starting-and-waiting.md | 3 +- packages/stem/CHANGELOG.md | 5 ++ packages/stem/README.md | 9 ++-- packages/stem/lib/src/bootstrap/stem_app.dart | 3 ++ .../stem/lib/src/bootstrap/stem_client.dart | 3 ++ .../stem/lib/src/bootstrap/workflow_app.dart | 28 +++++++++-- packages/stem/lib/src/core/payload_codec.dart | 13 +++++ packages/stem/lib/src/core/stem.dart | 31 ++++++++++-- .../workflow/runtime/workflow_runtime.dart | 25 ++++++++-- .../stem/test/bootstrap/stem_app_test.dart | 47 ++++++++++++++++++- .../stem/test/unit/core/stem_core_test.dart | 38 ++++++++++++++- 12 files changed, 190 insertions(+), 18 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index ba47129b..ec9222d7 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -54,7 +54,8 @@ lets you deserialize complex objects before they reach application code. Use `result.requiredValue()` when a completed task must have a decoded value and you want a fail-fast read instead of manual nullable handling. For low-level DTO waits through `Stem.waitForTask`, prefer -`decodeJson:` over a manual raw-payload cast. +`decodeJson:` for plain DTOs or `decodeVersionedJson:` when the stored payload +persists an explicit schema version. If you already have a raw `TaskStatus`, use `status.payloadJson(...)` or `status.payloadAs(codec: ...)` to decode the whole payload DTO without a separate cast/closure. Use `status.payloadVersionedJson(...)` when the stored diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index b718810d..c1895851 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -92,7 +92,8 @@ like `ordersFlow.startAndWait(...)` and `waitForCompletion` is the low-level completion API for name-based runs. It polls the store until the run finishes or the caller times out. For DTO -results, prefer `decodeJson:` over a manual raw-payload cast. +results, prefer `decodeJson:` for plain DTOs or `decodeVersionedJson:` when +the persisted payload carries an explicit schema version. If you already have a raw `WorkflowResult`, use `result.payloadJson(...)` or `result.payloadAs(codec: ...)` to decode the stored workflow result without another cast/closure. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index b5264d6d..864dab5e 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -20,6 +20,11 @@ `WorkflowRuntime.emitVersionedJson(...)`, `StemWorkflowApp.emitVersionedJson(...)`, and `QueueEventsProducer.emitVersionedJson(...)`. +- Added `decodeVersionedJson:` to the low-level + `Stem.waitForTask`, `StemApp.waitForTask`, + `StemClient.waitForTask`, `StemWorkflowApp.waitForCompletion`, and + `WorkflowRuntime.waitForCompletion` APIs so schema-versioned DTO waits no + longer need a manual raw-payload closure. - Added direct `versionedJson(...)` shortcuts for manual task definitions, workflow refs, and workflow event refs so evolving DTO payloads do not need a separate `PayloadCodec.versionedJson(...)` constant in the common case. diff --git a/packages/stem/README.md b/packages/stem/README.md index 70dfc132..9d47b01d 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -913,9 +913,10 @@ print(result.requiredValue().total); ``` `StemWorkflowApp.waitForCompletion` is the low-level completion API for -name-based runs. It accepts either `decode:` or the shorter `decodeJson:` -shortcut for DTOs and exposes the decoded value along with the raw `RunState`, -letting you work with domain models without manual casts: +name-based runs. It accepts `decode:`, the shorter `decodeJson:` shortcut for +plain DTOs, or `decodeVersionedJson:` for schema-versioned DTOs, and exposes +the decoded value along with the raw `RunState`, letting you work with domain +models without manual casts: ```dart final runId = await app.startWorkflow('orders.workflow'); @@ -987,7 +988,7 @@ Producers can now wait for individual task results using either `Stem.waitForTask` with optional decoders. These helpers return a `TaskResult` containing the underlying `TaskStatus`, decoded payload, and a timeout flag. For low-level DTO waits, `Stem.waitForTask` also accepts -`decodeJson:`: +`decodeJson:` and `decodeVersionedJson:`: ```dart final charge = await ChargeCustomer.definition.enqueueAndWait( diff --git a/packages/stem/lib/src/bootstrap/stem_app.dart b/packages/stem/lib/src/bootstrap/stem_app.dart index 2994c1b7..d8cb329e 100644 --- a/packages/stem/lib/src/bootstrap/stem_app.dart +++ b/packages/stem/lib/src/bootstrap/stem_app.dart @@ -143,6 +143,8 @@ class StemApp implements StemTaskApp { Duration? timeout, TResult Function(Object? payload)? decode, TResult Function(Map payload)? decodeJson, + TResult Function(Map payload, int version)? + decodeVersionedJson, }) async { await _ensureStarted(); return stem.waitForTask( @@ -150,6 +152,7 @@ class StemApp implements StemTaskApp { timeout: timeout, decode: decode, decodeJson: decodeJson, + decodeVersionedJson: decodeVersionedJson, ); } diff --git a/packages/stem/lib/src/bootstrap/stem_client.dart b/packages/stem/lib/src/bootstrap/stem_client.dart index 6ffbd343..8c63d7bf 100644 --- a/packages/stem/lib/src/bootstrap/stem_client.dart +++ b/packages/stem/lib/src/bootstrap/stem_client.dart @@ -201,12 +201,15 @@ abstract class StemClient implements TaskResultCaller { Duration? timeout, TResult Function(Object? payload)? decode, TResult Function(Map payload)? decodeJson, + TResult Function(Map payload, int version)? + decodeVersionedJson, }) { return stem.waitForTask( taskId, timeout: timeout, decode: decode, decodeJson: decodeJson, + decodeVersionedJson: decodeVersionedJson, ); } diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index d5d81ad2..8fab87d9 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -132,12 +132,15 @@ class StemWorkflowApp Duration? timeout, TResult Function(Object? payload)? decode, TResult Function(Map payload)? decodeJson, + TResult Function(Map payload, int version)? + decodeVersionedJson, }) { return app.waitForTask( taskId, timeout: timeout, decode: decode, decodeJson: decodeJson, + decodeVersionedJson: decodeVersionedJson, ); } @@ -506,10 +509,14 @@ class StemWorkflowApp Duration? timeout, T Function(Object? payload)? decode, T Function(Map payload)? decodeJson, + T Function(Map payload, int version)? decodeVersionedJson, }) async { assert( - decode == null || decodeJson == null, - 'Specify either decode or decodeJson, not both.', + [decode, decodeJson, decodeVersionedJson] + .whereType() + .length <= + 1, + 'Specify at most one of decode, decodeJson, or decodeVersionedJson.', ); final startedAt = stemNow(); while (true) { @@ -522,6 +529,7 @@ class StemWorkflowApp state, decode, decodeJson: decodeJson, + decodeVersionedJson: decodeVersionedJson, timedOut: false, ); } @@ -530,6 +538,7 @@ class StemWorkflowApp state, decode, decodeJson: decodeJson, + decodeVersionedJson: decodeVersionedJson, timedOut: true, ); } @@ -559,9 +568,15 @@ class StemWorkflowApp T Function(Object? payload)? decode, { required bool timedOut, T Function(Map payload)? decodeJson, + T Function(Map payload, int version)? decodeVersionedJson, }) { final value = state.status == WorkflowStatus.completed - ? _decodeResult(state.result, decode, decodeJson) + ? _decodeResult( + state.result, + decode, + decodeJson, + decodeVersionedJson, + ) : null; return WorkflowResult( runId: state.id, @@ -577,10 +592,17 @@ class StemWorkflowApp Object? payload, T Function(Object? payload)? decode, T Function(Map payload)? decodeJson, + T Function(Map payload, int version)? decodeVersionedJson, ) { if (decode != null) { return decode(payload); } + if (decodeVersionedJson != null) { + return decodeVersionedJson( + PayloadCodec.decodeJsonMap(payload, typeName: 'workflow result'), + PayloadCodec.readPayloadVersion(payload), + ); + } if (decodeJson != null) { return decodeJson( PayloadCodec.decodeJsonMap(payload, typeName: 'workflow result'), diff --git a/packages/stem/lib/src/core/payload_codec.dart b/packages/stem/lib/src/core/payload_codec.dart index 027e5022..569d8b86 100644 --- a/packages/stem/lib/src/core/payload_codec.dart +++ b/packages/stem/lib/src/core/payload_codec.dart @@ -130,6 +130,19 @@ class PayloadCodec { return _payloadJsonMap(payload, typeName); } + /// Reads the persisted schema version from a durable JSON payload. + static int readPayloadVersion( + Object? payload, { + int defaultVersion = 1, + String typeName = 'payload', + }) { + return _payloadVersion( + _payloadJsonMap(payload, typeName), + defaultVersion: defaultVersion, + typeName: typeName, + ); + } + /// Converts a typed value into a durable payload representation. Object? encode(T value) { final encoded = _encode(value); diff --git a/packages/stem/lib/src/core/stem.dart b/packages/stem/lib/src/core/stem.dart index 995a1576..de57be75 100644 --- a/packages/stem/lib/src/core/stem.dart +++ b/packages/stem/lib/src/core/stem.dart @@ -89,6 +89,8 @@ abstract interface class TaskResultCaller implements TaskEnqueuer { Duration? timeout, TResult Function(Object? payload)? decode, TResult Function(Map payload)? decodeJson, + TResult Function(Map payload, int version)? + decodeVersionedJson, }); /// Waits for [taskId] using a typed [definition] for result decoding. @@ -522,10 +524,14 @@ class Stem implements TaskResultCaller { Duration? timeout, T Function(Object? payload)? decode, T Function(Map payload)? decodeJson, + T Function(Map payload, int version)? decodeVersionedJson, }) async { assert( - decode == null || decodeJson == null, - 'Specify either decode or decodeJson, not both.', + [decode, decodeJson, decodeVersionedJson] + .whereType() + .length <= + 1, + 'Specify at most one of decode, decodeJson, or decodeVersionedJson.', ); final resultBackend = backend; if (resultBackend == null) { @@ -539,7 +545,12 @@ class Stem implements TaskResultCaller { taskId: taskId, status: lastStatus, value: lastStatus.state == TaskState.succeeded - ? _decodeTaskPayload(lastStatus.payload, decode, decodeJson) + ? _decodeTaskPayload( + lastStatus.payload, + decode, + decodeJson, + decodeVersionedJson, + ) : null, rawPayload: lastStatus.payload, ); @@ -562,7 +573,12 @@ class Stem implements TaskResultCaller { taskId: taskId, status: status, value: status.state == TaskState.succeeded - ? _decodeTaskPayload(status.payload, decode, decodeJson) + ? _decodeTaskPayload( + status.payload, + decode, + decodeJson, + decodeVersionedJson, + ) : null, rawPayload: status.payload, timedOut: timedOut && !status.state.isTerminal, @@ -1087,11 +1103,18 @@ class Stem implements TaskResultCaller { Object? payload, T Function(Object? payload)? decode, T Function(Map payload)? decodeJson, + T Function(Map payload, int version)? decodeVersionedJson, ) { if (payload == null) return null; if (decode != null) { return decode(payload); } + if (decodeVersionedJson != null) { + return decodeVersionedJson( + PayloadCodec.decodeJsonMap(payload, typeName: 'task result'), + PayloadCodec.readPayloadVersion(payload), + ); + } if (decodeJson != null) { return decodeJson( PayloadCodec.decodeJsonMap(payload, typeName: 'task result'), diff --git a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart index 9a0bb073..1e21c2f2 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart @@ -302,10 +302,14 @@ class WorkflowRuntime implements WorkflowCaller, WorkflowEventEmitter { Duration? timeout, T Function(Object? payload)? decode, T Function(Map payload)? decodeJson, + T Function(Map payload, int version)? decodeVersionedJson, }) async { assert( - decode == null || decodeJson == null, - 'Specify either decode or decodeJson, not both.', + [decode, decodeJson, decodeVersionedJson] + .whereType() + .length <= + 1, + 'Specify at most one of decode, decodeJson, or decodeVersionedJson.', ); final startedAt = _clock.now(); while (true) { @@ -318,6 +322,7 @@ class WorkflowRuntime implements WorkflowCaller, WorkflowEventEmitter { state, decode, decodeJson: decodeJson, + decodeVersionedJson: decodeVersionedJson, timedOut: false, ); } @@ -326,6 +331,7 @@ class WorkflowRuntime implements WorkflowCaller, WorkflowEventEmitter { state, decode, decodeJson: decodeJson, + decodeVersionedJson: decodeVersionedJson, timedOut: true, ); } @@ -429,9 +435,15 @@ class WorkflowRuntime implements WorkflowCaller, WorkflowEventEmitter { T Function(Object? payload)? decode, { required bool timedOut, T Function(Map payload)? decodeJson, + T Function(Map payload, int version)? decodeVersionedJson, }) { final value = state.status == WorkflowStatus.completed - ? _decodeResult(state.result, decode, decodeJson) + ? _decodeResult( + state.result, + decode, + decodeJson, + decodeVersionedJson, + ) : null; return WorkflowResult( runId: state.id, @@ -447,10 +459,17 @@ class WorkflowRuntime implements WorkflowCaller, WorkflowEventEmitter { Object? payload, T Function(Object? payload)? decode, T Function(Map payload)? decodeJson, + T Function(Map payload, int version)? decodeVersionedJson, ) { if (decode != null) { return decode(payload); } + if (decodeVersionedJson != null) { + return decodeVersionedJson( + PayloadCodec.decodeJsonMap(payload, typeName: 'workflow result'), + PayloadCodec.readPayloadVersion(payload), + ); + } if (decodeJson != null) { return decodeJson( PayloadCodec.decodeJsonMap(payload, typeName: 'workflow result'), diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index 175f4995..ba7a8f6f 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -478,6 +478,45 @@ void main() { } }); + test( + 'waitForCompletion decodes versioned custom types on success', + () async { + final flow = Flow>( + name: 'workflow.typed.versioned', + build: (builder) { + builder.step( + 'payload', + (ctx) async => { + PayloadCodec.versionKey: 2, + 'foo': 'bar', + }, + ); + }, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + final runId = await workflowApp.startWorkflow( + 'workflow.typed.versioned', + ); + final run = await workflowApp.waitForCompletion<_DemoPayload>( + runId, + decodeVersionedJson: _DemoPayload.fromVersionedJson, + ); + + expect(run, isNotNull); + expect(run!.value, isA<_DemoPayload>()); + expect(run.value!.foo, 'bar-v2'); + expect(run.state.result, { + PayloadCodec.versionKey: 2, + 'foo': 'bar', + }); + } finally { + await workflowApp.shutdown(); + } + }, + ); + test('startWorkflowJson encodes DTO params without a manual map', () async { final flow = Flow( name: 'workflow.json.start', @@ -508,7 +547,8 @@ void main() { }); test( - 'startWorkflowVersionedJson encodes DTO params with a persisted schema version', + 'startWorkflowVersionedJson encodes DTO params with a persisted ' + 'schema version', () async { final flow = Flow( name: 'workflow.versioned.json.start', @@ -1484,6 +1524,11 @@ class _DemoPayload { factory _DemoPayload.fromJson(Map json) => _DemoPayload(json['foo']! as String); + factory _DemoPayload.fromVersionedJson( + Map json, + int version, + ) => _DemoPayload('${json['foo']! as String}-v$version'); + final String foo; Map toJson() => {'foo': foo}; diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index 90e55680..9393609c 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -158,7 +158,10 @@ void main() { ); expect(id, isNotEmpty); - expect(broker.published.single.envelope.name, 'sample.versioned.json.args'); + expect( + broker.published.single.envelope.name, + 'sample.versioned.json.args', + ); expect(broker.published.single.envelope.queue, 'typed'); expect(broker.published.single.envelope.args, { PayloadCodec.versionKey: 2, @@ -678,6 +681,32 @@ void main() { expect(result?.requiredValue().id, 'receipt-json'); expect(result?.rawPayload, const {'id': 'receipt-json'}); }); + + test('supports decodeVersionedJson for low-level DTO waits', () async { + final backend = InMemoryResultBackend(); + final stem = Stem(broker: _RecordingBroker(), backend: backend); + + await backend.set( + 'task-versioned-json-wait', + TaskState.succeeded, + payload: const { + PayloadCodec.versionKey: 2, + 'id': 'receipt-versioned-json', + }, + ); + + final result = await stem.waitForTask<_CodecReceipt>( + 'task-versioned-json-wait', + decodeVersionedJson: _CodecReceipt.fromVersionedJson, + ); + + expect(result?.isSucceeded, isTrue); + expect(result?.requiredValue().id, 'receipt-versioned-json-v2'); + expect(result?.rawPayload, const { + PayloadCodec.versionKey: 2, + 'id': 'receipt-versioned-json', + }); + }); }); } @@ -707,6 +736,13 @@ class _CodecReceipt { return _CodecReceipt(json['id']! as String); } + factory _CodecReceipt.fromVersionedJson( + Map json, + int version, + ) { + return _CodecReceipt('${json['id']! as String}-v$version'); + } + final String id; Map toJson() => {'id': id}; From 9cceca3c25824c7126820e97d6023a675d88b806 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 12:38:01 -0500 Subject: [PATCH 231/302] Add versioned workflow resume decode helpers --- .../workflows/context-and-serialization.md | 20 ++- packages/stem/CHANGELOG.md | 5 + packages/stem/README.md | 6 + .../core/workflow_execution_context.dart | 55 ++++++ .../src/workflow/core/workflow_resume.dart | 69 ++++++++ .../unit/workflow/workflow_resume_test.dart | 162 ++++++++++++++++++ 6 files changed, 308 insertions(+), 9 deletions(-) diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index 78ad171f..8daec849 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -46,12 +46,14 @@ Depending on the context type, you can access: separate codec constant - `previousValue()` / `requiredPreviousValue()` for typed access to the prior step or checkpoint result -- `previousJson()` / `requiredPreviousJson()` for prior DTO results - without a separate codec constant +- `previousJson()`, `previousVersionedJson()`, + `requiredPreviousJson()`, or `requiredPreviousVersionedJson()` for + prior DTO results without a separate codec constant - `sleepUntilResumed(...)` for common sleep/retry loops - `waitForEventValue(...)` for common event waits -- `waitForEventValueJson(...)` for DTO event waits without a separate codec - constant +- `waitForEventValueJson(...)` or + `waitForEventValueVersionedJson(...)` for DTO event waits without a + separate codec constant - `event.awaitOn(step)` when a flow deliberately wants the lower-level `FlowStepControl` suspend-first path on a typed event ref - `sleepJson(...)`, `sleepVersionedJson(...)`, `awaitEventJson(...)`, @@ -63,8 +65,8 @@ Depending on the context type, you can access: `FlowStepControl` directly - `takeResumeData()` for event-driven resumes - `takeResumeValue(codec: ...)` for typed event-driven resumes -- `takeResumeJson(...)` for DTO event-driven resumes without a separate - codec constant +- `takeResumeJson(...)` or `takeResumeVersionedJson(...)` for DTO + event-driven resumes without a separate codec constant - `idempotencyKey(...)` - direct child-workflow start helpers such as `ref.start(context, params: value)` and @@ -183,9 +185,9 @@ Prefer the higher-level helpers first: continue on resume - `waitForEventValue(...)` when the step/checkpoint is waiting on one event -Drop down to `takeResumeData()`, `takeResumeValue(...)`, or -`takeResumeJson(...)` only when you need custom branching around resume -payloads. +Drop down to `takeResumeData()`, `takeResumeValue(...)`, +`takeResumeJson(...)`, or `takeResumeVersionedJson(...)` only when you +need custom branching around resume payloads. The runnable `annotated_workflows` example demonstrates both the context-aware and plain serializable forms. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 864dab5e..1bb584d2 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -20,6 +20,11 @@ `WorkflowRuntime.emitVersionedJson(...)`, `StemWorkflowApp.emitVersionedJson(...)`, and `QueueEventsProducer.emitVersionedJson(...)`. +- Added versioned workflow resume/result decode helpers: + `WorkflowExecutionContext.previousVersionedJson(...)`, + `WorkflowResumeContext.takeResumeVersionedJson(...)`, + `waitForEventValueVersionedJson(...)`, and + `waitForEventVersionedJson(...)`. - Added `decodeVersionedJson:` to the low-level `Stem.waitForTask`, `StemApp.waitForTask`, `StemClient.waitForTask`, `StemWorkflowApp.waitForCompletion`, and diff --git a/packages/stem/README.md b/packages/stem/README.md index 9d47b01d..d57f7100 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -967,6 +967,12 @@ Workflow lifecycle signals expose matching metadata helpers on Low-level `FlowStepControl` objects expose matching suspension metadata helpers via `control.dataJson(...)`, `control.dataVersionedJson(...)`, and `control.dataAs(codec: ...)`. +Workflow execution contexts expose the same version-aware decode path for +prior step results and resume/event payloads via +`step.requiredPreviousVersionedJson(...)`, +`step.takeResumeVersionedJson(...)`, +`step.waitForEventValueVersionedJson(...)`, and +`step.waitForEventVersionedJson(...)`. In the example above, these calls inside `run(...)`: diff --git a/packages/stem/lib/src/workflow/core/workflow_execution_context.dart b/packages/stem/lib/src/workflow/core/workflow_execution_context.dart index 6ae2b0d1..6176e7ef 100644 --- a/packages/stem/lib/src/workflow/core/workflow_execution_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_execution_context.dart @@ -354,4 +354,59 @@ extension WorkflowExecutionContextValues on WorkflowExecutionContext { } return value; } + + /// Returns the decoded prior step/checkpoint value as a versioned typed DTO, + /// or `null`. + T? previousVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final value = previousResult; + if (value == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(value); + } + + /// Returns the decoded prior step/checkpoint versioned DTO, or [fallback]. + T previousVersionedJsonOr( + T fallback, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return previousVersionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ) ?? + fallback; + } + + /// Returns the decoded prior step/checkpoint versioned DTO, throwing when + /// absent. + T requiredPreviousVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final value = previousVersionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ); + if (value == null) { + throw StateError('WorkflowExecutionContext.previousResult is null.'); + } + return value; + } } diff --git a/packages/stem/lib/src/workflow/core/workflow_resume.dart b/packages/stem/lib/src/workflow/core/workflow_resume.dart index d742684d..2038395c 100644 --- a/packages/stem/lib/src/workflow/core/workflow_resume.dart +++ b/packages/stem/lib/src/workflow/core/workflow_resume.dart @@ -41,6 +41,23 @@ extension WorkflowResumeContextValues on WorkflowResumeContext { ).decode(payload); } + /// Returns the next resume payload as a versioned typed DTO and consumes it. + T? takeResumeVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final payload = takeResumeData(); + if (payload == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(payload); + } + /// Suspends the current step on the first invocation and /// returns `true` once the step resumes. /// @@ -142,6 +159,34 @@ extension WorkflowResumeContextValues on WorkflowResumeContext { return null; } + /// Returns the next event payload as a versioned typed DTO when the step has + /// resumed, or registers an event wait and returns `null` on the first + /// invocation. + T? waitForEventValueVersionedJson( + String topic, { + required int version, + required T Function(Map payload, int version) decode, + DateTime? deadline, + Map? data, + int? defaultDecodeVersion, + String? typeName, + }) { + final payload = takeResumeVersionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ); + if (payload != null) { + return payload; + } + final pending = waitForTopic(topic, deadline: deadline, data: data); + if (pending is Future) { + unawaited(pending); + } + return null; + } + /// Suspends until [topic] is emitted, then returns the resumed payload. Future waitForEvent({ required String topic, @@ -175,6 +220,30 @@ extension WorkflowResumeContextValues on WorkflowResumeContext { await waitForTopic(topic, deadline: deadline, data: data); throw const WorkflowSuspensionSignal(); } + + /// Suspends until [topic] is emitted, then returns the resumed versioned DTO + /// payload. + Future waitForEventVersionedJson({ + required String topic, + required int version, + required T Function(Map payload, int version) decode, + DateTime? deadline, + Map? data, + int? defaultDecodeVersion, + String? typeName, + }) async { + final payload = takeResumeVersionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ); + if (payload != null) { + return payload; + } + await waitForTopic(topic, deadline: deadline, data: data); + throw const WorkflowSuspensionSignal(); + } } /// Direct typed wait helpers on [WorkflowEventRef]. diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart index 1d10c27b..f57e3364 100644 --- a/packages/stem/test/unit/workflow/workflow_resume_test.dart +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -75,6 +75,39 @@ void main() { ); }); + test( + 'FlowContext.takeResumeVersionedJson decodes versioned DTO payloads', + () { + final context = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + resumeData: const { + PayloadCodec.versionKey: 2, + 'message': 'approved', + }, + ); + + final value = context.takeResumeVersionedJson<_ResumePayload>( + version: 2, + decode: _ResumePayload.fromVersionedJson, + ); + + expect(value, isNotNull); + expect(value!.message, 'approved'); + expect( + context.takeResumeVersionedJson<_ResumePayload>( + version: 2, + decode: _ResumePayload.fromVersionedJson, + ), + isNull, + ); + }, + ); + test( 'WorkflowExecutionContext.previousValue reads typed previous results', () { @@ -258,6 +291,30 @@ void main() { }, ); + test( + 'WorkflowExecutionContext.requiredPreviousVersionedJson decodes DTO values', + () { + final flowContext = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'tail', + params: const {}, + previousResult: const { + PayloadCodec.versionKey: 2, + 'message': 'approved', + }, + stepIndex: 1, + ); + + final value = flowContext.requiredPreviousVersionedJson<_ResumePayload>( + version: 2, + decode: _ResumePayload.fromVersionedJson, + ); + + expect(value.message, 'approved'); + }, + ); + test('FlowContext.sleepUntilResumed suspends once then resumes', () { final firstContext = FlowContext( workflow: 'demo', @@ -418,6 +475,58 @@ void main() { }, ); + test( + 'FlowContext.waitForEventValueVersionedJson registers watcher ' + 'then decodes DTO payload', + () { + final firstContext = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + ); + + final firstResult = + firstContext.waitForEventValueVersionedJson<_ResumePayload>( + 'demo.event', + version: 2, + decode: _ResumePayload.fromVersionedJson, + ); + + expect(firstResult, isNull); + final control = firstContext.takeControl(); + expect(control, isNotNull); + expect(control!.type, FlowControlType.waitForEvent); + expect(control.topic, 'demo.event'); + + final resumedContext = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + resumeData: const { + PayloadCodec.versionKey: 2, + 'message': 'approved', + }, + ); + + final resumed = + resumedContext.waitForEventValueVersionedJson<_ResumePayload>( + 'demo.event', + version: 2, + decode: _ResumePayload.fromVersionedJson, + ); + + expect(resumed, isNotNull); + expect(resumed!.message, 'approved'); + expect(resumedContext.takeControl(), isNull); + }, + ); + test('WorkflowEventRef.waitValue reuses topic and codec for flows', () { const event = WorkflowEventRef<_ResumePayload>( topic: 'demo.event', @@ -589,6 +698,59 @@ void main() { }, ); + test( + 'FlowContext.waitForEventVersionedJson uses named args and resumes ' + 'with DTO payload', + () { + final waiting = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + ); + + expect( + () => waiting.waitForEventVersionedJson<_ResumePayload>( + topic: 'demo.event', + version: 2, + decode: _ResumePayload.fromVersionedJson, + ), + throwsA(isA()), + ); + expect(waiting.takeControl()?.topic, 'demo.event'); + + final resumed = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + resumeData: const { + PayloadCodec.versionKey: 2, + 'message': 'approved', + }, + ); + + expect( + resumed.waitForEventVersionedJson<_ResumePayload>( + topic: 'demo.event', + version: 2, + decode: _ResumePayload.fromVersionedJson, + ), + completion( + isA<_ResumePayload>().having( + (value) => value.message, + 'message', + 'approved', + ), + ), + ); + }, + ); + test( 'WorkflowScriptStepContext JSON suspension helpers encode DTO payloads', () async { From 2b93d8ff01a79cf66ac73cebdfbe60caab99e3e6 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 12:39:35 -0500 Subject: [PATCH 232/302] Add versioned task progress helpers --- .site/docs/core-concepts/tasks.md | 2 ++ packages/stem/CHANGELOG.md | 2 ++ packages/stem/lib/src/core/contracts.dart | 19 ++++++++++++ .../test/unit/core/task_invocation_test.dart | 29 +++++++++++++++++++ 4 files changed, 52 insertions(+) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index ec9222d7..c4a33b01 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -138,6 +138,8 @@ every retry signal and shows how the strategy interacts with broker timings. - `context.progress(percent, data: {...})` – emit progress signals for UI hooks. - `context.progressJson(percent, dto)` – emit DTO progress payloads without hand-built maps. +- `context.progressVersionedJson(percent, dto, version: n)` – emit DTO progress + payloads with an explicit persisted schema version. - `context.retry(...)` – request an immediate retry with optional per-call retry policy overrides. - when you inspect a raw `ProgressSignal`, prefer diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 1bb584d2..64d127f0 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -162,6 +162,8 @@ - Added `TaskContext.progressJson(...)` and `TaskInvocationContext.progressJson(...)` so task progress updates can emit DTO payloads without hand-built maps. +- Added `TaskExecutionContext.progressVersionedJson(...)` so task progress + updates can persist an explicit DTO schema version alongside the payload. - Added `sleepJson(...)`, `sleepVersionedJson(...)`, `awaitEventJson(...)`, `awaitEventVersionedJson(...)`, and `FlowStepControl.awaitTopicJson(...)` so lower-level flow/script suspension diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 9bafc330..caffc7b1 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2063,6 +2063,25 @@ extension TaskExecutionContextProgressX on TaskExecutionContext { ), ); } + + /// Report progress with a versioned JSON-serializable DTO payload. + Future progressVersionedJson( + double percentComplete, + T value, { + required int version, + String? typeName, + }) { + return progress( + percentComplete, + data: Map.from( + PayloadCodec.encodeVersionedJsonMap( + value, + version: version, + typeName: typeName, + ), + ), + ); + } } /// Context passed to handler implementations during execution. diff --git a/packages/stem/test/unit/core/task_invocation_test.dart b/packages/stem/test/unit/core/task_invocation_test.dart index 13ec41e9..40f21ec1 100644 --- a/packages/stem/test/unit/core/task_invocation_test.dart +++ b/packages/stem/test/unit/core/task_invocation_test.dart @@ -215,6 +215,35 @@ void main() { }, ); + test( + 'TaskInvocationContext.local reports progress with versioned DTO payloads', + () async { + ProgressSignal? progressSignal; + final TaskExecutionContext context = TaskInvocationContext.local( + id: 'task-1c', + headers: const {}, + meta: const {}, + attempt: 0, + heartbeat: () {}, + extendLease: (_) async {}, + progress: (percent, {Map? data}) async { + progressSignal = ProgressSignal(percent, data: data); + }, + ); + + await context.progressVersionedJson( + 25, + const _ProgressUpdate(stage: 'warming'), + version: 2, + ); + + expect(progressSignal?.data, equals(const { + PayloadCodec.versionKey: 2, + 'stage': 'warming', + })); + }, + ); + test('ProgressSignal exposes typed progress metadata helpers', () { const signal = ProgressSignal( 50, From 5795f67fe3be0bcbc869a423b4595988c51398d2 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 12:41:26 -0500 Subject: [PATCH 233/302] Add progress signal payload decode helpers --- .site/docs/core-concepts/tasks.md | 4 +- packages/stem/CHANGELOG.md | 4 ++ packages/stem/README.md | 6 ++- .../stem/lib/src/core/task_invocation.dart | 37 +++++++++++++++++++ .../test/unit/core/task_invocation_test.dart | 17 +++++++++ 5 files changed, 65 insertions(+), 3 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index c4a33b01..5dde86b6 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -144,7 +144,9 @@ every retry signal and shows how the strategy interacts with broker timings. retry policy overrides. - when you inspect a raw `ProgressSignal`, prefer `signal.dataJson('key', ...)`, `signal.dataVersionedJson('key', ...)`, or - `signal.dataValue('key')` over manual `signal.data?['key']` casts. + `signal.dataValue('key')` for keyed reads, or + `signal.payloadJson(...)`, `signal.payloadVersionedJson(...)`, and + `signal.payloadAs(codec: ...)` when the whole progress payload is one DTO. Use the context to build idempotent handlers. Re-enqueue work, cancel jobs, or store audit details in `context.meta`. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 64d127f0..5c56b4c0 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -108,6 +108,10 @@ - Added `ProgressSignal.dataValue(...)`, `requiredDataValue(...)`, `dataJson(...)`, and `dataAs(codec: ...)` so raw task-progress signal consumers can decode structured progress metadata without raw map casts. +- Added `ProgressSignal.payloadJson(...)`, + `payloadVersionedJson(...)`, and `payloadAs(codec: ...)` so raw task + progress inspection can decode a whole DTO payload without field-by-field + map reads. - Added `TaskExecutionContext` as the shared task-side execution surface for `TaskContext` and `TaskInvocationContext`, and taught `stem_builder` to accept it directly in annotated task definitions. diff --git a/packages/stem/README.md b/packages/stem/README.md index d57f7100..b31ad1a7 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -338,8 +338,10 @@ class ParentTask implements TaskHandler { If you inspect raw task progress signals, prefer `signal.dataJson('key', ...)`, `signal.dataVersionedJson('key', ...)`, -`signal.dataAs('key', codec: ...)`, or `signal.dataValue('key')` over -manual `signal.data?['key']` casts. +`signal.dataAs('key', codec: ...)`, or `signal.dataValue('key')` for keyed +reads, and `signal.payloadJson(...)`, +`signal.payloadVersionedJson(...)`, or `signal.payloadAs(codec: ...)` when the +entire progress payload is one DTO. Shared `TaskExecutionContext` implementations also expose `context.retry(...)`, so typed annotated tasks can request retries without depending on a concrete task runtime class. diff --git a/packages/stem/lib/src/core/task_invocation.dart b/packages/stem/lib/src/core/task_invocation.dart index 417a81cf..d007d16f 100644 --- a/packages/stem/lib/src/core/task_invocation.dart +++ b/packages/stem/lib/src/core/task_invocation.dart @@ -112,6 +112,13 @@ class ProgressSignal extends TaskInvocationSignal { return payload.value(key, codec: codec); } + /// Decodes the full progress payload as a typed DTO with [codec]. + T? payloadAs({required PayloadCodec codec}) { + final payload = data; + if (payload == null) return null; + return codec.decode(payload); + } + /// Decodes the progress metadata value for [key] as a typed DTO from JSON. T? dataJson( String key, { @@ -127,6 +134,19 @@ class ProgressSignal extends TaskInvocationSignal { ); } + /// Decodes the full progress payload as a typed DTO from JSON. + T? payloadJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + final payload = data; + if (payload == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(payload); + } + /// Decodes the progress metadata value for [key] as a typed DTO from /// version-aware JSON. T? dataVersionedJson( @@ -149,6 +169,23 @@ class ProgressSignal extends TaskInvocationSignal { typeName: typeName, ); } + + /// Decodes the full progress payload as a typed DTO from version-aware JSON. + T? payloadVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final payload = data; + if (payload == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(payload); + } } /// Request to enqueue a task from an isolate. diff --git a/packages/stem/test/unit/core/task_invocation_test.dart b/packages/stem/test/unit/core/task_invocation_test.dart index 40f21ec1..bf8268c1 100644 --- a/packages/stem/test/unit/core/task_invocation_test.dart +++ b/packages/stem/test/unit/core/task_invocation_test.dart @@ -248,6 +248,8 @@ void main() { const signal = ProgressSignal( 50, data: { + PayloadCodec.versionKey: 2, + 'stage': 'warming', 'step': 2, 'update': {'stage': 'warming'}, }, @@ -271,6 +273,21 @@ void main() { ), isA<_ProgressUpdate>().having((value) => value.stage, 'stage', 'warming'), ); + expect( + signal.payloadAs<_ProgressUpdate>(codec: _progressUpdateCodec), + isA<_ProgressUpdate>().having((value) => value.stage, 'stage', 'warming'), + ); + expect( + signal.payloadJson<_ProgressUpdate>(decode: _ProgressUpdate.fromJson), + isA<_ProgressUpdate>().having((value) => value.stage, 'stage', 'warming'), + ); + expect( + signal.payloadVersionedJson<_ProgressUpdate>( + version: 2, + decode: _ProgressUpdate.fromVersionedJson, + ), + isA<_ProgressUpdate>().having((value) => value.stage, 'stage', 'warming'), + ); }); test('TaskInvocationContext.local merges headers/meta and lineage', () async { From 3dc41848fd26719e492b3bc210f191e553b3fe36 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 12:43:58 -0500 Subject: [PATCH 234/302] Add workflow signal metadata payload helpers --- .site/docs/core-concepts/observability.md | 5 ++- packages/stem/CHANGELOG.md | 4 ++ packages/stem/README.md | 5 ++- packages/stem/lib/src/signals/payloads.dart | 32 ++++++++++++++ .../stem/test/unit/signals/payloads_test.dart | 42 +++++++++++++++++++ 5 files changed, 86 insertions(+), 2 deletions(-) diff --git a/.site/docs/core-concepts/observability.md b/.site/docs/core-concepts/observability.md index f84924a4..24acbb57 100644 --- a/.site/docs/core-concepts/observability.md +++ b/.site/docs/core-concepts/observability.md @@ -66,7 +66,10 @@ For workflow lifecycle signals, prefer `payload.metadataJson('key', ...)`, `payload.metadataVersionedJson('key', ...)`, or `payload.metadataAs('key', codec: ...)` over manual -`payload.metadata['key'] as Map` casts. +`payload.metadata['key'] as Map` casts. If the entire +metadata map is one DTO, use `payload.metadataPayloadJson(...)`, +`payload.metadataPayloadVersionedJson(...)`, or +`payload.metadataPayloadAs(codec: ...)` instead. ## Workflow Introspection diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 5c56b4c0..1af57439 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -105,6 +105,10 @@ `requiredMetadataValue(...)`, `metadataJson(...)`, and `metadataAs(codec: ...)` so workflow lifecycle signal consumers can decode structured metadata without raw map casts. +- Added `WorkflowRunPayload.metadataPayloadJson(...)`, + `metadataPayloadVersionedJson(...)`, and `metadataPayloadAs(codec: ...)` so + workflow lifecycle signal consumers can decode a whole metadata DTO without + field-by-field reads. - Added `ProgressSignal.dataValue(...)`, `requiredDataValue(...)`, `dataJson(...)`, and `dataAs(codec: ...)` so raw task-progress signal consumers can decode structured progress metadata without raw map casts. diff --git a/packages/stem/README.md b/packages/stem/README.md index b31ad1a7..f43782bf 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -965,7 +965,10 @@ Workflow lifecycle signals expose matching metadata helpers on `WorkflowRunPayload` via `payload.metadataJson('key', ...)`, `payload.metadataVersionedJson('key', ...)`, `payload.metadataAs('key', codec: ...)`, and -`payload.metadataValue('key')`. +`payload.metadataValue('key')`. When the whole metadata map is one DTO, +prefer `payload.metadataPayloadJson(...)`, +`payload.metadataPayloadVersionedJson(...)`, or +`payload.metadataPayloadAs(codec: ...)`. Low-level `FlowStepControl` objects expose matching suspension metadata helpers via `control.dataJson(...)`, `control.dataVersionedJson(...)`, and `control.dataAs(codec: ...)`. diff --git a/packages/stem/lib/src/signals/payloads.dart b/packages/stem/lib/src/signals/payloads.dart index 38505fbf..c67f01bd 100644 --- a/packages/stem/lib/src/signals/payloads.dart +++ b/packages/stem/lib/src/signals/payloads.dart @@ -644,6 +644,11 @@ class WorkflowRunPayload implements StemEvent { return metadata.value(key, codec: codec); } + /// Decodes the full metadata payload as a typed DTO with [codec]. + T metadataPayloadAs({required PayloadCodec codec}) { + return codec.decode(metadata); + } + /// Decodes the metadata value for [key] as a typed DTO with a JSON decoder. T? metadataJson( String key, { @@ -657,6 +662,17 @@ class WorkflowRunPayload implements StemEvent { ); } + /// Decodes the full metadata payload as a typed DTO with a JSON decoder. + T metadataPayloadJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(metadata); + } + /// Decodes the metadata value for [key] as a typed DTO with a version-aware /// JSON decoder. T? metadataVersionedJson( @@ -678,6 +694,22 @@ class WorkflowRunPayload implements StemEvent { ); } + /// Decodes the full metadata payload as a typed DTO with a version-aware + /// JSON decoder. + T metadataPayloadVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(metadata); + } + /// Returns the decoded metadata value for [key], or [fallback] when absent. T metadataValueOr(String key, T fallback, {PayloadCodec? codec}) { return metadata.valueOr(key, fallback, codec: codec); diff --git a/packages/stem/test/unit/signals/payloads_test.dart b/packages/stem/test/unit/signals/payloads_test.dart index 6f204c63..6f7cc140 100644 --- a/packages/stem/test/unit/signals/payloads_test.dart +++ b/packages/stem/test/unit/signals/payloads_test.dart @@ -146,6 +146,23 @@ void main() { expect(payload.metadataValue('attempt'), 3); expect(payload.metadataValueOr('missing', 'fallback'), 'fallback'); expect(payload.requiredMetadataValue('attempt'), 3); + expect( + payload.metadataPayloadJson<_WorkflowRunEnvelope>( + decode: _WorkflowRunEnvelope.fromJson, + ), + isA<_WorkflowRunEnvelope>() + .having((value) => value.attempt, 'attempt', 3) + .having((value) => value.approved, 'approved', isTrue), + ); + expect( + payload.metadataPayloadVersionedJson<_WorkflowRunEnvelope>( + version: 2, + decode: _WorkflowRunEnvelope.fromVersionedJson, + ), + isA<_WorkflowRunEnvelope>() + .having((value) => value.attempt, 'attempt', 3) + .having((value) => value.approved, 'approved', isTrue), + ); expect( payload.metadataJson<_WorkflowRunMetadata>( 'approval', @@ -207,3 +224,28 @@ class _WorkflowRunMetadata { final bool approved; } + +class _WorkflowRunEnvelope { + const _WorkflowRunEnvelope({required this.attempt, required this.approved}); + + factory _WorkflowRunEnvelope.fromJson(Map json) { + final approval = Map.from( + json['approval']! as Map, + ); + return _WorkflowRunEnvelope( + attempt: json['attempt'] as int, + approved: approval['approved'] as bool, + ); + } + + factory _WorkflowRunEnvelope.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _WorkflowRunEnvelope.fromJson(json); + } + + final int attempt; + final bool approved; +} From 90392e6b170c8bd56f761d0caa574b44ad7df0e7 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 12:46:18 -0500 Subject: [PATCH 235/302] Add envelope payload decode helpers --- .site/docs/core-concepts/producer.md | 3 + packages/stem/CHANGELOG.md | 3 + packages/stem/lib/src/core/envelope.dart | 65 ++++++++++++++++ .../stem/test/unit/core/stem_core_test.dart | 77 +++++++++++++++++++ 4 files changed, 148 insertions(+) diff --git a/.site/docs/core-concepts/producer.md b/.site/docs/core-concepts/producer.md index ef1bc54b..c8e81a25 100644 --- a/.site/docs/core-concepts/producer.md +++ b/.site/docs/core-concepts/producer.md @@ -60,6 +60,9 @@ Reach for them when the task name is truly dynamic or you are crossing a boundary that does not have the generated/manual `TaskDefinition`. When those calls already have DTO args, prefer `enqueuer.enqueueJson(...)` over hand-building an `args` map. +If you later inspect the raw `Envelope`, prefer `envelope.argsJson(...)`, +`envelope.argsVersionedJson(...)`, `envelope.metaJson(...)`, or +`envelope.metaVersionedJson(...)` over manual map casts. ## Enqueue options diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 1af57439..63d11424 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -154,6 +154,9 @@ `ctx.requiredParamJson(...)`, and shared `valueJson(...)` / `requiredValueJson(...)` helpers so nested DTO payload fields no longer need a separate `PayloadCodec` constant. +- Added `Envelope.argsJson(...)`, `argsVersionedJson(...)`, `metaJson(...)`, + and `metaVersionedJson(...)` so low-level producer and signal code can + decode whole envelope DTO payloads without dropping to raw maps. - Added `valueVersionedJson(...)`, `requiredValueVersionedJson(...)`, `valueListVersionedJson(...)`, and `requiredValueListVersionedJson(...)` to the shared payload-map helpers so diff --git a/packages/stem/lib/src/core/envelope.dart b/packages/stem/lib/src/core/envelope.dart index 053396a3..dd12dd4d 100644 --- a/packages/stem/lib/src/core/envelope.dart +++ b/packages/stem/lib/src/core/envelope.dart @@ -33,6 +33,7 @@ library; import 'dart:convert'; import 'package:stem/src/core/clock.dart'; +import 'package:stem/src/core/payload_codec.dart'; import 'package:uuid/uuid.dart'; /// Target classification for routing operations. @@ -227,6 +228,38 @@ class Envelope { /// Arguments passed to the task handler. final Map args; + /// Decodes the full task args payload as a typed DTO with [codec]. + T argsAs({required PayloadCodec codec}) { + return codec.decode(args); + } + + /// Decodes the full task args payload as a typed DTO from JSON. + T argsJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(args); + } + + /// Decodes the full task args payload as a typed DTO from version-aware + /// JSON. + T argsVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(args); + } + /// Arbitrary metadata headers (trace id, tenant, etc). final Map headers; @@ -254,6 +287,38 @@ class Envelope { /// Additional metadata persisted with the message. final Map meta; + /// Decodes the full envelope metadata payload as a typed DTO with [codec]. + T metaAs({required PayloadCodec codec}) { + return codec.decode(meta); + } + + /// Decodes the full envelope metadata payload as a typed DTO from JSON. + T metaJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(meta); + } + + /// Decodes the full envelope metadata payload as a typed DTO from + /// version-aware JSON. + T metaVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(meta); + } + /// Returns a copy of this envelope with updated fields. Envelope copyWith({ String? id, diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index 9393609c..e6c90951 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -35,6 +35,47 @@ void main() { expect(copy.queue, equals('emails')); expect(copy.meta, equals({'foo': 'bar'})); }); + + test('decodes whole args and meta DTO payloads', () { + final envelope = Envelope( + name: 'example', + args: const { + PayloadCodec.versionKey: 2, + 'value': 42, + }, + meta: const { + PayloadCodec.versionKey: 2, + 'label': 'queued', + }, + ); + + expect( + envelope.argsJson<_EnvelopeArgs>(decode: _EnvelopeArgs.fromJson).value, + 42, + ); + expect( + envelope + .argsVersionedJson<_EnvelopeArgs>( + version: 2, + decode: _EnvelopeArgs.fromVersionedJson, + ) + .value, + 42, + ); + expect( + envelope.metaJson<_EnvelopeMeta>(decode: _EnvelopeMeta.fromJson).label, + 'queued', + ); + expect( + envelope + .metaVersionedJson<_EnvelopeMeta>( + version: 2, + decode: _EnvelopeMeta.fromVersionedJson, + ) + .label, + 'queued', + ); + }); }); group('StemConfig', () { @@ -748,6 +789,42 @@ class _CodecReceipt { Map toJson() => {'id': id}; } +class _EnvelopeArgs { + const _EnvelopeArgs(this.value); + + factory _EnvelopeArgs.fromJson(Map json) { + return _EnvelopeArgs(json['value'] as int); + } + + factory _EnvelopeArgs.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _EnvelopeArgs.fromJson(json); + } + + final int value; +} + +class _EnvelopeMeta { + const _EnvelopeMeta(this.label); + + factory _EnvelopeMeta.fromJson(Map json) { + return _EnvelopeMeta(json['label'] as String); + } + + factory _EnvelopeMeta.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _EnvelopeMeta.fromJson(json); + } + + final String label; +} + const _codecReceiptCodec = PayloadCodec<_CodecReceipt>.json( decode: _CodecReceipt.fromJson, typeName: '_CodecReceipt', From 2b6ef89f108b671f6bf2132750367dd48cb9fc17 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 12:48:34 -0500 Subject: [PATCH 236/302] Add isolate bridge payload decode helpers --- packages/stem/CHANGELOG.md | 6 + .../stem/lib/src/core/task_invocation.dart | 166 ++++++++++++++++++ .../test/unit/core/task_invocation_test.dart | 107 +++++++++++ 3 files changed, 279 insertions(+) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 63d11424..0d8e0fbb 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -157,6 +157,12 @@ - Added `Envelope.argsJson(...)`, `argsVersionedJson(...)`, `metaJson(...)`, and `metaVersionedJson(...)` so low-level producer and signal code can decode whole envelope DTO payloads without dropping to raw maps. +- Added typed DTO readers on isolate bridge payloads like + `TaskEnqueueRequest.argsVersionedJson(...)`, + `StartWorkflowRequest.paramsVersionedJson(...)`, + `WaitForWorkflowResponse.resultVersionedJson(...)`, and + `EmitWorkflowEventRequest.payloadVersionedJson(...)` so low-level + cross-isolate helpers no longer need manual map casts. - Added `valueVersionedJson(...)`, `requiredValueVersionedJson(...)`, `valueListVersionedJson(...)`, and `requiredValueListVersionedJson(...)` to the shared payload-map helpers so diff --git a/packages/stem/lib/src/core/task_invocation.dart b/packages/stem/lib/src/core/task_invocation.dart index d007d16f..8de3989e 100644 --- a/packages/stem/lib/src/core/task_invocation.dart +++ b/packages/stem/lib/src/core/task_invocation.dart @@ -255,6 +255,38 @@ class TaskEnqueueRequest { /// Task arguments. final Map args; + /// Decodes the full task args payload as a typed DTO with [codec]. + T argsAs({required PayloadCodec codec}) { + return codec.decode(args); + } + + /// Decodes the full task args payload as a typed DTO from JSON. + T argsJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(args); + } + + /// Decodes the full task args payload as a typed DTO from version-aware + /// JSON. + T argsVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(args); + } + /// Task headers. final Map headers; @@ -264,6 +296,38 @@ class TaskEnqueueRequest { /// Task metadata. final Map meta; + /// Decodes the full task metadata payload as a typed DTO with [codec]. + T metaAs({required PayloadCodec codec}) { + return codec.decode(meta); + } + + /// Decodes the full task metadata payload as a typed DTO from JSON. + T metaJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(meta); + } + + /// Decodes the full task metadata payload as a typed DTO from version-aware + /// JSON. + T metaVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(meta); + } + /// Optional delay before execution. final DateTime? notBefore; @@ -300,6 +364,38 @@ class StartWorkflowRequest { /// Encoded workflow params. final Map params; + /// Decodes the full workflow params payload as a typed DTO with [codec]. + T paramsAs({required PayloadCodec codec}) { + return codec.decode(params); + } + + /// Decodes the full workflow params payload as a typed DTO from JSON. + T paramsJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(params); + } + + /// Decodes the full workflow params payload as a typed DTO from version-aware + /// JSON. + T paramsVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(params); + } + /// Optional parent workflow run id. final String? parentRunId; @@ -353,6 +449,44 @@ class WaitForWorkflowResponse { /// Serialized workflow result payload. final Map? result; + /// Decodes the workflow result payload as a typed DTO with [codec]. + T? resultAs({required PayloadCodec codec}) { + final payload = result; + if (payload == null) return null; + return codec.decode(payload); + } + + /// Decodes the workflow result payload as a typed DTO from JSON. + T? resultJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + final payload = result; + if (payload == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(payload); + } + + /// Decodes the workflow result payload as a typed DTO from version-aware + /// JSON. + T? resultVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final payload = result; + if (payload == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(payload); + } + /// Error message when workflow wait fails. final String? error; } @@ -370,6 +504,38 @@ class EmitWorkflowEventRequest { /// Encoded workflow event payload. final Map payload; + + /// Decodes the full workflow event payload as a typed DTO with [codec]. + T payloadAs({required PayloadCodec codec}) { + return codec.decode(payload); + } + + /// Decodes the full workflow event payload as a typed DTO from JSON. + T payloadJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(payload); + } + + /// Decodes the full workflow event payload as a typed DTO from version-aware + /// JSON. + T payloadVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(payload); + } } /// Response payload for isolate workflow event emit requests. diff --git a/packages/stem/test/unit/core/task_invocation_test.dart b/packages/stem/test/unit/core/task_invocation_test.dart index bf8268c1..8ede43a0 100644 --- a/packages/stem/test/unit/core/task_invocation_test.dart +++ b/packages/stem/test/unit/core/task_invocation_test.dart @@ -469,6 +469,63 @@ void main() { ]); }); + test('isolate bridge payloads expose typed DTO decode helpers', () { + const enqueue = TaskEnqueueRequest( + name: 'task.demo', + args: {PayloadCodec.versionKey: 2, 'stage': 'warming'}, + headers: {'x-trace-id': 'trace-1'}, + options: {}, + meta: {PayloadCodec.versionKey: 2, 'label': 'queued'}, + ); + const start = StartWorkflowRequest( + workflowName: 'workflow.demo', + params: {PayloadCodec.versionKey: 2, 'value': 'child'}, + ); + const wait = WaitForWorkflowResponse( + result: {PayloadCodec.versionKey: 2, 'value': 'done'}, + ); + const emit = EmitWorkflowEventRequest( + topic: 'workflow.ready', + payload: {PayloadCodec.versionKey: 2, 'value': 'event'}, + ); + + expect( + enqueue.argsVersionedJson<_ProgressUpdate>( + version: 2, + decode: _ProgressUpdate.fromVersionedJson, + ).stage, + 'warming', + ); + expect( + enqueue.metaVersionedJson<_QueueLabel>( + version: 2, + decode: _QueueLabel.fromVersionedJson, + ).label, + 'queued', + ); + expect( + start.paramsVersionedJson<_WorkflowStartPayload>( + version: 2, + decode: _WorkflowStartPayload.fromVersionedJson, + ).value, + 'child', + ); + expect( + wait.resultVersionedJson<_WorkflowResultPayload>( + version: 2, + decode: _WorkflowResultPayload.fromVersionedJson, + )?.value, + 'done', + ); + expect( + emit.payloadVersionedJson<_WorkflowEventPayload>( + version: 2, + decode: _WorkflowEventPayload.fromVersionedJson, + ).value, + 'event', + ); + }); + test('TaskInvocationContext.remote sends control signals', () async { final control = ReceivePort(); addTearDown(control.close); @@ -694,6 +751,56 @@ void main() { class _WorkflowEventPayload { const _WorkflowEventPayload(this.value); + factory _WorkflowEventPayload.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _WorkflowEventPayload(json['value'] as String); + } + + final String value; +} + +class _QueueLabel { + const _QueueLabel(this.label); + + factory _QueueLabel.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _QueueLabel(json['label'] as String); + } + + final String label; +} + +class _WorkflowStartPayload { + const _WorkflowStartPayload(this.value); + + factory _WorkflowStartPayload.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _WorkflowStartPayload(json['value'] as String); + } + + final String value; +} + +class _WorkflowResultPayload { + const _WorkflowResultPayload(this.value); + + factory _WorkflowResultPayload.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _WorkflowResultPayload(json['value'] as String); + } + final String value; } From e3d42775498fdc0f0c31ca89580ea137b3fa620f Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 12:55:38 -0500 Subject: [PATCH 237/302] Add observability payload decode helpers --- .site/docs/core-concepts/observability.md | 11 + packages/stem/CHANGELOG.md | 12 + packages/stem/README.md | 11 +- packages/stem/lib/src/signals/payloads.dart | 126 +++++++++++ packages/stem/lib/src/worker/worker.dart | 63 ++++++ .../runtime/workflow_introspection.dart | 175 +++++++++++++++ .../stem/test/unit/core/stem_event_test.dart | 206 ++++++++++++++++++ .../stem/test/unit/signals/payloads_test.dart | 110 ++++++++++ 8 files changed, 713 insertions(+), 1 deletion(-) diff --git a/.site/docs/core-concepts/observability.md b/.site/docs/core-concepts/observability.md index 24acbb57..f5d9a4a8 100644 --- a/.site/docs/core-concepts/observability.md +++ b/.site/docs/core-concepts/observability.md @@ -97,6 +97,17 @@ When a completed step or checkpoint carries a DTO payload, prefer `event.resultJson(...)`, `event.resultVersionedJson(...)`, or `event.resultAs(codec: ...)` over manual `event.result as Map` casts. +Step and runtime introspection events also expose typed metadata helpers via +`event.metadataJson('key', ...)`, `event.metadataVersionedJson('key', ...)`, +`event.metadataAs('key', codec: ...)`, `event.metadataPayloadJson(...)`, and +`event.metadataPayloadVersionedJson(...)`. +When worker events carry structured `data`, prefer `event.dataJson(...)`, +`event.dataVersionedJson(...)`, or `event.dataAs(codec: ...)` over manual +`event.data!['key']` casts. For completed control commands, use +`payload.responseJson(...)`, `payload.responseVersionedJson(...)`, +`payload.responseAs(codec: ...)`, `payload.errorJson(...)`, +`payload.errorVersionedJson(...)`, or `payload.errorAs(codec: ...)` instead of +walking raw `response` / `error` maps. ## Logging diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 0d8e0fbb..f880eed1 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,18 @@ ## 0.1.1 +- Added `WorkflowStepEvent.metadataJson(...)`, + `metadataVersionedJson(...)`, `metadataPayloadJson(...)`, and the matching + `WorkflowRuntimeEvent` helpers so workflow introspection metadata can decode + DTOs without raw map casts. +- Added `WorkerEvent.dataJson(...)`, `dataVersionedJson(...)`, and + `dataAs(codec: ...)` so worker observability events can decode structured + event data without raw map casts. +- Added `ControlCommandCompletedPayload.responseJson(...)`, + `responseVersionedJson(...)`, `responseAs(codec: ...)`, `errorJson(...)`, + `errorVersionedJson(...)`, and `errorAs(codec: ...)` so control command + result signals can decode structured response and error payloads without + raw map plumbing. - Added `TaskExecutionContext.argsAs(...)`, `argsJson(...)`, `argsVersionedJson(...)`, `WorkflowExecutionContext.paramsAs(...)`, `paramsJson(...)`, `paramsVersionedJson(...)`, and the same full-payload diff --git a/packages/stem/README.md b/packages/stem/README.md index f43782bf..98cfa2f8 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -952,7 +952,16 @@ Checkpoint entries from `viewCheckpoints(...)` and `entry.valueAs(codec: ...)`. Workflow introspection events expose matching helpers via `event.resultJson(...)`, `event.resultVersionedJson(...)`, and -`event.resultAs(codec: ...)`. +`event.resultAs(codec: ...)`, plus metadata helpers via +`event.metadataJson('key', ...)`, `event.metadataVersionedJson('key', ...)`, +`event.metadataAs('key', codec: ...)`, `event.metadataPayloadJson(...)`, and +`event.metadataPayloadVersionedJson(...)`. Worker events expose matching typed helpers on +`WorkerEvent.data` via `event.dataJson(...)`, +`event.dataVersionedJson(...)`, and `event.dataAs(codec: ...)`. Control +command completion signals expose the same surface on `response` and `error` +via `payload.responseJson(...)`, `payload.responseVersionedJson(...)`, +`payload.responseAs(codec: ...)`, `payload.errorJson(...)`, +`payload.errorVersionedJson(...)`, and `payload.errorAs(codec: ...)`. For lower-level suspension directives, prefer `step.sleepJson(...)`, `step.sleepVersionedJson(...)`, `step.awaitEventJson(...)`, `step.awaitEventVersionedJson(...)`, and diff --git a/packages/stem/lib/src/signals/payloads.dart b/packages/stem/lib/src/signals/payloads.dart index c67f01bd..d4c1a4d4 100644 --- a/packages/stem/lib/src/signals/payloads.dart +++ b/packages/stem/lib/src/signals/payloads.dart @@ -908,9 +908,135 @@ class ControlCommandCompletedPayload implements StemEvent { /// The response data from the command execution, if any. final Map? response; + /// Returns the decoded response value for [key], or `null` when absent. + T? responseValue(String key, {PayloadCodec? codec}) { + final payload = response; + if (payload == null) return null; + return payload.value(key, codec: codec); + } + + /// Returns the decoded response value for [key], or [fallback] when absent. + T responseValueOr(String key, T fallback, {PayloadCodec? codec}) { + final payload = response; + if (payload == null) return fallback; + return payload.valueOr(key, fallback, codec: codec); + } + + /// Returns the decoded response value for [key], throwing when absent. + T requiredResponseValue(String key, {PayloadCodec? codec}) { + final payload = response; + if (payload == null) { + throw StateError( + 'ControlCommandCompletedPayload.response does not contain "$key".', + ); + } + return payload.requiredValue(key, codec: codec); + } + + /// Decodes the full response payload as a typed DTO with [codec]. + T? responseAs({required PayloadCodec codec}) { + final payload = response; + if (payload == null) return null; + return codec.decode(payload); + } + + /// Decodes the full response payload as a typed DTO with a JSON decoder. + T? responseJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + final payload = response; + if (payload == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(payload); + } + + /// Decodes the full response payload as a typed DTO with a version-aware + /// JSON decoder. + T? responseVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final payload = response; + if (payload == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(payload); + } + /// Error information if the command failed, if any. final Map? error; + /// Returns the decoded error value for [key], or `null` when absent. + T? errorValue(String key, {PayloadCodec? codec}) { + final payload = error; + if (payload == null) return null; + return payload.value(key, codec: codec); + } + + /// Returns the decoded error value for [key], or [fallback] when absent. + T errorValueOr(String key, T fallback, {PayloadCodec? codec}) { + final payload = error; + if (payload == null) return fallback; + return payload.valueOr(key, fallback, codec: codec); + } + + /// Returns the decoded error value for [key], throwing when absent. + T requiredErrorValue(String key, {PayloadCodec? codec}) { + final payload = error; + if (payload == null) { + throw StateError( + 'ControlCommandCompletedPayload.error does not contain "$key".', + ); + } + return payload.requiredValue(key, codec: codec); + } + + /// Decodes the full error payload as a typed DTO with [codec]. + T? errorAs({required PayloadCodec codec}) { + final payload = error; + if (payload == null) return null; + return codec.decode(payload); + } + + /// Decodes the full error payload as a typed DTO with a JSON decoder. + T? errorJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + final payload = error; + if (payload == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(payload); + } + + /// Decodes the full error payload as a typed DTO with a version-aware JSON + /// decoder. + T? errorVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final payload = error; + if (payload == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(payload); + } + final DateTime _occurredAt; @override diff --git a/packages/stem/lib/src/worker/worker.dart b/packages/stem/lib/src/worker/worker.dart index 9a3d207a..9565fa91 100644 --- a/packages/stem/lib/src/worker/worker.dart +++ b/packages/stem/lib/src/worker/worker.dart @@ -106,6 +106,8 @@ import 'package:stem/src/core/clock.dart'; import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/core/encoder_keys.dart'; import 'package:stem/src/core/envelope.dart'; +import 'package:stem/src/core/payload_codec.dart'; +import 'package:stem/src/core/payload_map.dart'; import 'package:stem/src/core/retry.dart'; import 'package:stem/src/core/stem.dart'; import 'package:stem/src/core/stem_event.dart'; @@ -4772,6 +4774,67 @@ class WorkerEvent implements StemEvent { /// Additional data for the event. final Map? data; + /// Returns the decoded data value for [key], or `null` when absent. + T? dataValue(String key, {PayloadCodec? codec}) { + final payload = data; + if (payload == null) return null; + return payload.value(key, codec: codec); + } + + /// Returns the decoded data value for [key], or [fallback] when absent. + T dataValueOr(String key, T fallback, {PayloadCodec? codec}) { + final payload = data; + if (payload == null) return fallback; + return payload.valueOr(key, fallback, codec: codec); + } + + /// Returns the decoded data value for [key], throwing when absent. + T requiredDataValue(String key, {PayloadCodec? codec}) { + final payload = data; + if (payload == null) { + throw StateError('WorkerEvent.data does not contain "$key".'); + } + return payload.requiredValue(key, codec: codec); + } + + /// Decodes the full data payload as a typed DTO with [codec]. + T? dataAs({required PayloadCodec codec}) { + final payload = data; + if (payload == null) return null; + return codec.decode(payload); + } + + /// Decodes the full data payload as a typed DTO with a JSON decoder. + T? dataJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + final payload = data; + if (payload == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(payload); + } + + /// Decodes the full data payload as a typed DTO with a version-aware JSON + /// decoder. + T? dataVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final payload = data; + if (payload == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(payload); + } + @override String get eventName => 'worker.${type.name}'; diff --git a/packages/stem/lib/src/workflow/runtime/workflow_introspection.dart b/packages/stem/lib/src/workflow/runtime/workflow_introspection.dart index a47abcc6..ce9faa18 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_introspection.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_introspection.dart @@ -1,4 +1,5 @@ import 'package:stem/src/core/payload_codec.dart'; +import 'package:stem/src/core/payload_map.dart'; import 'package:stem/src/core/stem_event.dart'; /// Enumerates workflow step event types emitted by the runtime. @@ -102,6 +103,93 @@ class WorkflowStepEvent implements StemEvent { /// Optional metadata associated with the event. final Map? metadata; + /// Returns the decoded metadata value for [key], or `null` when absent. + T? metadataValue(String key, {PayloadCodec? codec}) { + final payload = metadata; + if (payload == null) return null; + return payload.value(key, codec: codec); + } + + /// Decodes the metadata value for [key] as a typed DTO with [codec]. + T? metadataAs(String key, {required PayloadCodec codec}) { + final payload = metadata; + if (payload == null) return null; + return payload.value(key, codec: codec); + } + + /// Decodes the metadata value for [key] as a typed DTO with a JSON decoder. + T? metadataJson( + String key, { + required T Function(Map payload) decode, + String? typeName, + }) { + final payload = metadata; + if (payload == null) return null; + return payload.valueJson( + key, + decode: decode, + typeName: typeName, + ); + } + + /// Decodes the metadata value for [key] as a typed DTO with a version-aware + /// JSON decoder. + T? metadataVersionedJson( + String key, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final payload = metadata; + if (payload == null) return null; + return payload.valueVersionedJson( + key, + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ); + } + + /// Decodes the full metadata payload as a typed DTO with [codec]. + T? metadataPayloadAs({required PayloadCodec codec}) { + final payload = metadata; + if (payload == null) return null; + return codec.decode(payload); + } + + /// Decodes the full metadata payload as a typed DTO with a JSON decoder. + T? metadataPayloadJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + final payload = metadata; + if (payload == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(payload); + } + + /// Decodes the full metadata payload as a typed DTO with a version-aware + /// JSON decoder. + T? metadataPayloadVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final payload = metadata; + if (payload == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(payload); + } + @override String get eventName => 'workflow.step.${type.name}'; @@ -146,6 +234,93 @@ class WorkflowRuntimeEvent implements StemEvent { /// Additional event metadata. final Map? metadata; + /// Returns the decoded metadata value for [key], or `null` when absent. + T? metadataValue(String key, {PayloadCodec? codec}) { + final payload = metadata; + if (payload == null) return null; + return payload.value(key, codec: codec); + } + + /// Decodes the metadata value for [key] as a typed DTO with [codec]. + T? metadataAs(String key, {required PayloadCodec codec}) { + final payload = metadata; + if (payload == null) return null; + return payload.value(key, codec: codec); + } + + /// Decodes the metadata value for [key] as a typed DTO with a JSON decoder. + T? metadataJson( + String key, { + required T Function(Map payload) decode, + String? typeName, + }) { + final payload = metadata; + if (payload == null) return null; + return payload.valueJson( + key, + decode: decode, + typeName: typeName, + ); + } + + /// Decodes the metadata value for [key] as a typed DTO with a version-aware + /// JSON decoder. + T? metadataVersionedJson( + String key, { + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final payload = metadata; + if (payload == null) return null; + return payload.valueVersionedJson( + key, + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ); + } + + /// Decodes the full metadata payload as a typed DTO with [codec]. + T? metadataPayloadAs({required PayloadCodec codec}) { + final payload = metadata; + if (payload == null) return null; + return codec.decode(payload); + } + + /// Decodes the full metadata payload as a typed DTO with a JSON decoder. + T? metadataPayloadJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + final payload = metadata; + if (payload == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(payload); + } + + /// Decodes the full metadata payload as a typed DTO with a version-aware + /// JSON decoder. + T? metadataPayloadVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final payload = metadata; + if (payload == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(payload); + } + @override String get eventName => 'workflow.runtime.${type.name}'; diff --git a/packages/stem/test/unit/core/stem_event_test.dart b/packages/stem/test/unit/core/stem_event_test.dart index 5874447d..a9880d58 100644 --- a/packages/stem/test/unit/core/stem_event_test.dart +++ b/packages/stem/test/unit/core/stem_event_test.dart @@ -12,6 +12,32 @@ void main() { expect(event.attributes, isA>()); }); + test('WorkerEvent exposes typed data helpers', () { + final event = WorkerEvent( + type: WorkerEventType.completed, + data: const { + 'retry': {'delayMs': 250}, + PayloadCodec.versionKey: 2, + 'delayMs': 250, + }, + ); + + expect(event.dataValue('delayMs'), 250); + expect(event.dataValueOr('missing', 'fallback'), 'fallback'); + expect(event.requiredDataValue('delayMs'), 250); + expect( + event.dataJson<_RetryData>(decode: _RetryData.fromJson), + isA<_RetryData>().having((value) => value.delayMs, 'delayMs', 250), + ); + expect( + event.dataVersionedJson<_RetryData>( + version: 2, + decode: _RetryData.fromVersionedJson, + ), + isA<_RetryData>().having((value) => value.delayMs, 'delayMs', 250), + ); + }); + test('QueueCustomEvent implements StemEvent contract', () { final event = QueueCustomEvent( id: 'evt-1', @@ -73,6 +99,132 @@ void main() { ), ); }); + + test('WorkflowStepEvent exposes typed metadata helpers', () { + final event = WorkflowStepEvent( + runId: 'run-3', + workflow: 'checkout', + stepId: 'charge', + type: WorkflowStepEventType.completed, + timestamp: DateTime.utc(2026, 2, 24, 16, 45), + metadata: const { + 'worker': { + PayloadCodec.versionKey: 2, + 'workerId': 'worker-1', + }, + PayloadCodec.versionKey: 2, + 'workerId': 'worker-1', + }, + ); + + expect(event.metadataValue>('worker'), isNotNull); + expect( + event.metadataJson<_StepMetadata>( + 'worker', + decode: _StepMetadata.fromJson, + ), + isA<_StepMetadata>().having( + (value) => value.workerId, + 'workerId', + 'worker-1', + ), + ); + expect( + event.metadataVersionedJson<_StepMetadata>( + 'worker', + version: 2, + decode: _StepMetadata.fromVersionedJson, + ), + isA<_StepMetadata>().having( + (value) => value.workerId, + 'workerId', + 'worker-1', + ), + ); + expect( + event.metadataPayloadJson<_StepMetadata>( + decode: _StepMetadata.fromJson, + ), + isA<_StepMetadata>().having( + (value) => value.workerId, + 'workerId', + 'worker-1', + ), + ); + expect( + event.metadataPayloadVersionedJson<_StepMetadata>( + version: 2, + decode: _StepMetadata.fromVersionedJson, + ), + isA<_StepMetadata>().having( + (value) => value.workerId, + 'workerId', + 'worker-1', + ), + ); + }); + + test('WorkflowRuntimeEvent exposes typed metadata helpers', () { + final event = WorkflowRuntimeEvent( + runId: 'run-4', + workflow: 'checkout', + type: WorkflowRuntimeEventType.continuationEnqueued, + timestamp: DateTime.utc(2026, 2, 24, 17), + metadata: const { + 'detail': { + PayloadCodec.versionKey: 2, + 'reason': 'resume', + }, + PayloadCodec.versionKey: 2, + 'reason': 'resume', + }, + ); + + expect( + event.metadataJson<_RuntimeMetadata>( + 'detail', + decode: _RuntimeMetadata.fromJson, + ), + isA<_RuntimeMetadata>().having( + (value) => value.reason, + 'reason', + 'resume', + ), + ); + expect( + event.metadataVersionedJson<_RuntimeMetadata>( + 'detail', + version: 2, + decode: _RuntimeMetadata.fromVersionedJson, + ), + isA<_RuntimeMetadata>().having( + (value) => value.reason, + 'reason', + 'resume', + ), + ); + expect( + event.metadataPayloadJson<_RuntimeMetadata>( + decode: _RuntimeMetadata.fromJson, + ), + isA<_RuntimeMetadata>().having( + (value) => value.reason, + 'reason', + 'resume', + ), + ); + expect( + event.metadataPayloadVersionedJson<_RuntimeMetadata>( + version: 2, + decode: _RuntimeMetadata.fromVersionedJson, + ), + isA<_RuntimeMetadata>().having( + (value) => value.reason, + 'reason', + 'resume', + ), + ); + }); }); } @@ -93,3 +245,57 @@ class _ChargeResult { final String chargeId; } + +class _RetryData { + const _RetryData({required this.delayMs}); + + factory _RetryData.fromJson(Map json) { + return _RetryData(delayMs: json['delayMs'] as int); + } + + factory _RetryData.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _RetryData.fromJson(json); + } + + final int delayMs; +} + +class _StepMetadata { + const _StepMetadata({required this.workerId}); + + factory _StepMetadata.fromJson(Map json) { + return _StepMetadata(workerId: json['workerId'] as String); + } + + factory _StepMetadata.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _StepMetadata.fromJson(json); + } + + final String workerId; +} + +class _RuntimeMetadata { + const _RuntimeMetadata({required this.reason}); + + factory _RuntimeMetadata.fromJson(Map json) { + return _RuntimeMetadata(reason: json['reason'] as String); + } + + factory _RuntimeMetadata.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _RuntimeMetadata.fromJson(json); + } + + final String reason; +} diff --git a/packages/stem/test/unit/signals/payloads_test.dart b/packages/stem/test/unit/signals/payloads_test.dart index 6f7cc140..66432480 100644 --- a/packages/stem/test/unit/signals/payloads_test.dart +++ b/packages/stem/test/unit/signals/payloads_test.dart @@ -2,6 +2,7 @@ import 'package:stem/src/control/control_messages.dart'; import 'package:stem/src/core/clock.dart'; import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/core/envelope.dart'; +import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/signals/payloads.dart'; import 'package:test/test.dart'; @@ -187,6 +188,71 @@ void main() { ), ); }); + + test('control command payload exposes typed response and error helpers', () { + const worker = WorkerInfo( + id: 'worker-1', + queues: ['default'], + broadcasts: [], + ); + final command = ControlCommandMessage( + requestId: 'req-2', + type: 'pause', + targets: const ['*'], + ); + final payload = ControlCommandCompletedPayload( + worker: worker, + command: command, + status: 'error', + response: const { + PayloadCodec.versionKey: 2, + 'queue': 'priority', + 'paused': true, + }, + error: const { + PayloadCodec.versionKey: 2, + 'code': 'pause_failed', + 'message': 'already paused', + }, + ); + + expect(payload.responseValue('queue'), 'priority'); + expect(payload.responseValueOr('missing', 'fallback'), 'fallback'); + expect(payload.requiredResponseValue('paused'), isTrue); + expect( + payload.responseJson<_ControlResponse>(decode: _ControlResponse.fromJson), + isA<_ControlResponse>() + .having((value) => value.queue, 'queue', 'priority') + .having((value) => value.paused, 'paused', isTrue), + ); + expect( + payload.responseVersionedJson<_ControlResponse>( + version: 2, + decode: _ControlResponse.fromVersionedJson, + ), + isA<_ControlResponse>() + .having((value) => value.queue, 'queue', 'priority') + .having((value) => value.paused, 'paused', isTrue), + ); + expect(payload.errorValue('code'), 'pause_failed'); + expect(payload.errorValueOr('missing', 'fallback'), 'fallback'); + expect(payload.requiredErrorValue('message'), 'already paused'); + expect( + payload.errorJson<_ControlError>(decode: _ControlError.fromJson), + isA<_ControlError>() + .having((value) => value.code, 'code', 'pause_failed') + .having((value) => value.message, 'message', 'already paused'), + ); + expect( + payload.errorVersionedJson<_ControlError>( + version: 2, + decode: _ControlError.fromVersionedJson, + ), + isA<_ControlError>() + .having((value) => value.code, 'code', 'pause_failed') + .having((value) => value.message, 'message', 'already paused'), + ); + }); } class _TaskResultPayload { @@ -249,3 +315,47 @@ class _WorkflowRunEnvelope { final int attempt; final bool approved; } + +class _ControlResponse { + const _ControlResponse({required this.queue, required this.paused}); + + factory _ControlResponse.fromJson(Map json) { + return _ControlResponse( + queue: json['queue'] as String, + paused: json['paused'] as bool, + ); + } + + factory _ControlResponse.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _ControlResponse.fromJson(json); + } + + final String queue; + final bool paused; +} + +class _ControlError { + const _ControlError({required this.code, required this.message}); + + factory _ControlError.fromJson(Map json) { + return _ControlError( + code: json['code'] as String, + message: json['message'] as String, + ); + } + + factory _ControlError.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _ControlError.fromJson(json); + } + + final String code; + final String message; +} From 9663b805beed9ff2a8c3fbdb1e3e49efb0d27189 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 12:58:13 -0500 Subject: [PATCH 238/302] Add control message payload helpers --- .site/docs/workers/worker-control.md | 5 + packages/stem/CHANGELOG.md | 4 + .../lib/src/control/control_messages.dart | 157 ++++++++++++++++++ .../unit/control/control_messages_test.dart | 136 +++++++++++++++ 4 files changed, 302 insertions(+) create mode 100644 packages/stem/test/unit/control/control_messages_test.dart diff --git a/.site/docs/workers/worker-control.md b/.site/docs/workers/worker-control.md index 46169b8e..4e413198 100644 --- a/.site/docs/workers/worker-control.md +++ b/.site/docs/workers/worker-control.md @@ -59,6 +59,11 @@ stem worker resume --worker worker-a --queue default For a runnable lab that exercises ping/stats/revoke/shutdown against real workers, see `packages/stem/example/worker_control_lab` in the repository. +If you are working directly with low-level `ControlCommandMessage` and +`ControlReplyMessage` values in custom tooling, prefer +`payloadJson(...)` / `payloadVersionedJson(...)` and +`errorJson(...)` / `errorVersionedJson(...)` over manual map casts. + ## Autoscaling Concurrency Workers can autoscale their isolate pools between configured minimum and diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index f880eed1..6d163a2c 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.1 +- Added `ControlCommandMessage.payloadJson(...)`, + `payloadVersionedJson(...)`, and the matching `ControlReplyMessage` + payload/error helpers so low-level worker control tooling can decode DTO + payloads without raw map casts. - Added `WorkflowStepEvent.metadataJson(...)`, `metadataVersionedJson(...)`, `metadataPayloadJson(...)`, and the matching `WorkflowRuntimeEvent` helpers so workflow introspection metadata can decode diff --git a/packages/stem/lib/src/control/control_messages.dart b/packages/stem/lib/src/control/control_messages.dart index cc01f3b2..80b33eef 100644 --- a/packages/stem/lib/src/control/control_messages.dart +++ b/packages/stem/lib/src/control/control_messages.dart @@ -1,4 +1,6 @@ import 'package:stem/src/core/envelope.dart'; +import 'package:stem/src/core/payload_codec.dart'; +import 'package:stem/src/core/payload_map.dart'; /// Control-plane command dispatched to worker control queues. class ControlCommandMessage { @@ -34,6 +36,53 @@ class ControlCommandMessage { /// Arbitrary command payload. final Map payload; + /// Returns the decoded payload value for [key], or `null` when absent. + T? payloadValue(String key, {PayloadCodec? codec}) { + return payload.value(key, codec: codec); + } + + /// Returns the decoded payload value for [key], or [fallback] when absent. + T payloadValueOr(String key, T fallback, {PayloadCodec? codec}) { + return payload.valueOr(key, fallback, codec: codec); + } + + /// Returns the decoded payload value for [key], throwing when absent. + T requiredPayloadValue(String key, {PayloadCodec? codec}) { + return payload.requiredValue(key, codec: codec); + } + + /// Decodes the full payload as a typed DTO with [codec]. + T payloadAs({required PayloadCodec codec}) { + return codec.decode(payload); + } + + /// Decodes the full payload as a typed DTO with a JSON decoder. + T payloadJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(payload); + } + + /// Decodes the full payload as a typed DTO with a version-aware JSON + /// decoder. + T payloadVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(payload); + } + /// Optional timeout for the command, in milliseconds. final int? timeoutMs; @@ -81,9 +130,117 @@ class ControlReplyMessage { /// Arbitrary reply payload. final Map payload; + /// Returns the decoded payload value for [key], or `null` when absent. + T? payloadValue(String key, {PayloadCodec? codec}) { + return payload.value(key, codec: codec); + } + + /// Returns the decoded payload value for [key], or [fallback] when absent. + T payloadValueOr(String key, T fallback, {PayloadCodec? codec}) { + return payload.valueOr(key, fallback, codec: codec); + } + + /// Returns the decoded payload value for [key], throwing when absent. + T requiredPayloadValue(String key, {PayloadCodec? codec}) { + return payload.requiredValue(key, codec: codec); + } + + /// Decodes the full payload as a typed DTO with [codec]. + T payloadAs({required PayloadCodec codec}) { + return codec.decode(payload); + } + + /// Decodes the full payload as a typed DTO with a JSON decoder. + T payloadJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(payload); + } + + /// Decodes the full payload as a typed DTO with a version-aware JSON + /// decoder. + T payloadVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(payload); + } + /// Optional error payload. final Map? error; + /// Returns the decoded error value for [key], or `null` when absent. + T? errorValue(String key, {PayloadCodec? codec}) { + final payload = error; + if (payload == null) return null; + return payload.value(key, codec: codec); + } + + /// Returns the decoded error value for [key], or [fallback] when absent. + T errorValueOr(String key, T fallback, {PayloadCodec? codec}) { + final payload = error; + if (payload == null) return fallback; + return payload.valueOr(key, fallback, codec: codec); + } + + /// Returns the decoded error value for [key], throwing when absent. + T requiredErrorValue(String key, {PayloadCodec? codec}) { + final payload = error; + if (payload == null) { + throw StateError('ControlReplyMessage.error does not contain "$key".'); + } + return payload.requiredValue(key, codec: codec); + } + + /// Decodes the full error payload as a typed DTO with [codec]. + T? errorAs({required PayloadCodec codec}) { + final payload = error; + if (payload == null) return null; + return codec.decode(payload); + } + + /// Decodes the full error payload as a typed DTO with a JSON decoder. + T? errorJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + final payload = error; + if (payload == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(payload); + } + + /// Decodes the full error payload as a typed DTO with a version-aware JSON + /// decoder. + T? errorVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final payload = error; + if (payload == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(payload); + } + /// Serializes the reply into a map payload. Map toMap() => { 'requestId': requestId, diff --git a/packages/stem/test/unit/control/control_messages_test.dart b/packages/stem/test/unit/control/control_messages_test.dart new file mode 100644 index 00000000..6423643b --- /dev/null +++ b/packages/stem/test/unit/control/control_messages_test.dart @@ -0,0 +1,136 @@ +import 'package:stem/src/control/control_messages.dart'; +import 'package:stem/src/core/payload_codec.dart'; +import 'package:test/test.dart'; + +void main() { + test('ControlCommandMessage exposes typed payload helpers', () { + final command = ControlCommandMessage( + requestId: 'req-1', + type: 'pause', + targets: const ['*'], + payload: const { + PayloadCodec.versionKey: 2, + 'queue': 'priority', + 'paused': true, + }, + ); + + expect(command.payloadValue('queue'), 'priority'); + expect(command.payloadValueOr('missing', 'fallback'), 'fallback'); + expect(command.requiredPayloadValue('paused'), isTrue); + expect( + command.payloadJson<_ControlPayload>(decode: _ControlPayload.fromJson), + isA<_ControlPayload>() + .having((value) => value.queue, 'queue', 'priority') + .having((value) => value.paused, 'paused', isTrue), + ); + expect( + command.payloadVersionedJson<_ControlPayload>( + version: 2, + decode: _ControlPayload.fromVersionedJson, + ), + isA<_ControlPayload>() + .having((value) => value.queue, 'queue', 'priority') + .having((value) => value.paused, 'paused', isTrue), + ); + }); + + test('ControlReplyMessage exposes typed payload and error helpers', () { + final reply = ControlReplyMessage( + requestId: 'req-2', + workerId: 'worker-1', + status: 'error', + payload: const { + PayloadCodec.versionKey: 2, + 'queue': 'priority', + 'paused': true, + }, + error: const { + PayloadCodec.versionKey: 2, + 'code': 'pause_failed', + 'message': 'already paused', + }, + ); + + expect(reply.payloadValue('queue'), 'priority'); + expect(reply.payloadValueOr('missing', 'fallback'), 'fallback'); + expect(reply.requiredPayloadValue('paused'), isTrue); + expect( + reply.payloadJson<_ControlPayload>(decode: _ControlPayload.fromJson), + isA<_ControlPayload>() + .having((value) => value.queue, 'queue', 'priority') + .having((value) => value.paused, 'paused', isTrue), + ); + expect( + reply.payloadVersionedJson<_ControlPayload>( + version: 2, + decode: _ControlPayload.fromVersionedJson, + ), + isA<_ControlPayload>() + .having((value) => value.queue, 'queue', 'priority') + .having((value) => value.paused, 'paused', isTrue), + ); + expect(reply.errorValue('code'), 'pause_failed'); + expect(reply.errorValueOr('missing', 'fallback'), 'fallback'); + expect(reply.requiredErrorValue('message'), 'already paused'); + expect( + reply.errorJson<_ControlError>(decode: _ControlError.fromJson), + isA<_ControlError>() + .having((value) => value.code, 'code', 'pause_failed') + .having((value) => value.message, 'message', 'already paused'), + ); + expect( + reply.errorVersionedJson<_ControlError>( + version: 2, + decode: _ControlError.fromVersionedJson, + ), + isA<_ControlError>() + .having((value) => value.code, 'code', 'pause_failed') + .having((value) => value.message, 'message', 'already paused'), + ); + }); +} + +class _ControlPayload { + const _ControlPayload({required this.queue, required this.paused}); + + factory _ControlPayload.fromJson(Map json) { + return _ControlPayload( + queue: json['queue'] as String, + paused: json['paused'] as bool, + ); + } + + factory _ControlPayload.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _ControlPayload.fromJson(json); + } + + final String queue; + final bool paused; +} + +class _ControlError { + const _ControlError({required this.code, required this.message}); + + factory _ControlError.fromJson(Map json) { + return _ControlError( + code: json['code'] as String, + message: json['message'] as String, + ); + } + + factory _ControlError.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _ControlError.fromJson(json); + } + + final String code; + final String message; +} From c685ea5f74bf6acd649413a0c9a2c1023008d5f7 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 13:00:48 -0500 Subject: [PATCH 239/302] Add schedule and dead-letter payload helpers --- packages/stem/CHANGELOG.md | 4 + packages/stem/lib/src/core/contracts.dart | 128 ++++++++++++++ .../stem/test/unit/core/contracts_test.dart | 160 ++++++++++++++++++ 3 files changed, 292 insertions(+) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 6d163a2c..9aac55cb 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.1 +- Added `DeadLetterEntry.metaJson(...)`, `metaVersionedJson(...)`, + `ScheduleEntry.argsJson(...)`, `kwargsJson(...)`, and `metaJson(...)` so + DLQ and scheduler tooling can decode persisted DTO payloads without raw map + casts. - Added `ControlCommandMessage.payloadJson(...)`, `payloadVersionedJson(...)`, and the matching `ControlReplyMessage` payload/error helpers so low-level worker control tooling can decode DTO diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index caffc7b1..e4987642 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -696,6 +696,38 @@ class DeadLetterEntry { /// Additional metadata captured at failure time. final Map meta; + /// Decodes the full metadata payload as a typed DTO with [codec]. + T metaAs({required PayloadCodec codec}) { + return codec.decode(meta); + } + + /// Decodes the full metadata payload as a typed DTO with a JSON decoder. + T metaJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(meta); + } + + /// Decodes the full metadata payload as a typed DTO with a version-aware + /// JSON decoder. + T metaVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(meta); + } + /// Timestamp when the task was dead-lettered. final DateTime deadAt; @@ -889,9 +921,73 @@ class ScheduleEntry { /// Positional arguments to pass to the task. final Map args; + /// Decodes the full args payload as a typed DTO with [codec]. + T argsAs({required PayloadCodec codec}) { + return codec.decode(args); + } + + /// Decodes the full args payload as a typed DTO with a JSON decoder. + T argsJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(args); + } + + /// Decodes the full args payload as a typed DTO with a version-aware JSON + /// decoder. + T argsVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(args); + } + /// Keyword-style arguments passed to the task. final Map kwargs; + /// Decodes the full kwargs payload as a typed DTO with [codec]. + T kwargsAs({required PayloadCodec codec}) { + return codec.decode(kwargs); + } + + /// Decodes the full kwargs payload as a typed DTO with a JSON decoder. + T kwargsJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(kwargs); + } + + /// Decodes the full kwargs payload as a typed DTO with a version-aware JSON + /// decoder. + T kwargsVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(kwargs); + } + /// Whether this schedule entry is enabled. final bool enabled; @@ -937,6 +1033,38 @@ class ScheduleEntry { /// Additional metadata for this schedule entry. final Map meta; + /// Decodes the full metadata payload as a typed DTO with [codec]. + T metaAs({required PayloadCodec codec}) { + return codec.decode(meta); + } + + /// Decodes the full metadata payload as a typed DTO with a JSON decoder. + T metaJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(meta); + } + + /// Decodes the full metadata payload as a typed DTO with a version-aware + /// JSON decoder. + T metaVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(meta); + } + /// Optimistic locking version assigned by the underlying store. final int version; diff --git a/packages/stem/test/unit/core/contracts_test.dart b/packages/stem/test/unit/core/contracts_test.dart index 94accf2f..4de4c09c 100644 --- a/packages/stem/test/unit/core/contracts_test.dart +++ b/packages/stem/test/unit/core/contracts_test.dart @@ -335,6 +335,29 @@ void main() { expect(decoded.meta['trace'], equals('abc')); expect(decoded.deadAt, equals(DateTime.utc(2025))); }); + + test('exposes typed metadata helpers', () { + final entry = DeadLetterEntry( + envelope: Envelope(name: 'task', args: const {}), + deadAt: DateTime.utc(2025), + meta: const { + PayloadCodec.versionKey: 2, + 'trace': 'abc', + }, + ); + + expect( + entry.metaJson<_TraceMeta>(decode: _TraceMeta.fromJson), + isA<_TraceMeta>().having((value) => value.trace, 'trace', 'abc'), + ); + expect( + entry.metaVersionedJson<_TraceMeta>( + version: 2, + decode: _TraceMeta.fromVersionedJson, + ), + isA<_TraceMeta>().having((value) => value.trace, 'trace', 'abc'), + ); + }); }); group('DeadLetterPage/ReplayResult', () { @@ -466,6 +489,71 @@ void main() { expect(updated.lastError, isNull); expect(updated.enabled, isFalse); }); + + test('exposes typed args, kwargs, and metadata helpers', () { + final entry = ScheduleEntry( + id: 'schedule-typed', + taskName: 'task', + queue: 'default', + spec: IntervalScheduleSpec(every: const Duration(minutes: 1)), + args: const { + PayloadCodec.versionKey: 2, + 'value': 1, + }, + kwargs: const { + PayloadCodec.versionKey: 2, + 'label': 'nightly', + }, + meta: const { + PayloadCodec.versionKey: 2, + 'source': 'scheduler', + }, + ); + + expect( + entry.argsJson<_ScheduleArgs>(decode: _ScheduleArgs.fromJson), + isA<_ScheduleArgs>().having((value) => value.value, 'value', 1), + ); + expect( + entry.argsVersionedJson<_ScheduleArgs>( + version: 2, + decode: _ScheduleArgs.fromVersionedJson, + ), + isA<_ScheduleArgs>().having((value) => value.value, 'value', 1), + ); + expect( + entry.kwargsJson<_ScheduleKwargs>(decode: _ScheduleKwargs.fromJson), + isA<_ScheduleKwargs>().having( + (value) => value.label, + 'label', + 'nightly', + ), + ); + expect( + entry.kwargsVersionedJson<_ScheduleKwargs>( + version: 2, + decode: _ScheduleKwargs.fromVersionedJson, + ), + isA<_ScheduleKwargs>().having( + (value) => value.label, + 'label', + 'nightly', + ), + ); + expect( + entry.metaJson<_ScheduleMeta>(decode: _ScheduleMeta.fromJson), + isA<_ScheduleMeta>() + .having((value) => value.source, 'source', 'scheduler'), + ); + expect( + entry.metaVersionedJson<_ScheduleMeta>( + version: 2, + decode: _ScheduleMeta.fromVersionedJson, + ), + isA<_ScheduleMeta>() + .having((value) => value.source, 'source', 'scheduler'), + ); + }); }); test('ScheduleConflictException string includes metadata', () { @@ -516,3 +604,75 @@ class _ReceiptPayload { final String id; } + +class _TraceMeta { + const _TraceMeta({required this.trace}); + + factory _TraceMeta.fromJson(Map json) { + return _TraceMeta(trace: json['trace'] as String); + } + + factory _TraceMeta.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _TraceMeta.fromJson(json); + } + + final String trace; +} + +class _ScheduleArgs { + const _ScheduleArgs({required this.value}); + + factory _ScheduleArgs.fromJson(Map json) { + return _ScheduleArgs(value: json['value'] as int); + } + + factory _ScheduleArgs.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _ScheduleArgs.fromJson(json); + } + + final int value; +} + +class _ScheduleKwargs { + const _ScheduleKwargs({required this.label}); + + factory _ScheduleKwargs.fromJson(Map json) { + return _ScheduleKwargs(label: json['label'] as String); + } + + factory _ScheduleKwargs.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _ScheduleKwargs.fromJson(json); + } + + final String label; +} + +class _ScheduleMeta { + const _ScheduleMeta({required this.source}); + + factory _ScheduleMeta.fromJson(Map json) { + return _ScheduleMeta(source: json['source'] as String); + } + + factory _ScheduleMeta.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _ScheduleMeta.fromJson(json); + } + + final String source; +} From 0bc1b359453c70c45e0cc9f873ed5f58c6fb9fbe Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 13:02:47 -0500 Subject: [PATCH 240/302] Add worker heartbeat extras helpers --- .site/docs/core-concepts/observability.md | 3 + packages/stem/CHANGELOG.md | 3 + .../stem/lib/src/observability/heartbeat.dart | 50 +++++++++++++++ .../unit/observability/heartbeat_test.dart | 61 +++++++++++++++++++ 4 files changed, 117 insertions(+) diff --git a/.site/docs/core-concepts/observability.md b/.site/docs/core-concepts/observability.md index f5d9a4a8..a3f88e89 100644 --- a/.site/docs/core-concepts/observability.md +++ b/.site/docs/core-concepts/observability.md @@ -108,6 +108,9 @@ When worker events carry structured `data`, prefer `event.dataJson(...)`, `payload.responseAs(codec: ...)`, `payload.errorJson(...)`, `payload.errorVersionedJson(...)`, or `payload.errorAs(codec: ...)` instead of walking raw `response` / `error` maps. +Persisted worker heartbeats expose the same typed decode path on `extras` via +`heartbeat.extrasJson(...)`, `heartbeat.extrasVersionedJson(...)`, and +`heartbeat.extrasAs(codec: ...)`. ## Logging diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 9aac55cb..71877fcc 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Added `WorkerHeartbeat.extrasJson(...)`, + `extrasVersionedJson(...)`, and `extrasAs(codec: ...)` so persisted worker + heartbeat metadata can decode DTO payloads without raw map casts. - Added `DeadLetterEntry.metaJson(...)`, `metaVersionedJson(...)`, `ScheduleEntry.argsJson(...)`, `kwargsJson(...)`, and `metaJson(...)` so DLQ and scheduler tooling can decode persisted DTO payloads without raw map diff --git a/packages/stem/lib/src/observability/heartbeat.dart b/packages/stem/lib/src/observability/heartbeat.dart index e9e74145..9acfff1b 100644 --- a/packages/stem/lib/src/observability/heartbeat.dart +++ b/packages/stem/lib/src/observability/heartbeat.dart @@ -1,5 +1,8 @@ import 'dart:convert'; +import 'package:stem/src/core/payload_codec.dart'; +import 'package:stem/src/core/payload_map.dart'; + /// Structured payload describing worker state for external monitoring systems. class WorkerHeartbeat { /// Captures the current worker state at [timestamp] using optional [extras]. @@ -68,6 +71,53 @@ class WorkerHeartbeat { /// Additional metadata for downstream consumers. final Map extras; + /// Decodes the full extras payload as a typed DTO with [codec]. + T extrasAs({required PayloadCodec codec}) { + return codec.decode(extras); + } + + /// Decodes the full extras payload as a typed DTO with a JSON decoder. + T extrasJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(extras); + } + + /// Decodes the full extras payload as a typed DTO with a version-aware JSON + /// decoder. + T extrasVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(extras); + } + + /// Returns the decoded extras value for [key], or `null` when absent. + T? extraValue(String key, {PayloadCodec? codec}) { + return extras.value(key, codec: codec); + } + + /// Returns the decoded extras value for [key], or [fallback] when absent. + T extraValueOr(String key, T fallback, {PayloadCodec? codec}) { + return extras.valueOr(key, fallback, codec: codec); + } + + /// Returns the decoded extras value for [key], throwing when absent. + T requiredExtraValue(String key, {PayloadCodec? codec}) { + return extras.requiredValue(key, codec: codec); + } + /// Serializes this heartbeat into a JSON-ready map for transport or storage. Map toJson() => { 'workerId': workerId, diff --git a/packages/stem/test/unit/observability/heartbeat_test.dart b/packages/stem/test/unit/observability/heartbeat_test.dart index fee8c8a2..c267f0a2 100644 --- a/packages/stem/test/unit/observability/heartbeat_test.dart +++ b/packages/stem/test/unit/observability/heartbeat_test.dart @@ -30,4 +30,65 @@ void main() { expect(decoded.queues.first.inflight, equals(2)); expect(decoded.extras['host'], equals('app-01')); }); + + test('worker heartbeat exposes typed extras helpers', () { + final heartbeat = WorkerHeartbeat( + workerId: 'worker-1', + timestamp: DateTime.utc(2025), + isolateCount: 3, + inflight: 2, + queues: [QueueHeartbeat(name: 'default', inflight: 2)], + extras: const { + PayloadCodec.versionKey: 2, + 'env': 'test', + 'region': 'us-east-1', + }, + ); + + expect(heartbeat.extraValue('env'), 'test'); + expect( + heartbeat.extraValueOr('missing', 'fallback'), + 'fallback', + ); + expect(heartbeat.requiredExtraValue('region'), 'us-east-1'); + expect( + heartbeat.extrasJson<_HeartbeatExtras>( + decode: _HeartbeatExtras.fromJson, + ), + isA<_HeartbeatExtras>() + .having((value) => value.env, 'env', 'test') + .having((value) => value.region, 'region', 'us-east-1'), + ); + expect( + heartbeat.extrasVersionedJson<_HeartbeatExtras>( + version: 2, + decode: _HeartbeatExtras.fromVersionedJson, + ), + isA<_HeartbeatExtras>() + .having((value) => value.env, 'env', 'test') + .having((value) => value.region, 'region', 'us-east-1'), + ); + }); +} + +class _HeartbeatExtras { + const _HeartbeatExtras({required this.env, required this.region}); + + factory _HeartbeatExtras.fromJson(Map json) { + return _HeartbeatExtras( + env: json['env'] as String, + region: json['region'] as String, + ); + } + + factory _HeartbeatExtras.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _HeartbeatExtras.fromJson(json); + } + + final String env; + final String region; } From b4e564c6ac7e60bc291cb30832d265a323e34b99 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 13:06:32 -0500 Subject: [PATCH 241/302] Add workflow runtime payload helpers --- .site/docs/workflows/starting-and-waiting.md | 10 +- packages/stem/CHANGELOG.md | 4 + packages/stem/README.md | 10 +- .../stem/lib/src/workflow/core/run_state.dart | 117 +++++++++++ .../src/workflow/runtime/workflow_views.dart | 73 +++++++ .../workflow_metadata_views_test.dart | 194 +++++++++++++++++- 6 files changed, 399 insertions(+), 9 deletions(-) diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index c1895851..8a851f25 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -100,13 +100,15 @@ stored workflow result without another cast/closure. If you are inspecting the underlying `RunState` directly, use `state.resultJson(...)`, `state.resultAs(codec: ...)`, `state.resultVersionedJson(...)`, `state.suspensionPayloadJson(...)`, -`state.suspensionPayloadVersionedJson(...)`, or -`state.suspensionPayloadAs(codec: ...)` instead of manual raw-map casts. +`state.suspensionPayloadVersionedJson(...)`, +`state.lastErrorJson(...)`, `state.runtimeJson(...)`, +`state.cancellationDataJson(...)`, or `state.suspensionPayloadAs(codec: ...)` +instead of manual raw-map casts. Workflow run detail views expose the same convenience surface via `runView.resultJson(...)`, `runView.resultAs(codec: ...)`, `runView.resultVersionedJson(...)`, `runView.suspensionPayloadJson(...)`, -`runView.suspensionPayloadVersionedJson(...)`, and -`runView.suspensionPayloadAs(codec: ...)`. +`runView.suspensionPayloadVersionedJson(...)`, `runView.lastErrorJson(...)`, +`runView.runtimeJson(...)`, and `runView.suspensionPayloadAs(codec: ...)`. Checkpoint entries from `viewCheckpoints(...)` and `WorkflowCheckpointView.fromEntry(...)` expose the same surface via `entry.valueJson(...)`, `entry.valueVersionedJson(...)`, and diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 71877fcc..10d4c99a 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.1 +- Added `RunState.lastErrorJson(...)`, `runtimeJson(...)`, + `cancellationDataJson(...)`, and the matching `WorkflowRunView` helpers so + workflow inspection surfaces can decode error, runtime, and cancellation + DTOs without raw map casts. - Added `WorkerHeartbeat.extrasJson(...)`, `extrasVersionedJson(...)`, and `extrasAs(codec: ...)` so persisted worker heartbeat metadata can decode DTO payloads without raw map casts. diff --git a/packages/stem/README.md b/packages/stem/README.md index 98cfa2f8..42a0dcde 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -939,13 +939,15 @@ stored workflow result without another cast/closure. If you are inspecting the underlying `RunState` directly, use `state.resultJson(...)`, `state.resultAs(codec: ...)`, `state.resultVersionedJson(...)`, `state.suspensionPayloadJson(...)`, -`state.suspensionPayloadVersionedJson(...)`, or -`state.suspensionPayloadAs(codec: ...)` instead of manual raw-map casts. +`state.suspensionPayloadVersionedJson(...)`, +`state.lastErrorJson(...)`, `state.runtimeJson(...)`, +`state.cancellationDataJson(...)`, or `state.suspensionPayloadAs(codec: ...)` +instead of manual raw-map casts. Workflow run detail views expose the same convenience surface via `runView.resultJson(...)`, `runView.resultAs(codec: ...)`, `runView.resultVersionedJson(...)`, `runView.suspensionPayloadJson(...)`, -`runView.suspensionPayloadVersionedJson(...)`, and -`runView.suspensionPayloadAs(codec: ...)`. +`runView.suspensionPayloadVersionedJson(...)`, `runView.lastErrorJson(...)`, +`runView.runtimeJson(...)`, and `runView.suspensionPayloadAs(codec: ...)`. Checkpoint entries from `viewCheckpoints(...)` and `WorkflowCheckpointView.fromEntry(...)` expose the same surface via `entry.valueJson(...)`, `entry.valueVersionedJson(...)`, and diff --git a/packages/stem/lib/src/workflow/core/run_state.dart b/packages/stem/lib/src/workflow/core/run_state.dart index b172c27c..1edc692f 100644 --- a/packages/stem/lib/src/workflow/core/run_state.dart +++ b/packages/stem/lib/src/workflow/core/run_state.dart @@ -136,6 +136,44 @@ class RunState { /// Last error payload recorded for the run. final Map? lastError; + /// Decodes the last error payload with [codec], when present. + TError? lastErrorAs({required PayloadCodec codec}) { + final payload = lastError; + if (payload == null) return null; + return codec.decode(payload); + } + + /// Decodes the last error payload with a JSON decoder, when present. + TError? lastErrorJson({ + required TError Function(Map payload) decode, + String? typeName, + }) { + final payload = lastError; + if (payload == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(payload); + } + + /// Decodes the last error payload with a version-aware JSON decoder, when + /// present. + TError? lastErrorVersionedJson({ + required int version, + required TError Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final payload = lastError; + if (payload == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(payload); + } + /// Suspension metadata stored for the waiting step. final Map? suspensionData; @@ -154,6 +192,85 @@ class RunState { /// Metadata recorded when the run is cancelled (automatic or manual). final Map? cancellationData; + /// Decodes the runtime metadata payload with [codec]. + TRuntime runtimeAs({required PayloadCodec codec}) { + return codec.decode(runtimeMetadata.toJson()); + } + + /// Decodes the runtime metadata payload with a JSON decoder. + TRuntime runtimeJson({ + required TRuntime Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(runtimeMetadata.toJson()); + } + + /// Decodes the runtime metadata payload with a version-aware JSON decoder. + TRuntime runtimeVersionedJson({ + required int version, + required TRuntime Function( + Map payload, + int version, + ) + decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(runtimeMetadata.toJson()); + } + + /// Decodes the cancellation payload with [codec], when present. + TCancellation? cancellationDataAs({ + required PayloadCodec codec, + }) { + final payload = cancellationData; + if (payload == null) return null; + return codec.decode(payload); + } + + /// Decodes the cancellation payload with a JSON decoder, when present. + TCancellation? cancellationDataJson({ + required TCancellation Function(Map payload) decode, + String? typeName, + }) { + final payload = cancellationData; + if (payload == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(payload); + } + + /// Decodes the cancellation payload with a version-aware JSON decoder, when + /// present. + TCancellation? cancellationDataVersionedJson({ + required int version, + required TCancellation Function( + Map payload, + int version, + ) + decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final payload = cancellationData; + if (payload == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(payload); + } + static const _unset = Object(); /// Whether the run is in a terminal state. diff --git a/packages/stem/lib/src/workflow/runtime/workflow_views.dart b/packages/stem/lib/src/workflow/runtime/workflow_views.dart index 65ab6f64..7679b290 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_views.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_views.dart @@ -98,12 +98,85 @@ class WorkflowRunView { /// Last error payload, if present. final Map? lastError; + /// Decodes the last error payload with [codec], when present. + TError? lastErrorAs({required PayloadCodec codec}) { + final payload = lastError; + if (payload == null) return null; + return codec.decode(payload); + } + + /// Decodes the last error payload with a JSON decoder, when present. + TError? lastErrorJson({ + required TError Function(Map payload) decode, + String? typeName, + }) { + final payload = lastError; + if (payload == null) return null; + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(payload); + } + + /// Decodes the last error payload with a version-aware JSON decoder, when + /// present. + TError? lastErrorVersionedJson({ + required int version, + required TError Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + final payload = lastError; + if (payload == null) return null; + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(payload); + } + /// Public user-supplied workflow params. final Map params; /// Run-scoped runtime metadata (queues/channel/serialization framing). final Map runtime; + /// Decodes the runtime metadata payload with [codec]. + TRuntime runtimeAs({required PayloadCodec codec}) { + return codec.decode(runtime); + } + + /// Decodes the runtime metadata payload with a JSON decoder. + TRuntime runtimeJson({ + required TRuntime Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(runtime); + } + + /// Decodes the runtime metadata payload with a version-aware JSON decoder. + TRuntime runtimeVersionedJson({ + required int version, + required TRuntime Function( + Map payload, + int version, + ) + decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(runtime); + } + /// Suspension payload, if run is suspended. final Map? suspensionData; diff --git a/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart b/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart index de9a7962..3f48e928 100644 --- a/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart +++ b/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart @@ -108,6 +108,29 @@ void main() { expect(state.encryptionScope, equals('signed-envelope')); expect(state.encryptionEnabled, isTrue); expect(state.streamId, equals('invoice_run-2')); + expect( + state.runtimeJson<_RuntimePayload>(decode: _RuntimePayload.fromJson), + isA<_RuntimePayload>() + .having( + (value) => value.orchestrationQueue, + 'orchestrationQueue', + 'workflow', + ) + .having((value) => value.streamId, 'streamId', 'invoice_run-2'), + ); + expect( + state.runtimeVersionedJson<_RuntimePayload>( + version: 2, + decode: _RuntimePayload.fromVersionedJson, + ), + isA<_RuntimePayload>() + .having( + (value) => value.orchestrationQueue, + 'orchestrationQueue', + 'workflow', + ) + .having((value) => value.streamId, 'streamId', 'invoice_run-2'), + ); }); test('decodes raw result payloads as DTOs', () { @@ -143,6 +166,56 @@ void main() { ), ); }); + + test('decodes last-error and cancellation payloads as DTOs', () { + final state = RunState( + id: 'run-4', + workflow: 'invoice', + status: WorkflowStatus.cancelled, + cursor: 2, + params: const {'tenant': 'acme'}, + createdAt: DateTime.utc(2026, 2, 25), + lastError: const { + PayloadCodec.versionKey: 2, + 'message': 'boom', + }, + cancellationData: const { + PayloadCodec.versionKey: 2, + 'reason': 'manual', + }, + ); + + expect( + state.lastErrorJson<_WorkflowErrorPayload>( + decode: _WorkflowErrorPayload.fromJson, + ), + isA<_WorkflowErrorPayload>() + .having((value) => value.message, 'message', 'boom'), + ); + expect( + state.lastErrorVersionedJson<_WorkflowErrorPayload>( + version: 2, + decode: _WorkflowErrorPayload.fromVersionedJson, + ), + isA<_WorkflowErrorPayload>() + .having((value) => value.message, 'message', 'boom'), + ); + expect( + state.cancellationDataJson<_CancellationPayload>( + decode: _CancellationPayload.fromJson, + ), + isA<_CancellationPayload>() + .having((value) => value.reason, 'reason', 'manual'), + ); + expect( + state.cancellationDataVersionedJson<_CancellationPayload>( + version: 2, + decode: _CancellationPayload.fromVersionedJson, + ), + isA<_CancellationPayload>() + .having((value) => value.reason, 'reason', 'manual'), + ); + }); }); group('Workflow watcher metadata getters', () { @@ -291,9 +364,29 @@ void main() { workflow: 'invoice', status: WorkflowStatus.suspended, cursor: 2, - params: const {'tenant': 'acme'}, + params: const { + 'tenant': 'acme', + '__stem.workflow.runtime': { + PayloadCodec.versionKey: 2, + 'workflowId': 'abc123', + 'orchestrationQueue': 'workflow', + 'continuationQueue': 'workflow-continue', + 'executionQueue': 'workflow-step', + 'serializationFormat': 'json', + 'serializationVersion': '1', + 'frameFormat': 'stem-envelope', + 'frameVersion': '1', + 'encryptionScope': 'signed-envelope', + 'encryptionEnabled': true, + 'streamId': 'invoice_run-2', + }, + }, createdAt: DateTime.utc(2026, 2, 25), result: const {'invoiceId': 'inv-4'}, + lastError: const { + PayloadCodec.versionKey: 2, + 'message': 'boom', + }, suspensionData: const { 'type': 'event', 'payload': {'invoiceId': 'inv-5'}, @@ -341,6 +434,44 @@ void main() { 'inv-5', ), ); + expect( + view.lastErrorJson<_WorkflowErrorPayload>( + decode: _WorkflowErrorPayload.fromJson, + ), + isA<_WorkflowErrorPayload>() + .having((value) => value.message, 'message', 'boom'), + ); + expect( + view.lastErrorVersionedJson<_WorkflowErrorPayload>( + version: 2, + decode: _WorkflowErrorPayload.fromVersionedJson, + ), + isA<_WorkflowErrorPayload>() + .having((value) => value.message, 'message', 'boom'), + ); + expect( + view.runtimeJson<_RuntimePayload>(decode: _RuntimePayload.fromJson), + isA<_RuntimePayload>() + .having( + (value) => value.orchestrationQueue, + 'orchestrationQueue', + 'workflow', + ) + .having((value) => value.streamId, 'streamId', 'invoice_run-2'), + ); + expect( + view.runtimeVersionedJson<_RuntimePayload>( + version: 2, + decode: _RuntimePayload.fromVersionedJson, + ), + isA<_RuntimePayload>() + .having( + (value) => value.orchestrationQueue, + 'orchestrationQueue', + 'workflow', + ) + .having((value) => value.streamId, 'streamId', 'invoice_run-2'), + ); }); test('decodes checkpoint values as DTOs', () { @@ -395,3 +526,64 @@ class _InvoicePayload { final String invoiceId; } + +class _RuntimePayload { + const _RuntimePayload({ + required this.orchestrationQueue, + required this.streamId, + }); + + factory _RuntimePayload.fromJson(Map json) { + return _RuntimePayload( + orchestrationQueue: json['orchestrationQueue'] as String, + streamId: json['streamId'] as String, + ); + } + + factory _RuntimePayload.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _RuntimePayload.fromJson(json); + } + + final String orchestrationQueue; + final String streamId; +} + +class _WorkflowErrorPayload { + const _WorkflowErrorPayload({required this.message}); + + factory _WorkflowErrorPayload.fromJson(Map json) { + return _WorkflowErrorPayload(message: json['message'] as String); + } + + factory _WorkflowErrorPayload.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _WorkflowErrorPayload.fromJson(json); + } + + final String message; +} + +class _CancellationPayload { + const _CancellationPayload({required this.reason}); + + factory _CancellationPayload.fromJson(Map json) { + return _CancellationPayload(reason: json['reason'] as String); + } + + factory _CancellationPayload.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _CancellationPayload.fromJson(json); + } + + final String reason; +} From 4d23854d170409c2874756916daf0a686003629f Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 13:10:30 -0500 Subject: [PATCH 242/302] Add workflow run param decode helpers --- .site/docs/workflows/starting-and-waiting.md | 1 + packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 4 +++ .../src/workflow/runtime/workflow_views.dart | 31 +++++++++++++++++++ .../workflow_metadata_views_test.dart | 21 +++++++++++++ 5 files changed, 60 insertions(+) diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 8a851f25..80f72cc0 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -105,6 +105,7 @@ If you are inspecting the underlying `RunState` directly, use `state.cancellationDataJson(...)`, or `state.suspensionPayloadAs(codec: ...)` instead of manual raw-map casts. Workflow run detail views expose the same convenience surface via +`runView.paramsJson(...)`, `runView.paramsAs(codec: ...)`, `runView.resultJson(...)`, `runView.resultAs(codec: ...)`, `runView.resultVersionedJson(...)`, `runView.suspensionPayloadJson(...)`, `runView.suspensionPayloadVersionedJson(...)`, `runView.lastErrorJson(...)`, diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 10d4c99a..14561462 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Added `WorkflowRunView.paramsJson(...)`, + `paramsVersionedJson(...)`, and `paramsAs(codec: ...)` so workflow detail + views can decode stored workflow input DTOs without raw map casts. - Added `RunState.lastErrorJson(...)`, `runtimeJson(...)`, `cancellationDataJson(...)`, and the matching `WorkflowRunView` helpers so workflow inspection surfaces can decode error, runtime, and cancellation diff --git a/packages/stem/README.md b/packages/stem/README.md index 42a0dcde..47bb86b6 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -944,6 +944,7 @@ If you are inspecting the underlying `RunState` directly, use `state.cancellationDataJson(...)`, or `state.suspensionPayloadAs(codec: ...)` instead of manual raw-map casts. Workflow run detail views expose the same convenience surface via +`runView.paramsJson(...)`, `runView.paramsAs(codec: ...)`, `runView.resultJson(...)`, `runView.resultAs(codec: ...)`, `runView.resultVersionedJson(...)`, `runView.suspensionPayloadJson(...)`, `runView.suspensionPayloadVersionedJson(...)`, `runView.lastErrorJson(...)`, @@ -1032,6 +1033,9 @@ cast/closure. If you already have a raw `TaskResult`, use `result.payloadJson(...)` or `result.payloadAs(codec: ...)` to decode the stored task result DTO without another cast/closure. +If you are inspecting a low-level `TaskError`, use `error.metaJson(...)`, +`error.metaVersionedJson(...)`, or `error.metaAs(codec: ...)` instead of +manual `error.meta[...]` casts. Generated annotated tasks use the same surface: diff --git a/packages/stem/lib/src/workflow/runtime/workflow_views.dart b/packages/stem/lib/src/workflow/runtime/workflow_views.dart index 7679b290..5b46520a 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_views.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_views.dart @@ -139,6 +139,37 @@ class WorkflowRunView { /// Public user-supplied workflow params. final Map params; + /// Decodes the workflow params payload with [codec]. + TParams paramsAs({required PayloadCodec codec}) { + return codec.decode(params); + } + + /// Decodes the workflow params payload with a JSON decoder. + TParams paramsJson({ + required TParams Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(params); + } + + /// Decodes the workflow params payload with a version-aware JSON decoder. + TParams paramsVersionedJson({ + required int version, + required TParams Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(params); + } + /// Run-scoped runtime metadata (queues/channel/serialization framing). final Map runtime; diff --git a/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart b/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart index 3f48e928..b7e9ca3b 100644 --- a/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart +++ b/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart @@ -365,6 +365,8 @@ void main() { status: WorkflowStatus.suspended, cursor: 2, params: const { + PayloadCodec.versionKey: 2, + 'invoiceId': 'inv-params', 'tenant': 'acme', '__stem.workflow.runtime': { PayloadCodec.versionKey: 2, @@ -394,6 +396,25 @@ void main() { ); final view = WorkflowRunView.fromState(state); + expect( + view.paramsJson<_InvoicePayload>(decode: _InvoicePayload.fromJson), + isA<_InvoicePayload>().having( + (value) => value.invoiceId, + 'invoiceId', + 'inv-params', + ), + ); + expect( + view.paramsVersionedJson<_InvoicePayload>( + version: 2, + decode: _InvoicePayload.fromVersionedJson, + ), + isA<_InvoicePayload>().having( + (value) => value.invoiceId, + 'invoiceId', + 'inv-params', + ), + ); expect( view.resultJson<_InvoicePayload>(decode: _InvoicePayload.fromJson), isA<_InvoicePayload>().having( From 53202157bfa9409d7633f7ff3747294852e15362 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 13:10:59 -0500 Subject: [PATCH 243/302] Add task error metadata helpers --- .site/docs/core-concepts/tasks.md | 3 ++ packages/stem/CHANGELOG.md | 3 ++ packages/stem/lib/src/core/contracts.dart | 33 +++++++++++++++ .../stem/test/unit/core/contracts_test.dart | 41 +++++++++++++++++++ 4 files changed, 80 insertions(+) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 5dde86b6..bff542b4 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -64,6 +64,9 @@ If you already have a raw `TaskResult`, use `result.payloadJson(...)` or `result.payloadAs(codec: ...)` to decode the stored task result DTO without another cast/closure. Use `result.payloadVersionedJson(...)` for the same versioned DTO path on persisted task results. +If you are inspecting a low-level `TaskError`, use `error.metaJson(...)`, +`error.metaVersionedJson(...)`, or `error.metaAs(codec: ...)` instead of +manual `error.meta[...]` casts. If your manual task args are DTOs, prefer `TaskDefinition.json(...)` when the type already has `toJson()`. Use `TaskDefinition.versionedJson(...)` diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 14561462..62e56002 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Added `TaskError.metaJson(...)`, `metaVersionedJson(...)`, and + `metaAs(codec: ...)` so low-level task failure metadata can decode DTO + payloads without raw map casts. - Added `WorkflowRunView.paramsJson(...)`, `paramsVersionedJson(...)`, and `paramsAs(codec: ...)` so workflow detail views can decode stored workflow input DTOs without raw map casts. diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index e4987642..9e10f576 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -624,6 +624,39 @@ class TaskError { /// Additional metadata for this error. final Map meta; + /// Decodes the full error metadata payload as a typed DTO with [codec]. + T metaAs({required PayloadCodec codec}) { + return codec.decode(meta); + } + + /// Decodes the full error metadata payload as a typed DTO with a JSON + /// decoder. + T metaJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(meta); + } + + /// Decodes the full error metadata payload as a typed DTO with a + /// version-aware JSON decoder. + T metaVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(meta); + } + /// Serializes the error metadata to JSON. Map toJson() => { 'type': type, diff --git a/packages/stem/test/unit/core/contracts_test.dart b/packages/stem/test/unit/core/contracts_test.dart index 4de4c09c..10a69122 100644 --- a/packages/stem/test/unit/core/contracts_test.dart +++ b/packages/stem/test/unit/core/contracts_test.dart @@ -204,6 +204,29 @@ void main() { ); }); + test('error metadata helpers decode structured values', () { + const error = TaskError( + type: 'Boom', + message: 'fail', + meta: { + PayloadCodec.versionKey: 2, + 'queue': 'default', + }, + ); + + expect( + error.metaJson<_ErrorMeta>(decode: _ErrorMeta.fromJson), + isA<_ErrorMeta>().having((value) => value.queue, 'queue', 'default'), + ); + expect( + error.metaVersionedJson<_ErrorMeta>( + version: 2, + decode: _ErrorMeta.fromVersionedJson, + ), + isA<_ErrorMeta>().having((value) => value.queue, 'queue', 'default'), + ); + }); + test('requiredPayloadValue throws when payload is absent', () { final status = TaskStatus( id: 'task-5', @@ -676,3 +699,21 @@ class _ScheduleMeta { final String source; } + +class _ErrorMeta { + const _ErrorMeta({required this.queue}); + + factory _ErrorMeta.fromJson(Map json) { + return _ErrorMeta(queue: json['queue'] as String); + } + + factory _ErrorMeta.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _ErrorMeta.fromJson(json); + } + + final String queue; +} From 40af1a0619d504d118dd1d9dc94318b29756af92 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 13:13:05 -0500 Subject: [PATCH 244/302] Add run state param decode helpers --- .site/docs/workflows/starting-and-waiting.md | 1 + packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 1 + .../stem/lib/src/workflow/core/run_state.dart | 31 +++++++++++++++++++ .../workflow_metadata_views_test.dart | 29 +++++++++++++++++ 5 files changed, 65 insertions(+) diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 80f72cc0..990f2b41 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -98,6 +98,7 @@ If you already have a raw `WorkflowResult`, use `result.payloadJson(...)` or `result.payloadAs(codec: ...)` to decode the stored workflow result without another cast/closure. If you are inspecting the underlying `RunState` directly, use +`state.paramsJson(...)`, `state.paramsAs(codec: ...)`, `state.resultJson(...)`, `state.resultAs(codec: ...)`, `state.resultVersionedJson(...)`, `state.suspensionPayloadJson(...)`, `state.suspensionPayloadVersionedJson(...)`, diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 62e56002..0e09753d 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Added `RunState.paramsJson(...)`, `paramsVersionedJson(...)`, and + `paramsAs(codec: ...)` so low-level workflow run snapshots can decode + stored workflow input DTOs without raw map casts. - Added `TaskError.metaJson(...)`, `metaVersionedJson(...)`, and `metaAs(codec: ...)` so low-level task failure metadata can decode DTO payloads without raw map casts. diff --git a/packages/stem/README.md b/packages/stem/README.md index 47bb86b6..b84a03f0 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -937,6 +937,7 @@ If you already have a raw `WorkflowResult`, use `result.payloadJson(...)` or `result.payloadAs(codec: ...)` to decode the stored workflow result without another cast/closure. If you are inspecting the underlying `RunState` directly, use +`state.paramsJson(...)`, `state.paramsAs(codec: ...)`, `state.resultJson(...)`, `state.resultAs(codec: ...)`, `state.resultVersionedJson(...)`, `state.suspensionPayloadJson(...)`, `state.suspensionPayloadVersionedJson(...)`, diff --git a/packages/stem/lib/src/workflow/core/run_state.dart b/packages/stem/lib/src/workflow/core/run_state.dart index 1edc692f..777157d1 100644 --- a/packages/stem/lib/src/workflow/core/run_state.dart +++ b/packages/stem/lib/src/workflow/core/run_state.dart @@ -76,6 +76,37 @@ class RunState { Map get workflowParams => WorkflowRunRuntimeMetadata.stripFromParams(params); + /// Decodes the workflow params payload with [codec]. + TParams paramsAs({required PayloadCodec codec}) { + return codec.decode(workflowParams); + } + + /// Decodes the workflow params payload with a JSON decoder. + TParams paramsJson({ + required TParams Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(workflowParams); + } + + /// Decodes the workflow params payload with a version-aware JSON decoder. + TParams paramsVersionedJson({ + required int version, + required TParams Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(workflowParams); + } + /// Run-scoped runtime metadata. WorkflowRunRuntimeMetadata get runtimeMetadata => WorkflowRunRuntimeMetadata.fromParams(params); diff --git a/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart b/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart index b7e9ca3b..324772a8 100644 --- a/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart +++ b/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart @@ -98,6 +98,17 @@ void main() { ); expect(state.workflowParams, equals(const {'tenant': 'acme'})); + expect( + state.paramsJson<_TenantPayload>(decode: _TenantPayload.fromJson), + isA<_TenantPayload>().having((value) => value.tenant, 'tenant', 'acme'), + ); + expect( + state.paramsVersionedJson<_TenantPayload>( + version: 2, + decode: _TenantPayload.fromVersionedJson, + ), + isA<_TenantPayload>().having((value) => value.tenant, 'tenant', 'acme'), + ); expect(state.orchestrationQueue, equals('workflow')); expect(state.continuationQueue, equals('workflow-continue')); expect(state.executionQueue, equals('workflow-step')); @@ -548,6 +559,24 @@ class _InvoicePayload { final String invoiceId; } +class _TenantPayload { + const _TenantPayload({required this.tenant}); + + factory _TenantPayload.fromJson(Map json) { + return _TenantPayload(tenant: json['tenant'] as String); + } + + factory _TenantPayload.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _TenantPayload.fromJson(json); + } + + final String tenant; +} + class _RuntimePayload { const _RuntimePayload({ required this.orchestrationQueue, From d9ee469ff915c0abdce439f5799d2d737d388d10 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 13:15:22 -0500 Subject: [PATCH 245/302] Add workflow watcher data decode helpers --- .../docs/workflows/suspensions-and-events.md | 4 +- packages/stem/CHANGELOG.md | 4 ++ packages/stem/README.md | 5 +- .../src/workflow/core/workflow_watcher.dart | 62 +++++++++++++++++++ .../workflow_metadata_views_test.dart | 59 ++++++++++++++++++ 5 files changed, 131 insertions(+), 3 deletions(-) diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index 00ba15cd..713482da 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -23,7 +23,9 @@ await ctx.sleepFor(duration: const Duration(milliseconds: 200)); `awaitEvent(topic, deadline: ...)` records a durable watcher. External code can resume those runs through the runtime API by emitting a payload for the topic. -When you inspect watcher entries directly, use `watcher.payloadJson(...)` or +When you inspect watcher entries directly, use `watcher.dataJson(...)` or +`watcher.dataAs(codec: ...)` when the full watcher metadata maps to one DTO. +If only the nested watcher payload is a DTO, use `watcher.payloadJson(...)` or `watcher.payloadAs(codec: ...)` instead of manual raw-map casts. Typical flow: diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 0e09753d..fb29f8b0 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.1 +- Added `WorkflowWatcher.dataJson(...)`, `dataVersionedJson(...)`, + `dataAs(codec: ...)`, and the matching `WorkflowWatcherResolution` + `resumeData...` helpers so watcher inspection can decode full stored watcher + metadata DTOs without raw map casts. - Added `RunState.paramsJson(...)`, `paramsVersionedJson(...)`, and `paramsAs(codec: ...)` so low-level workflow run snapshots can decode stored workflow input DTOs without raw map casts. diff --git a/packages/stem/README.md b/packages/stem/README.md index b84a03f0..6ed837b8 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -1232,8 +1232,9 @@ backend metadata under `stem.unique.duplicates`. registers the run in the store so the next emitted payload is persisted atomically and delivered exactly once on resume. Operators can inspect suspended runs via `WorkflowStore.listWatchers` or `runsWaitingOn`. When the - watcher payload is a DTO, prefer `watcher.payloadJson(...)` or - `watcher.payloadAs(codec: ...)`. + watcher metadata is one DTO, prefer `watcher.dataJson(...)` or + `watcher.dataAs(codec: ...)`. When only the nested watcher payload is a DTO, + use `watcher.payloadJson(...)` or `watcher.payloadAs(codec: ...)`. - Checkpoints act as heartbeats. Every successful `saveStep` refreshes the run's `updatedAt` timestamp so operators (and future reclaim logic) can distinguish actively-owned runs from ones that need recovery. diff --git a/packages/stem/lib/src/workflow/core/workflow_watcher.dart b/packages/stem/lib/src/workflow/core/workflow_watcher.dart index db45e4f2..7b48a00d 100644 --- a/packages/stem/lib/src/workflow/core/workflow_watcher.dart +++ b/packages/stem/lib/src/workflow/core/workflow_watcher.dart @@ -43,6 +43,37 @@ class WorkflowWatcher { /// Additional metadata supplied when the watcher was registered. final Map data; + /// Decodes the full watcher metadata with [codec]. + TData dataAs({required PayloadCodec codec}) { + return codec.decode(data); + } + + /// Decodes the full watcher metadata with a JSON decoder. + TData dataJson({ + required TData Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(data); + } + + /// Decodes the full watcher metadata with a version-aware JSON decoder. + TData dataVersionedJson({ + required int version, + required TData Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(data); + } + /// Suspension type (`sleep`, `event`, etc.) when recorded by runtime. String? get suspensionType => data['type']?.toString(); @@ -152,6 +183,37 @@ class WorkflowWatcherResolution { /// Resume data merged from stored metadata and event payload. final Map resumeData; + /// Decodes the full resume data payload with [codec]. + TData resumeDataAs({required PayloadCodec codec}) { + return codec.decode(resumeData); + } + + /// Decodes the full resume data payload with a JSON decoder. + TData resumeDataJson({ + required TData Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(resumeData); + } + + /// Decodes the full resume data payload with a version-aware JSON decoder. + TData resumeDataVersionedJson({ + required int version, + required TData Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(resumeData); + } + /// Suspension type (`sleep`, `event`, etc.) propagated to resume payload. String? get suspensionType => resumeData['type']?.toString(); diff --git a/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart b/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart index 324772a8..5a4f9f23 100644 --- a/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart +++ b/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart @@ -238,10 +238,12 @@ void main() { createdAt: DateTime.utc(2026, 2, 25), deadline: DateTime.utc(2026, 2, 25, 0, 15), data: const { + PayloadCodec.versionKey: 2, 'type': 'event', 'iteration': 2, 'iterationStep': 'approval#2', 'payload': {'invoiceId': 'inv-1'}, + 'topic': 'invoice.approved', 'suspendedAt': '2026-02-25T00:01:00Z', 'requestedResumeAt': '2026-02-25T00:02:00Z', 'policyDeadline': '2026-02-25T00:15:00Z', @@ -252,10 +254,12 @@ void main() { stepName: 'awaitApproval', topic: 'invoice.approved', resumeData: const { + PayloadCodec.versionKey: 2, 'type': 'event', 'iteration': 2, 'iterationStep': 'approval#2', 'payload': {'invoiceId': 'inv-1'}, + 'topic': 'invoice.approved', 'deliveredAt': '2026-02-25T00:01:30Z', }, ); @@ -285,6 +289,21 @@ void main() { 'inv-1', ), ); + expect( + watcher.dataJson<_WatcherMetadata>(decode: _WatcherMetadata.fromJson), + isA<_WatcherMetadata>() + .having((value) => value.topic, 'topic', 'invoice.approved') + .having((value) => value.invoiceId, 'invoiceId', 'inv-1'), + ); + expect( + watcher.dataVersionedJson<_WatcherMetadata>( + version: 2, + decode: _WatcherMetadata.fromVersionedJson, + ), + isA<_WatcherMetadata>() + .having((value) => value.topic, 'topic', 'invoice.approved') + .having((value) => value.invoiceId, 'invoiceId', 'inv-1'), + ); expect(watcher.suspendedAt, equals(DateTime.utc(2026, 2, 25, 0, 1))); expect( watcher.requestedResumeAt, @@ -320,6 +339,23 @@ void main() { 'inv-1', ), ); + expect( + resolution.resumeDataJson<_WatcherMetadata>( + decode: _WatcherMetadata.fromJson, + ), + isA<_WatcherMetadata>() + .having((value) => value.topic, 'topic', 'invoice.approved') + .having((value) => value.invoiceId, 'invoiceId', 'inv-1'), + ); + expect( + resolution.resumeDataVersionedJson<_WatcherMetadata>( + version: 2, + decode: _WatcherMetadata.fromVersionedJson, + ), + isA<_WatcherMetadata>() + .having((value) => value.topic, 'topic', 'invoice.approved') + .having((value) => value.invoiceId, 'invoiceId', 'inv-1'), + ); expect( resolution.deliveredAt, equals(DateTime.utc(2026, 2, 25, 0, 1, 30)), @@ -577,6 +613,29 @@ class _TenantPayload { final String tenant; } +class _WatcherMetadata { + const _WatcherMetadata({required this.topic, required this.invoiceId}); + + factory _WatcherMetadata.fromJson(Map json) { + final payload = json['payload'] as Map; + return _WatcherMetadata( + topic: json['topic'] as String, + invoiceId: payload['invoiceId'] as String, + ); + } + + factory _WatcherMetadata.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _WatcherMetadata.fromJson(json); + } + + final String topic; + final String invoiceId; +} + class _RuntimePayload { const _RuntimePayload({ required this.orchestrationQueue, From 6a85fbaaf3496892b01efdcf6850c468eacb0a4b Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 13:17:40 -0500 Subject: [PATCH 246/302] Add task status metadata helpers --- .site/docs/core-concepts/tasks.md | 4 +- packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 4 +- packages/stem/lib/src/core/contracts.dart | 31 +++++++++++++++ .../stem/test/unit/core/contracts_test.dart | 39 +++++++++++++++++++ 5 files changed, 79 insertions(+), 2 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index bff542b4..c8ec9333 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -59,7 +59,9 @@ persists an explicit schema version. If you already have a raw `TaskStatus`, use `status.payloadJson(...)` or `status.payloadAs(codec: ...)` to decode the whole payload DTO without a separate cast/closure. Use `status.payloadVersionedJson(...)` when the stored -payload carries an explicit `__stemPayloadVersion`. +payload carries an explicit `__stemPayloadVersion`. If the whole task metadata +map is one DTO, use `status.metaJson(...)` or `status.metaAs(codec: ...)` +instead of manual `status.meta[...]` casts. If you already have a raw `TaskResult`, use `result.payloadJson(...)` or `result.payloadAs(codec: ...)` to decode the stored task result DTO without another cast/closure. Use `result.payloadVersionedJson(...)` for the diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index fb29f8b0..f2975e5c 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Added `TaskStatus.metaJson(...)`, `metaVersionedJson(...)`, and + `metaAs(codec: ...)` so low-level task status metadata can decode DTO + payloads without raw map casts. - Added `WorkflowWatcher.dataJson(...)`, `dataVersionedJson(...)`, `dataAs(codec: ...)`, and the matching `WorkflowWatcherResolution` `resumeData...` helpers so watcher inspection can decode full stored watcher diff --git a/packages/stem/README.md b/packages/stem/README.md index 6ed837b8..b60f6a1d 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -1030,7 +1030,9 @@ Use `waitFor(...)` when you need to keep the task id for inspection or pass it through another boundary before waiting. If you already have a raw `TaskStatus`, use `status.payloadJson(...)` or `status.payloadAs(codec: ...)` to decode the whole payload DTO without another -cast/closure. +cast/closure. If the whole task metadata map is one DTO, use +`status.metaJson(...)` or `status.metaAs(codec: ...)` instead of manual +`status.meta[...]` casts. If you already have a raw `TaskResult`, use `result.payloadJson(...)` or `result.payloadAs(codec: ...)` to decode the stored task result DTO without another cast/closure. diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 9e10f576..83f92382 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -327,6 +327,37 @@ class TaskStatus { /// Additional metadata for this task status. final Map meta; + /// Decodes the full task metadata payload with [codec]. + T metaAs({required PayloadCodec codec}) { + return codec.decode(meta); + } + + /// Decodes the full task metadata payload with a JSON decoder. + T metaJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(meta); + } + + /// Decodes the full task metadata payload with a version-aware JSON decoder. + T metaVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(meta); + } + /// The attempt number for this task execution. final int attempt; diff --git a/packages/stem/test/unit/core/contracts_test.dart b/packages/stem/test/unit/core/contracts_test.dart index 10a69122..402c3c8b 100644 --- a/packages/stem/test/unit/core/contracts_test.dart +++ b/packages/stem/test/unit/core/contracts_test.dart @@ -90,6 +90,7 @@ void main() { state: TaskState.failed, attempt: 1, meta: const { + PayloadCodec.versionKey: 2, 'task': 'email.send', 'queue': 'critical', 'namespace': 'acme', @@ -122,6 +123,22 @@ void main() { }, ); + expect( + status.metaJson<_TaskStatusMeta>(decode: _TaskStatusMeta.fromJson), + isA<_TaskStatusMeta>() + .having((value) => value.task, 'task', 'email.send') + .having((value) => value.queue, 'queue', 'critical'), + ); + expect( + status.metaVersionedJson<_TaskStatusMeta>( + version: 2, + decode: _TaskStatusMeta.fromVersionedJson, + ), + isA<_TaskStatusMeta>() + .having((value) => value.task, 'task', 'email.send') + .having((value) => value.queue, 'queue', 'critical'), + ); + expect(status.taskName, equals('email.send')); expect(status.queueName, equals('critical')); expect(status.namespace, equals('acme')); @@ -717,3 +734,25 @@ class _ErrorMeta { final String queue; } + +class _TaskStatusMeta { + const _TaskStatusMeta({required this.task, required this.queue}); + + factory _TaskStatusMeta.fromJson(Map json) { + return _TaskStatusMeta( + task: json['task'] as String, + queue: json['queue'] as String, + ); + } + + factory _TaskStatusMeta.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _TaskStatusMeta.fromJson(json); + } + + final String task; + final String queue; +} From a58538080cf00eecc0802ffeb19fe3a1145fcf35 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 13:20:25 -0500 Subject: [PATCH 247/302] Add queue event metadata helpers --- .site/docs/core-concepts/queue-events.md | 3 ++ packages/stem/CHANGELOG.md | 3 ++ packages/stem/lib/src/core/queue_events.dart | 32 +++++++++++++++ .../test/unit/core/queue_events_test.dart | 39 ++++++++++++++++++- 4 files changed, 76 insertions(+), 1 deletion(-) diff --git a/.site/docs/core-concepts/queue-events.md b/.site/docs/core-concepts/queue-events.md index 6e8f2d3c..057dd291 100644 --- a/.site/docs/core-concepts/queue-events.md +++ b/.site/docs/core-concepts/queue-events.md @@ -26,6 +26,9 @@ payload fields instead of repeating raw `payload['key']` casts. If one queue event maps to one DTO, use `event.payloadJson(...)`, `event.payloadVersionedJson(...)`, or `event.payloadAs(codec: ...)` to decode the whole payload in one step. +If the whole queue-event metadata map is one DTO, use `event.metaJson(...)`, +`event.metaVersionedJson(...)`, or `event.metaAs(codec: ...)` instead of +manual `event.meta[...]` casts. ## Producer + Listener diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index f2975e5c..2a645fa3 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Added `QueueCustomEvent.metaJson(...)`, `metaVersionedJson(...)`, and + `metaAs(codec: ...)` so queue-event metadata can decode DTO payloads + without raw map casts. - Added `TaskStatus.metaJson(...)`, `metaVersionedJson(...)`, and `metaAs(codec: ...)` so low-level task status metadata can decode DTO payloads without raw map casts. diff --git a/packages/stem/lib/src/core/queue_events.dart b/packages/stem/lib/src/core/queue_events.dart index db1076b4..1d98be4b 100644 --- a/packages/stem/lib/src/core/queue_events.dart +++ b/packages/stem/lib/src/core/queue_events.dart @@ -46,6 +46,38 @@ class QueueCustomEvent implements StemEvent { /// Additional metadata supplied by the publisher. final Map meta; + /// Decodes the full event metadata payload with [codec]. + T metaAs({required PayloadCodec codec}) { + return codec.decode(meta); + } + + /// Decodes the full event metadata payload with a JSON decoder. + T metaJson({ + required T Function(Map payload) decode, + String? typeName, + }) { + return PayloadCodec.json( + decode: decode, + typeName: typeName, + ).decode(meta); + } + + /// Decodes the full event metadata payload with a version-aware JSON + /// decoder. + T metaVersionedJson({ + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ).decode(meta); + } + /// Returns the decoded payload value for [key], or `null` when it is absent. T? payloadValue(String key, {PayloadCodec? codec}) { return payload.value(key, codec: codec); diff --git a/packages/stem/test/unit/core/queue_events_test.dart b/packages/stem/test/unit/core/queue_events_test.dart index 7745edc4..cfefec35 100644 --- a/packages/stem/test/unit/core/queue_events_test.dart +++ b/packages/stem/test/unit/core/queue_events_test.dart @@ -40,7 +40,7 @@ void main() { 'order.created', payload: const {'orderId': 'o-1'}, headers: const {'x-source': 'test'}, - meta: const {'tenant': 'acme'}, + meta: const {PayloadCodec.versionKey: 2, 'tenant': 'acme'}, ); final event = await received; @@ -50,6 +50,25 @@ void main() { expect(event.requiredPayloadValue('orderId'), 'o-1'); expect(event.headers['x-source'], 'test'); expect(event.meta['tenant'], 'acme'); + expect( + event.metaJson<_QueueEventMeta>(decode: _QueueEventMeta.fromJson), + isA<_QueueEventMeta>().having( + (value) => value.tenant, + 'tenant', + 'acme', + ), + ); + expect( + event.metaVersionedJson<_QueueEventMeta>( + version: 2, + decode: _QueueEventMeta.fromVersionedJson, + ), + isA<_QueueEventMeta>().having( + (value) => value.tenant, + 'tenant', + 'acme', + ), + ); }); test('ignores events from other queues', () async { @@ -244,3 +263,21 @@ class _QueueEventPayload { 'status': status, }; } + +class _QueueEventMeta { + const _QueueEventMeta({required this.tenant}); + + factory _QueueEventMeta.fromJson(Map json) { + return _QueueEventMeta(tenant: json['tenant'] as String); + } + + factory _QueueEventMeta.fromVersionedJson( + Map json, + int version, + ) { + expect(version, 2); + return _QueueEventMeta.fromJson(json); + } + + final String tenant; +} From 10b5640d18a000f3894adbf68ba691bd7c1b993d Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 13:23:07 -0500 Subject: [PATCH 248/302] Clarify direct workflow and task happy paths --- .site/docs/core-concepts/tasks.md | 4 +++- .site/docs/workflows/flows-and-scripts.md | 11 +++++++---- .site/docs/workflows/starting-and-waiting.md | 6 ++++-- packages/stem/CHANGELOG.md | 4 ++++ packages/stem/README.md | 4 ++++ 5 files changed, 22 insertions(+), 7 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index c8ec9333..0015d9ea 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -100,7 +100,9 @@ final request = context.argsJson( definitions can now create a fluent builder directly through `definition.prepareEnqueue(...)`. `TaskEnqueuer.prepareEnqueue(...)` remains available when you want the caller-bound variant that keeps the enqueue target -attached to the builder. +attached to the builder. Treat both `prepareEnqueue(...)` forms as the +advanced override path; for the normal case, prefer direct +`enqueue(...)` / `enqueueAndWait(...)`. For tasks with no producer inputs, use `TaskDefinition.noArgs(...)` instead. That gives you direct `enqueue(...)` / diff --git a/.site/docs/workflows/flows-and-scripts.md b/.site/docs/workflows/flows-and-scripts.md index 7de22fe0..b9951bd8 100644 --- a/.site/docs/workflows/flows-and-scripts.md +++ b/.site/docs/workflows/flows-and-scripts.md @@ -36,7 +36,9 @@ final approvalsRef = approvalsFlow.ref>( ``` When a flow has no start params, start directly from the flow itself with -`flow.start(...)`, `flow.startAndWait(...)`, or `flow.prepareStart()`. +`flow.start(...)` or `flow.startAndWait(...)`. Keep `flow.prepareStart()` for +the rarer cases where you want to assemble overrides incrementally before +dispatch. Use `ref0()` only when another API specifically needs a `NoArgsWorkflowRef`. Use `Flow` when: @@ -60,9 +62,10 @@ final retryRef = retryScript.ref>( ``` When a script has no start params, start directly from the script itself with -`retryScript.start(...)`, `retryScript.startAndWait(...)`, or -`retryScript.prepareStart()`. Use `ref0()` only when another API specifically -needs a `NoArgsWorkflowRef`. +`retryScript.start(...)` or `retryScript.startAndWait(...)`. Keep +`retryScript.prepareStart()` for the rarer cases where you want to assemble +overrides incrementally before dispatch. Use `ref0()` only when another API +specifically needs a `NoArgsWorkflowRef`. Use `WorkflowScript` when: diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 990f2b41..a565f050 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -81,8 +81,10 @@ When the persisted workflow result or suspension payload carries an explicit `runState.suspensionPayloadVersionedJson(...)` on the low-level snapshots. For workflows without start params, start directly from the flow or script -itself with `start(...)`, `startAndWait(...)`, or `prepareStart()`. -Use `ref0()` when another API specifically needs a `NoArgsWorkflowRef`. +itself with `start(...)` or `startAndWait(...)`. Keep `prepareStart()` for the +rarer cases where you want to assemble overrides incrementally before +dispatch. Use `ref0()` when another API specifically needs a +`NoArgsWorkflowRef`. ## Wait for completion diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 2a645fa3..3b68b473 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.1 +- Clarified docs so direct `start(...)` / `startAndWait(...)` and + `enqueue(...)` / `enqueueAndWait(...)` are the default happy path, with + `prepareStart(...)` and `prepareEnqueue(...)` positioned as advanced + override builders. - Added `QueueCustomEvent.metaJson(...)`, `metaVersionedJson(...)`, and `metaAs(codec: ...)` so queue-event metadata can decode DTO payloads without raw map casts. diff --git a/packages/stem/README.md b/packages/stem/README.md index b60f6a1d..be1ccaba 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -304,6 +304,10 @@ final result = await HelloTask.definition print(result?.value); ``` +Treat `prepareEnqueue(...)` as the advanced path when you need to assemble +headers, metadata, delay, priority, or other overrides incrementally. For the +normal case, prefer direct `enqueue(...)` or `enqueueAndWait(...)`. + ### Enqueue from inside a task Handlers can enqueue follow-up work using `TaskContext.enqueue` and request From 54fd937e818183d7770eb1b4ee874aba5095acf7 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 13:25:55 -0500 Subject: [PATCH 249/302] Prefer direct workflow start overrides in docs --- .site/docs/workflows/starting-and-waiting.md | 21 ++++++++++--------- packages/stem/CHANGELOG.md | 3 +++ packages/stem/README.md | 19 +++++++++-------- .../workflows/cancellation_policy.dart | 16 +++++++------- 4 files changed, 31 insertions(+), 28 deletions(-) diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index a565f050..69b962fc 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -44,18 +44,19 @@ final result = await approvalsRef.waitFor(workflowApp, runId); Use this path when you want the same typed start/wait surface as generated workflow refs, but the workflow itself is still hand-written. -When you want to add advanced start options fluently, use the workflow start -builder: +When you want to add advanced start options, keep using the direct typed ref +helpers: ```dart -final runId = await approvalsRef - .prepareStart(const ApprovalDraft(documentId: 'doc-42')) - .parentRunId('parent-run') - .ttl(const Duration(hours: 1)) - .cancellationPolicy( - const WorkflowCancellationPolicy(maxRuntime: Duration(minutes: 10)), - ) - .start(workflowApp); +final runId = await approvalsRef.start( + workflowApp, + params: const ApprovalDraft(documentId: 'doc-42'), + parentRunId: 'parent-run', + ttl: const Duration(hours: 1), + cancellationPolicy: const WorkflowCancellationPolicy( + maxRuntime: Duration(minutes: 10), + ), +); ``` `refJson(...)` is the shortest manual DTO path when the params already have diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 3b68b473..3cd40276 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Refreshed workflow examples/docs to prefer direct `start(...)` named + overrides over `prepareStart(...)` when no incremental builder assembly is + needed. - Clarified docs so direct `start(...)` / `startAndWait(...)` and `enqueue(...)` / `enqueueAndWait(...)` are the default happy path, with `prepareStart(...)` and `prepareEnqueue(...)` positioned as advanced diff --git a/packages/stem/README.md b/packages/stem/README.md index be1ccaba..713100bf 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -576,17 +576,18 @@ await app.close(); ``` When you need advanced start options without dropping back to raw workflow -names, use the fluent workflow start builder: +names, keep using the direct typed ref helpers: ```dart -final runId = await approvalsRef - .prepareStart(const ApprovalDraft(documentId: 'doc-42')) - .parentRunId('parent-run') - .ttl(const Duration(hours: 1)) - .cancellationPolicy( - const WorkflowCancellationPolicy(maxRuntime: Duration(minutes: 10)), - ) - .start(app); +final runId = await approvalsRef.start( + app, + params: const ApprovalDraft(documentId: 'doc-42'), + parentRunId: 'parent-run', + ttl: const Duration(hours: 1), + cancellationPolicy: const WorkflowCancellationPolicy( + maxRuntime: Duration(minutes: 10), + ), +); ``` Use `refJson(...)` when your manual workflow start params are DTOs with diff --git a/packages/stem/example/workflows/cancellation_policy.dart b/packages/stem/example/workflows/cancellation_policy.dart index f2d87a21..10476274 100644 --- a/packages/stem/example/workflows/cancellation_policy.dart +++ b/packages/stem/example/workflows/cancellation_policy.dart @@ -29,15 +29,13 @@ Future main() async { flows: [reportsGenerate], ); - final runId = await reportsGenerate - .prepareStart() - .cancellationPolicy( - const WorkflowCancellationPolicy( - maxRunDuration: Duration(minutes: 10), - maxSuspendDuration: Duration(seconds: 2), - ), - ) - .start(app); + final runId = await reportsGenerate.start( + app, + cancellationPolicy: const WorkflowCancellationPolicy( + maxRunDuration: Duration(minutes: 10), + maxSuspendDuration: Duration(seconds: 2), + ), + ); // Wait a bit longer than the policy allows so the auto-cancel can trigger. await Future.delayed(const Duration(seconds: 4)); From 16bc4c093300fa643d46276a1031b8690e08edc3 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 13:27:38 -0500 Subject: [PATCH 250/302] Prefer direct task enqueue overrides in examples --- packages/stem/CHANGELOG.md | 3 +++ packages/stem/README.md | 13 +++++----- .../task_context_mixed/lib/shared.dart | 24 +++++++++---------- .../stem/example/task_usage_patterns.dart | 11 +++++---- 4 files changed, 27 insertions(+), 24 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 3cd40276..16547204 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Refreshed runnable task examples/docs to prefer direct `enqueue(...)` and + `enqueueAndWait(...)` with named overrides over `prepareEnqueue(...)` when + no incremental builder assembly is needed. - Refreshed workflow examples/docs to prefer direct `start(...)` named overrides over `prepareStart(...)` when no incremental builder assembly is needed. diff --git a/packages/stem/README.md b/packages/stem/README.md index 713100bf..f115664f 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -294,12 +294,13 @@ The same pattern now carries through the low-level readback helpers: You can also build requests fluently from the task definition itself: ```dart -final result = await HelloTask.definition - .prepareEnqueue(const HelloArgs(name: 'Tenant A')) - .header('x-tenant', 'tenant-a') - .priority(5) - .delay(const Duration(seconds: 30)) - .enqueueAndWait(stem); +final result = await HelloTask.definition.enqueueAndWait( + stem, + const HelloArgs(name: 'Tenant A'), + headers: const {'x-tenant': 'tenant-a'}, + options: const TaskOptions(priority: 5), + notBefore: stemNow().add(const Duration(seconds: 30)), +); print(result?.value); ``` diff --git a/packages/stem/example/task_context_mixed/lib/shared.dart b/packages/stem/example/task_context_mixed/lib/shared.dart index b04d192e..7c72b888 100644 --- a/packages/stem/example/task_context_mixed/lib/shared.dart +++ b/packages/stem/example/task_context_mixed/lib/shared.dart @@ -306,19 +306,17 @@ FutureOr isolateChildEntrypoint( '[isolate_child] id=${context.id} attempt=${context.attempt} runId=$runId', ); - await context - .prepareEnqueue( - definition: auditDefinition, - args: AuditArgs( - runId: runId, - message: 'isolate child used prepareEnqueue', - ), - ) - .header('x-child', 'isolate') - .meta('origin', 'isolate-child') - .delay(const Duration(milliseconds: 200)) - .enqueueOptions(const TaskEnqueueOptions(shadow: 'audit-shadow')) - .enqueue(); + await auditDefinition.enqueue( + context, + AuditArgs( + runId: runId, + message: 'isolate child used direct enqueue', + ), + headers: const {'x-child': 'isolate'}, + meta: const {'origin': 'isolate-child'}, + notBefore: stemNow().add(const Duration(milliseconds: 200)), + enqueueOptions: const TaskEnqueueOptions(shadow: 'audit-shadow'), + ); return 'isolate-ok'; } diff --git a/packages/stem/example/task_usage_patterns.dart b/packages/stem/example/task_usage_patterns.dart index 820ec676..18a4d382 100644 --- a/packages/stem/example/task_usage_patterns.dart +++ b/packages/stem/example/task_usage_patterns.dart @@ -65,11 +65,12 @@ FutureOr invocationParentEntrypoint( TaskInvocationContext context, Map args, ) async { - await childDefinition - .prepareEnqueue(const ChildArgs('from-invocation-builder')) - .priority(5) - .delay(const Duration(milliseconds: 100)) - .enqueue(context); + await childDefinition.enqueue( + context, + const ChildArgs('from-invocation-builder'), + options: const TaskOptions(priority: 5), + notBefore: stemNow().add(const Duration(milliseconds: 100)), + ); return null; } From b60437c6889fd7189aac7c01a38b5fb93368af2c Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 14:22:13 -0500 Subject: [PATCH 251/302] Rename read-side versioned decode defaults --- packages/stem/lib/src/core/contracts.dart | 30 ++++++------- packages/stem/lib/src/core/payload_map.dart | 28 ++++++------ .../core/workflow_execution_context.dart | 44 +++++++++---------- .../src/workflow/core/workflow_resume.dart | 14 +++--- .../core/workflow_script_context.dart | 30 ++++++------- .../runtime/workflow_introspection.dart | 20 ++++----- .../stem/test/unit/core/payload_map_test.dart | 4 +- .../test/unit/core/task_invocation_test.dart | 4 +- .../unit/workflow/workflow_resume_test.dart | 22 +++++----- 9 files changed, 98 insertions(+), 98 deletions(-) diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 83f92382..cf67c8b3 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -1970,15 +1970,15 @@ extension TaskInputContextArgs on TaskInputContext { /// Decodes the full task-argument payload as a version-aware DTO. T argsVersionedJson({ - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { return PayloadCodec.versionedJson( - version: version, + version: defaultVersion, decode: decode, - defaultDecodeVersion: defaultDecodeVersion, + defaultDecodeVersion: defaultDecodeVersion ?? defaultVersion, typeName: typeName, ).decode(args); } @@ -2042,14 +2042,14 @@ extension TaskInputContextArgs on TaskInputContext { /// Returns the decoded version-aware task arg DTO for [key], or `null`. T? argVersionedJson( String key, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { return args.valueVersionedJson( key, - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, @@ -2060,15 +2060,15 @@ extension TaskInputContextArgs on TaskInputContext { T argVersionedJsonOr( String key, T fallback, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { return args.valueVersionedJsonOr( key, fallback, - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, @@ -2079,14 +2079,14 @@ extension TaskInputContextArgs on TaskInputContext { /// absent. T requiredArgVersionedJson( String key, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { return args.requiredValueVersionedJson( key, - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, @@ -2137,14 +2137,14 @@ extension TaskInputContextArgs on TaskInputContext { /// Returns the decoded version-aware task arg DTO list for [key], or `null`. List? argListVersionedJson( String key, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { return args.valueListVersionedJson( key, - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, @@ -2156,15 +2156,15 @@ extension TaskInputContextArgs on TaskInputContext { List argListVersionedJsonOr( String key, List fallback, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { return args.valueListVersionedJsonOr( key, fallback, - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, @@ -2175,14 +2175,14 @@ extension TaskInputContextArgs on TaskInputContext { /// when absent. List requiredArgListVersionedJson( String key, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { return args.requiredValueListVersionedJson( key, - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, diff --git a/packages/stem/lib/src/core/payload_map.dart b/packages/stem/lib/src/core/payload_map.dart index fd6b3bb9..6513c259 100644 --- a/packages/stem/lib/src/core/payload_map.dart +++ b/packages/stem/lib/src/core/payload_map.dart @@ -46,17 +46,17 @@ extension PayloadMapX on Map { /// decoder. T? valueVersionedJson( String key, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { final payload = this[key]; if (payload == null) return null; return PayloadCodec.versionedJson( - version: version, + version: defaultVersion, decode: decode, - defaultDecodeVersion: defaultDecodeVersion, + defaultDecodeVersion: defaultDecodeVersion ?? defaultVersion, typeName: typeName, ).decode(payload); } @@ -97,14 +97,14 @@ extension PayloadMapX on Map { T valueVersionedJsonOr( String key, T fallback, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { return valueVersionedJson( key, - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, @@ -116,8 +116,8 @@ extension PayloadMapX on Map { /// absent. T requiredValueVersionedJson( String key, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { @@ -126,7 +126,7 @@ extension PayloadMapX on Map { } return valueVersionedJson( key, - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, @@ -186,8 +186,8 @@ extension PayloadMapX on Map { /// when it is absent. List? valueListVersionedJson( String key, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { @@ -195,9 +195,9 @@ extension PayloadMapX on Map { if (payload == null) return null; final values = payload as List; final codec = PayloadCodec.versionedJson( - version: version, + version: defaultVersion, decode: decode, - defaultDecodeVersion: defaultDecodeVersion, + defaultDecodeVersion: defaultDecodeVersion ?? defaultVersion, typeName: typeName, ); return List.unmodifiable(values.map(codec.decode)); @@ -239,14 +239,14 @@ extension PayloadMapX on Map { List valueListVersionedJsonOr( String key, List fallback, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { return valueListVersionedJson( key, - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, @@ -258,8 +258,8 @@ extension PayloadMapX on Map { /// absent. List requiredValueListVersionedJson( String key, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { @@ -268,7 +268,7 @@ extension PayloadMapX on Map { } return valueListVersionedJson( key, - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, diff --git a/packages/stem/lib/src/workflow/core/workflow_execution_context.dart b/packages/stem/lib/src/workflow/core/workflow_execution_context.dart index 6176e7ef..b5e470a9 100644 --- a/packages/stem/lib/src/workflow/core/workflow_execution_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_execution_context.dart @@ -63,15 +63,15 @@ extension WorkflowExecutionContextParams on WorkflowExecutionContext { /// Decodes the full workflow start-parameter payload as a version-aware /// DTO. T paramsVersionedJson({ - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { return PayloadCodec.versionedJson( - version: version, + version: defaultVersion, decode: decode, - defaultDecodeVersion: defaultDecodeVersion, + defaultDecodeVersion: defaultDecodeVersion ?? defaultVersion, typeName: typeName, ).decode(params); } @@ -137,14 +137,14 @@ extension WorkflowExecutionContextParams on WorkflowExecutionContext { /// `null`. T? paramVersionedJson( String key, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { return params.valueVersionedJson( key, - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, @@ -156,15 +156,15 @@ extension WorkflowExecutionContextParams on WorkflowExecutionContext { T paramVersionedJsonOr( String key, T fallback, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { return params.valueVersionedJsonOr( key, fallback, - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, @@ -175,14 +175,14 @@ extension WorkflowExecutionContextParams on WorkflowExecutionContext { /// throwing when absent. T requiredParamVersionedJson( String key, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { return params.requiredValueVersionedJson( key, - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, @@ -235,14 +235,14 @@ extension WorkflowExecutionContextParams on WorkflowExecutionContext { /// or `null`. List? paramListVersionedJson( String key, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { return params.valueListVersionedJson( key, - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, @@ -254,15 +254,15 @@ extension WorkflowExecutionContextParams on WorkflowExecutionContext { List paramListVersionedJsonOr( String key, List fallback, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { return params.valueListVersionedJsonOr( key, fallback, - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, @@ -273,14 +273,14 @@ extension WorkflowExecutionContextParams on WorkflowExecutionContext { /// throwing when absent. List requiredParamListVersionedJson( String key, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { return params.requiredValueListVersionedJson( key, - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, @@ -358,17 +358,17 @@ extension WorkflowExecutionContextValues on WorkflowExecutionContext { /// Returns the decoded prior step/checkpoint value as a versioned typed DTO, /// or `null`. T? previousVersionedJson({ - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { final value = previousResult; if (value == null) return null; return PayloadCodec.versionedJson( - version: version, + version: defaultVersion, decode: decode, - defaultDecodeVersion: defaultDecodeVersion, + defaultDecodeVersion: defaultDecodeVersion ?? defaultVersion, typeName: typeName, ).decode(value); } @@ -376,13 +376,13 @@ extension WorkflowExecutionContextValues on WorkflowExecutionContext { /// Returns the decoded prior step/checkpoint versioned DTO, or [fallback]. T previousVersionedJsonOr( T fallback, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { return previousVersionedJson( - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, @@ -393,13 +393,13 @@ extension WorkflowExecutionContextValues on WorkflowExecutionContext { /// Returns the decoded prior step/checkpoint versioned DTO, throwing when /// absent. T requiredPreviousVersionedJson({ - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { final value = previousVersionedJson( - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, diff --git a/packages/stem/lib/src/workflow/core/workflow_resume.dart b/packages/stem/lib/src/workflow/core/workflow_resume.dart index 2038395c..94a2ee14 100644 --- a/packages/stem/lib/src/workflow/core/workflow_resume.dart +++ b/packages/stem/lib/src/workflow/core/workflow_resume.dart @@ -43,17 +43,17 @@ extension WorkflowResumeContextValues on WorkflowResumeContext { /// Returns the next resume payload as a versioned typed DTO and consumes it. T? takeResumeVersionedJson({ - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { final payload = takeResumeData(); if (payload == null) return null; return PayloadCodec.versionedJson( - version: version, + version: defaultVersion, decode: decode, - defaultDecodeVersion: defaultDecodeVersion, + defaultDecodeVersion: defaultDecodeVersion ?? defaultVersion, typeName: typeName, ).decode(payload); } @@ -164,15 +164,15 @@ extension WorkflowResumeContextValues on WorkflowResumeContext { /// invocation. T? waitForEventValueVersionedJson( String topic, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, DateTime? deadline, Map? data, int? defaultDecodeVersion, String? typeName, }) { final payload = takeResumeVersionedJson( - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, @@ -225,15 +225,15 @@ extension WorkflowResumeContextValues on WorkflowResumeContext { /// payload. Future waitForEventVersionedJson({ required String topic, - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, DateTime? deadline, Map? data, int? defaultDecodeVersion, String? typeName, }) async { final payload = takeResumeVersionedJson( - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, diff --git a/packages/stem/lib/src/workflow/core/workflow_script_context.dart b/packages/stem/lib/src/workflow/core/workflow_script_context.dart index 6fe28d98..214edfbb 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script_context.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script_context.dart @@ -122,15 +122,15 @@ extension WorkflowScriptContextParams on WorkflowScriptContext { /// Decodes the full workflow start-parameter payload as a version-aware /// DTO. T paramsVersionedJson({ - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { return PayloadCodec.versionedJson( - version: version, + version: defaultVersion, decode: decode, - defaultDecodeVersion: defaultDecodeVersion, + defaultDecodeVersion: defaultDecodeVersion ?? defaultVersion, typeName: typeName, ).decode(params); } @@ -196,14 +196,14 @@ extension WorkflowScriptContextParams on WorkflowScriptContext { /// `null`. T? paramVersionedJson( String key, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { return params.valueVersionedJson( key, - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, @@ -215,15 +215,15 @@ extension WorkflowScriptContextParams on WorkflowScriptContext { T paramVersionedJsonOr( String key, T fallback, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { return params.valueVersionedJsonOr( key, fallback, - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, @@ -234,14 +234,14 @@ extension WorkflowScriptContextParams on WorkflowScriptContext { /// throwing when absent. T requiredParamVersionedJson( String key, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { return params.requiredValueVersionedJson( key, - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, @@ -294,14 +294,14 @@ extension WorkflowScriptContextParams on WorkflowScriptContext { /// or `null`. List? paramListVersionedJson( String key, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { return params.valueListVersionedJson( key, - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, @@ -313,15 +313,15 @@ extension WorkflowScriptContextParams on WorkflowScriptContext { List paramListVersionedJsonOr( String key, List fallback, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { return params.valueListVersionedJsonOr( key, fallback, - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, @@ -332,14 +332,14 @@ extension WorkflowScriptContextParams on WorkflowScriptContext { /// throwing when absent. List requiredParamListVersionedJson( String key, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { return params.requiredValueListVersionedJson( key, - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, diff --git a/packages/stem/lib/src/workflow/runtime/workflow_introspection.dart b/packages/stem/lib/src/workflow/runtime/workflow_introspection.dart index ce9faa18..1b3e6fb2 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_introspection.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_introspection.dart @@ -136,8 +136,8 @@ class WorkflowStepEvent implements StemEvent { /// JSON decoder. T? metadataVersionedJson( String key, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { @@ -145,7 +145,7 @@ class WorkflowStepEvent implements StemEvent { if (payload == null) return null; return payload.valueVersionedJson( key, - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, @@ -175,17 +175,17 @@ class WorkflowStepEvent implements StemEvent { /// Decodes the full metadata payload as a typed DTO with a version-aware /// JSON decoder. T? metadataPayloadVersionedJson({ - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { final payload = metadata; if (payload == null) return null; return PayloadCodec.versionedJson( - version: version, + version: defaultVersion, decode: decode, - defaultDecodeVersion: defaultDecodeVersion, + defaultDecodeVersion: defaultDecodeVersion ?? defaultVersion, typeName: typeName, ).decode(payload); } @@ -267,8 +267,8 @@ class WorkflowRuntimeEvent implements StemEvent { /// JSON decoder. T? metadataVersionedJson( String key, { - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { @@ -276,7 +276,7 @@ class WorkflowRuntimeEvent implements StemEvent { if (payload == null) return null; return payload.valueVersionedJson( key, - version: version, + defaultVersion: defaultVersion, decode: decode, defaultDecodeVersion: defaultDecodeVersion, typeName: typeName, @@ -306,17 +306,17 @@ class WorkflowRuntimeEvent implements StemEvent { /// Decodes the full metadata payload as a typed DTO with a version-aware /// JSON decoder. T? metadataPayloadVersionedJson({ - required int version, required T Function(Map payload, int version) decode, + int defaultVersion = 1, int? defaultDecodeVersion, String? typeName, }) { final payload = metadata; if (payload == null) return null; return PayloadCodec.versionedJson( - version: version, + version: defaultVersion, decode: decode, - defaultDecodeVersion: defaultDecodeVersion, + defaultDecodeVersion: defaultDecodeVersion ?? defaultVersion, typeName: typeName, ).decode(payload); } diff --git a/packages/stem/test/unit/core/payload_map_test.dart b/packages/stem/test/unit/core/payload_map_test.dart index 99467711..8279d1cc 100644 --- a/packages/stem/test/unit/core/payload_map_test.dart +++ b/packages/stem/test/unit/core/payload_map_test.dart @@ -68,7 +68,7 @@ void main() { final draft = payload.valueVersionedJson<_ApprovalDraft>( 'draft', - version: 2, + defaultVersion: 2, decode: _ApprovalDraft.fromVersionedJson, ); @@ -179,7 +179,7 @@ void main() { final drafts = payload.valueListVersionedJson<_ApprovalDraft>( 'drafts', - version: 2, + defaultVersion: 2, decode: _ApprovalDraft.fromVersionedJson, ); diff --git a/packages/stem/test/unit/core/task_invocation_test.dart b/packages/stem/test/unit/core/task_invocation_test.dart index 8ede43a0..03c6550a 100644 --- a/packages/stem/test/unit/core/task_invocation_test.dart +++ b/packages/stem/test/unit/core/task_invocation_test.dart @@ -175,7 +175,7 @@ void main() { expect( context .argsVersionedJson<_ProgressUpdate>( - version: 2, + defaultVersion: 2, decode: _ProgressUpdate.fromVersionedJson, ) .stage, @@ -185,7 +185,7 @@ void main() { context .argVersionedJson<_ProgressUpdate>( 'update', - version: 2, + defaultVersion: 2, decode: _ProgressUpdate.fromVersionedJson, ) ?.stage, diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart index f57e3364..0e3c7a02 100644 --- a/packages/stem/test/unit/workflow/workflow_resume_test.dart +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -92,7 +92,7 @@ void main() { ); final value = context.takeResumeVersionedJson<_ResumePayload>( - version: 2, + defaultVersion: 2, decode: _ResumePayload.fromVersionedJson, ); @@ -100,7 +100,7 @@ void main() { expect(value!.message, 'approved'); expect( context.takeResumeVersionedJson<_ResumePayload>( - version: 2, + defaultVersion: 2, decode: _ResumePayload.fromVersionedJson, ), isNull, @@ -206,7 +206,7 @@ void main() { expect( flowContext .paramsVersionedJson<_ResumePayload>( - version: 2, + defaultVersion: 2, decode: _ResumePayload.fromVersionedJson, ) .message, @@ -216,7 +216,7 @@ void main() { flowContext .paramVersionedJson<_ResumePayload>( 'payload', - version: 2, + defaultVersion: 2, decode: _ResumePayload.fromVersionedJson, ) ?.message, @@ -231,7 +231,7 @@ void main() { expect( scriptContext .paramsVersionedJson<_ResumePayload>( - version: 2, + defaultVersion: 2, decode: _ResumePayload.fromVersionedJson, ) .message, @@ -241,7 +241,7 @@ void main() { scriptContext .paramVersionedJson<_ResumePayload>( 'payload', - version: 2, + defaultVersion: 2, decode: _ResumePayload.fromVersionedJson, ) ?.message, @@ -307,7 +307,7 @@ void main() { ); final value = flowContext.requiredPreviousVersionedJson<_ResumePayload>( - version: 2, + defaultVersion: 2, decode: _ResumePayload.fromVersionedJson, ); @@ -491,7 +491,7 @@ void main() { final firstResult = firstContext.waitForEventValueVersionedJson<_ResumePayload>( 'demo.event', - version: 2, + defaultVersion: 2, decode: _ResumePayload.fromVersionedJson, ); @@ -517,7 +517,7 @@ void main() { final resumed = resumedContext.waitForEventValueVersionedJson<_ResumePayload>( 'demo.event', - version: 2, + defaultVersion: 2, decode: _ResumePayload.fromVersionedJson, ); @@ -714,7 +714,7 @@ void main() { expect( () => waiting.waitForEventVersionedJson<_ResumePayload>( topic: 'demo.event', - version: 2, + defaultVersion: 2, decode: _ResumePayload.fromVersionedJson, ), throwsA(isA()), @@ -737,7 +737,7 @@ void main() { expect( resumed.waitForEventVersionedJson<_ResumePayload>( topic: 'demo.event', - version: 2, + defaultVersion: 2, decode: _ResumePayload.fromVersionedJson, ), completion( From fdb43cf8edfe602d2ff88fcbfaf15c1bf899f6c5 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 14:23:30 -0500 Subject: [PATCH 252/302] Clarify versioned decode fallback docs --- .site/docs/workflows/context-and-serialization.md | 3 +++ .site/docs/workflows/starting-and-waiting.md | 2 ++ packages/stem/CHANGELOG.md | 3 +++ packages/stem/README.md | 9 +++++++-- 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index 8daec849..01b32686 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -67,6 +67,9 @@ Depending on the context type, you can access: - `takeResumeValue(codec: ...)` for typed event-driven resumes - `takeResumeJson(...)` or `takeResumeVersionedJson(...)` for DTO event-driven resumes without a separate codec constant +- for read-side `...VersionedJson(...)` helpers, `defaultVersion:` is only the + fallback used when an older stored payload does not already carry + `__stemPayloadVersion` - `idempotencyKey(...)` - direct child-workflow start helpers such as `ref.start(context, params: value)` and diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 69b962fc..1a35f835 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -80,6 +80,8 @@ When the persisted workflow result or suspension payload carries an explicit `__stemPayloadVersion`, use `workflowResult.payloadVersionedJson(...)`, `runState.resultVersionedJson(...)`, or `runState.suspensionPayloadVersionedJson(...)` on the low-level snapshots. +Those read-side helpers take `defaultVersion:` as the fallback for older +payloads that do not yet carry a stored version marker. For workflows without start params, start directly from the flow or script itself with `start(...)` or `startAndWait(...)`. Keep `prepareStart()` for the diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 16547204..3023b1e6 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Renamed read-side `...VersionedJson(...)` fallback args to + `defaultVersion:` so decode helpers no longer imply they are choosing the + persisted schema version on already-stored payloads. - Refreshed runnable task examples/docs to prefer direct `enqueue(...)` and `enqueueAndWait(...)` with named overrides over `prepareEnqueue(...)` when no incremental builder assembly is needed. diff --git a/packages/stem/README.md b/packages/stem/README.md index f115664f..90a8d819 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -244,16 +244,21 @@ final workflowParams = ctx.paramsAs( codec: approvalDraftCodec, ); final versionedParams = ctx.paramsVersionedJson( - version: 2, + defaultVersion: 2, decode: ApprovalDraft.fromVersionedJson, ); final nestedDraft = ctx.paramVersionedJson( 'draft', - version: 2, + defaultVersion: 2, decode: ApprovalDraft.fromVersionedJson, ); ``` +For read-side `...VersionedJson(...)` helpers, `defaultVersion:` is only the +fallback used when an older stored payload does not already include +`__stemPayloadVersion`. Keep `version:` for write-side helpers that are +actually persisting a new schema version. + For typed task calls, the definition and call objects now expose the common producer operations directly. Prefer `enqueueAndWait(...)` when you only need the final typed result: From 8d3514e72bc344e218ced521603ee84242cf4ee3 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 14:38:56 -0500 Subject: [PATCH 253/302] Remove no-args call convenience helpers --- packages/stem/CHANGELOG.md | 2 +- packages/stem/lib/src/core/contracts.dart | 18 -------------- packages/stem/lib/src/core/stem.dart | 17 +++++++------ .../lib/src/workflow/core/workflow_ref.dart | 24 +++++-------------- .../stem/test/bootstrap/stem_app_test.dart | 4 ++-- .../unit/core/task_enqueue_builder_test.dart | 14 +++++++---- 6 files changed, 28 insertions(+), 51 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 3023b1e6..74a3119d 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -676,7 +676,7 @@ - Updated the manual workflow examples and docs to start runs through typed refs instead of repeating raw workflow-name strings in the happy path. - Added `NoArgsWorkflowRef` plus `Flow.ref0()` / `WorkflowScript.ref0()` so - zero-input workflows can use `.call()` instead of passing `const {}`. + zero-input workflows can start directly without passing `const {}`. - Added workflow manifests, runtime metadata views, and run/step drilldown APIs for inspecting workflow definitions and persisted execution state. - Clarified the workflow authoring model by distinguishing flow steps from diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index cf67c8b3..fa0c125e 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -3009,24 +3009,6 @@ class NoArgsTaskDefinition { decodeResult: decodeResult, ); - /// Builds a typed call without requiring an explicit empty payload. - TaskCall<(), TResult> call({ - Map headers = const {}, - TaskOptions? options, - DateTime? notBefore, - Map? meta, - TaskEnqueueOptions? enqueueOptions, - }) { - return asDefinition.call( - (), - headers: headers, - options: options, - notBefore: notBefore, - meta: meta, - enqueueOptions: enqueueOptions, - ); - } - /// Creates a fluent enqueue builder for this no-args task definition. TaskEnqueueBuilder<(), TResult> prepareEnqueue() { return asDefinition.prepareEnqueue(()); diff --git a/packages/stem/lib/src/core/stem.dart b/packages/stem/lib/src/core/stem.dart index de57be75..663bbf42 100644 --- a/packages/stem/lib/src/core/stem.dart +++ b/packages/stem/lib/src/core/stem.dart @@ -1281,13 +1281,16 @@ extension NoArgsTaskDefinitionExtension Map? meta, TaskEnqueueOptions? enqueueOptions, }) { - return call( - headers: headers, - options: options, - notBefore: notBefore, - meta: meta, - enqueueOptions: enqueueOptions, - ).enqueue(enqueuer, enqueueOptions: enqueueOptions); + return asDefinition + .call( + (), + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ) + .enqueue(enqueuer, enqueueOptions: enqueueOptions); } /// Waits for [taskId] using this definition's decoding rules. diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index b1dfec30..589a4617 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -233,20 +233,6 @@ class NoArgsWorkflowRef { static Map _encodeParams(() _) => const {}; - /// Builds a workflow start call without requiring an explicit empty payload. - WorkflowStartCall<(), TResult> call({ - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return asRef.call( - (), - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ); - } - /// Creates a fluent builder for this workflow start. WorkflowStartBuilder<(), TResult> prepareStart() { return asRef.prepareStart(()); @@ -259,11 +245,13 @@ class NoArgsWorkflowRef { Duration? ttl, WorkflowCancellationPolicy? cancellationPolicy, }) { - return call( + return asRef.start( parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy, - ).start(caller); + caller, + params: (), + ); } /// Starts this workflow ref with [caller] and waits for the result. @@ -275,12 +263,12 @@ class NoArgsWorkflowRef { Duration pollInterval = const Duration(milliseconds: 100), Duration? timeout, }) { - return call( + return asRef.startAndWait( parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy, - ).startAndWait( caller, + params: (), pollInterval: pollInterval, timeout: timeout, ); diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index ba7a8f6f..8e61077d 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -1359,7 +1359,7 @@ void main() { final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); try { - final runId = await workflowRef.call().start(workflowApp); + final runId = await workflowRef.start(workflowApp); final result = await workflowRef.waitFor( workflowApp, runId, @@ -1421,7 +1421,7 @@ void main() { final workflowApp = await StemWorkflowApp.inMemory(scripts: [script]); try { - final runId = await workflowRef.call().start(workflowApp); + final runId = await workflowRef.start(workflowApp); final result = await workflowRef.waitFor( workflowApp, runId, diff --git a/packages/stem/test/unit/core/task_enqueue_builder_test.dart b/packages/stem/test/unit/core/task_enqueue_builder_test.dart index 27cc660f..dbd31f6f 100644 --- a/packages/stem/test/unit/core/task_enqueue_builder_test.dart +++ b/packages/stem/test/unit/core/task_enqueue_builder_test.dart @@ -186,13 +186,14 @@ void main() { expect(updated.meta['m2'], 2); }); - test('NoArgsTaskDefinition.call encodes an empty payload', () { + test('NoArgsTaskDefinition.prepareEnqueue builds an empty payload call', () { final definition = TaskDefinition.noArgs(name: 'demo.no_args'); - final call = definition.call( - headers: const {'h': 'v'}, - meta: const {'m': 1}, - ); + final call = definition + .prepareEnqueue() + .headers(const {'h': 'v'}) + .metadata(const {'m': 1}) + .build(); expect(call.name, 'demo.no_args'); expect(call.encodeArgs(), isEmpty); @@ -273,6 +274,9 @@ class _RecordingTaskResultCaller extends _RecordingTaskEnqueuer String taskId, { Duration? timeout, TResult Function(Object? payload)? decode, + TResult Function(Map payload)? decodeJson, + TResult Function(Map payload, int version)? + decodeVersionedJson, }) async { waitedTaskId = taskId; return TaskResult( From c3dd1c58f586d60eee183ecad12f04fe30e13e7d Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 14:40:53 -0500 Subject: [PATCH 254/302] Remove no-args workflow builder wrappers --- .site/docs/workflows/flows-and-scripts.md | 10 +++++----- packages/stem/CHANGELOG.md | 10 +++++----- packages/stem/lib/src/workflow/core/flow.dart | 5 ----- packages/stem/lib/src/workflow/core/workflow_ref.dart | 10 ---------- .../stem/lib/src/workflow/core/workflow_script.dart | 5 ----- .../stem/test/workflow/workflow_runtime_ref_test.dart | 4 ++-- 6 files changed, 12 insertions(+), 32 deletions(-) diff --git a/.site/docs/workflows/flows-and-scripts.md b/.site/docs/workflows/flows-and-scripts.md index b9951bd8..9880f766 100644 --- a/.site/docs/workflows/flows-and-scripts.md +++ b/.site/docs/workflows/flows-and-scripts.md @@ -36,9 +36,9 @@ final approvalsRef = approvalsFlow.ref>( ``` When a flow has no start params, start directly from the flow itself with -`flow.start(...)` or `flow.startAndWait(...)`. Keep `flow.prepareStart()` for -the rarer cases where you want to assemble overrides incrementally before -dispatch. +`flow.start(...)` or `flow.startAndWait(...)`. Keep +`flow.ref0().prepareStart()` for the rarer cases where you want to assemble +overrides incrementally before dispatch. Use `ref0()` only when another API specifically needs a `NoArgsWorkflowRef`. Use `Flow` when: @@ -63,8 +63,8 @@ final retryRef = retryScript.ref>( When a script has no start params, start directly from the script itself with `retryScript.start(...)` or `retryScript.startAndWait(...)`. Keep -`retryScript.prepareStart()` for the rarer cases where you want to assemble -overrides incrementally before dispatch. Use `ref0()` only when another API +`retryScript.ref0().prepareStart()` for the rarer cases where you want to +assemble overrides incrementally before dispatch. Use `ref0()` only when another API specifically needs a `NoArgsWorkflowRef`. Use `WorkflowScript` when: diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 74a3119d..1fedb7f9 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -275,12 +275,12 @@ `PayloadCodec.json(...)` and `refJson(...)` surfaces. - Renamed the caller-bound advanced child-workflow helpers from `prepareWorkflowStart(...)` / `prepareNoArgsWorkflowStart(...)` to - `prepareStart(...)` / `prepareNoArgsStart(...)` so the caller side aligns - with task-side `prepareEnqueue(...)` and ref-side `prepareStart(...)`. + `prepareStart(...)` so the caller side aligns with task-side + `prepareEnqueue(...)` and ref-side `prepareStart(...)`. - Renamed the advanced workflow-ref builder entrypoints from `startBuilder(...)` - / `startBuilder()` to `prepareStart(...)` / `prepareStart()` on - `WorkflowRef`, `NoArgsWorkflowRef`, `Flow`, and `WorkflowScript` so the - workflow side aligns with task-side `prepareEnqueue(...)`. + / `startBuilder()` to `prepareStart(...)` on `WorkflowRef` and + `NoArgsWorkflowRef` so the workflow side aligns with task-side + `prepareEnqueue(...)`. - Relaxed JSON/codec DTO decoding helpers to accept `Map` `fromJson(...)` signatures across manual task/workflow/event helpers and the shared `PayloadCodec` surface. DTO payloads still persist as string-keyed diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index b967b6ea..99c6f1e8 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -126,11 +126,6 @@ class Flow { return definition.ref0(); } - /// Creates a fluent start builder for flows without start params. - WorkflowStartBuilder<(), T> prepareStart() { - return ref0().prepareStart(); - } - /// Starts this flow directly when it does not accept start params. Future start( WorkflowCaller caller, { diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 589a4617..60740861 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -532,16 +532,6 @@ extension WorkflowCallerBuilderExtension on WorkflowCaller { ); } - /// Creates a caller-bound fluent start builder for a no-args workflow ref. - BoundWorkflowStartBuilder<(), TResult> - prepareNoArgsStart({ - required NoArgsWorkflowRef definition, - }) { - return BoundWorkflowStartBuilder._( - caller: this, - builder: definition.prepareStart(), - ); - } } /// Convenience helpers for waiting on typed workflow refs using a generic diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index 677778e9..95e501d8 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -134,11 +134,6 @@ class WorkflowScript { return definition.ref0(); } - /// Creates a fluent start builder for scripts without start params. - WorkflowStartBuilder<(), T> prepareStart() { - return ref0().prepareStart(); - } - /// Starts this script directly when it does not accept start params. Future start( WorkflowCaller caller, { diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index be08d628..eefe675e 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -483,7 +483,7 @@ void main() { expect(result?.value, 'hello builder'); expect(state?.parentRunId, 'parent-builder'); - final scriptBuilder = script.prepareStart().cancellationPolicy( + final scriptBuilder = script.ref0().prepareStart().cancellationPolicy( const WorkflowCancellationPolicy( maxRunDuration: Duration(seconds: 5), ), @@ -553,7 +553,7 @@ void main() { expect(state?.parentRunId, 'parent-bound'); final scriptBuilder = workflowApp.runtime - .prepareNoArgsStart(definition: scriptRef) + .prepareStart(definition: scriptRef.asRef, params: ()) .cancellationPolicy( const WorkflowCancellationPolicy( maxRunDuration: Duration(seconds: 5), From d8c79921d7ed70dcd6a61d90981b6c232dbcd905 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 14:42:27 -0500 Subject: [PATCH 255/302] Remove no-args task builder wrappers --- packages/stem/CHANGELOG.md | 9 ++++----- packages/stem/lib/src/core/contracts.dart | 17 ----------------- .../unit/core/task_enqueue_builder_test.dart | 5 +++-- 3 files changed, 7 insertions(+), 24 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 1fedb7f9..67d380d1 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -298,8 +298,7 @@ supported workflow-start surfaces. - Removed the deprecated task-builder compatibility helpers: `enqueueBuilder(...)` and `enqueueNoArgsBuilder(...)`. The direct - `prepareEnqueue(...)` and `prepareNoArgsEnqueue(...)` forms are now the only - supported builder entrypoints. + `prepareEnqueue(...)` form is now the only supported builder entrypoint. - Removed the deprecated `withJsonCodec(...)` / `refWithJsonCodec(...)` compatibility helpers. The direct `json(...)` / `refJson(...)` forms are now the only supported JSON shortcut APIs. @@ -309,9 +308,9 @@ `TaskDefinition.json(...)`, `TaskDefinition.codec(...)`, `WorkflowRef.json(...)`, `WorkflowRef.codec(...)`, `refJson(...)`, and `refCodec(...)`. -- Added `prepareEnqueue(...)` / `prepareNoArgsEnqueue(...)` as the clearer - task-side names for advanced enqueue builders, and deprecated the older - `enqueueBuilder(...)` / `enqueueNoArgsBuilder(...)` aliases. +- Added `prepareEnqueue(...)` as the clearer task-side name for advanced + enqueue builders, and deprecated the older `enqueueBuilder(...)` / + `enqueueNoArgsBuilder(...)` aliases. - Added `prepareWorkflowStart(...)` / `prepareNoArgsWorkflowStart(...)` as the clearer names for caller-bound workflow start builders, and deprecated the older `startWorkflowBuilder(...)` / `startNoArgsWorkflowBuilder(...)` diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index fa0c125e..c3f18958 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -3009,11 +3009,6 @@ class NoArgsTaskDefinition { decodeResult: decodeResult, ); - /// Creates a fluent enqueue builder for this no-args task definition. - TaskEnqueueBuilder<(), TResult> prepareEnqueue() { - return asDefinition.prepareEnqueue(()); - } - /// Decodes a persisted payload into a typed result. TResult? decode(Object? payload) => asDefinition.decode(payload); } @@ -3366,18 +3361,6 @@ extension TaskEnqueuerBuilderExtension on TaskEnqueuer { ); } - /// Creates a caller-bound fluent builder for a no-args task definition. - BoundTaskEnqueueBuilder<(), TResult> prepareNoArgsEnqueue({ - required NoArgsTaskDefinition definition, - }) { - return BoundTaskEnqueueBuilder( - enqueuer: this, - builder: TaskEnqueueBuilder( - definition: definition.asDefinition, - args: (), - ), - ); - } } /// Retry strategy used to compute the next backoff delay. diff --git a/packages/stem/test/unit/core/task_enqueue_builder_test.dart b/packages/stem/test/unit/core/task_enqueue_builder_test.dart index dbd31f6f..3bc09a8a 100644 --- a/packages/stem/test/unit/core/task_enqueue_builder_test.dart +++ b/packages/stem/test/unit/core/task_enqueue_builder_test.dart @@ -190,7 +190,8 @@ void main() { final definition = TaskDefinition.noArgs(name: 'demo.no_args'); final call = definition - .prepareEnqueue() + .asDefinition + .prepareEnqueue(()) .headers(const {'h': 'v'}) .metadata(const {'m': 1}) .build(); @@ -204,7 +205,7 @@ void main() { test('NoArgsTaskDefinition.prepareEnqueue creates a fluent builder', () { final definition = TaskDefinition.noArgs(name: 'demo.no_args'); - final call = definition.prepareEnqueue().priority(4).build(); + final call = definition.asDefinition.prepareEnqueue(()).priority(4).build(); expect(call.name, 'demo.no_args'); expect(call.resolveOptions().priority, 4); From 65a5b525807b013dfd44a1ef6f7ed133606af229 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 14:45:07 -0500 Subject: [PATCH 256/302] Remove workflow event call wrappers --- .../docs/workflows/suspensions-and-events.md | 3 +- packages/stem/CHANGELOG.md | 13 ++++----- packages/stem/README.md | 2 -- .../src/workflow/core/workflow_event_ref.dart | 29 ------------------- .../workflow/workflow_runtime_ref_test.dart | 15 +++++----- 5 files changed, 15 insertions(+), 47 deletions(-) diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index 713482da..492f00a4 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -71,8 +71,7 @@ When the topic and codec travel together in your codebase, prefer `WorkflowEventRef.json(...)` for normal DTO payloads, `WorkflowEventRef.versionedJson(...)` when the payload schema should carry an explicit `__stemPayloadVersion`, and keep `event.emit(emitter, dto)` as the -happy path. `event.call(value).emit(...)` remains available as the lower-level -prebuilt-call variant. +happy path. Pair that with `await event.wait(ctx)`. If you are writing a flow and deliberately want the lower-level `FlowStepControl` path, use `event.awaitOn(step)` instead of dropping back to a raw topic string. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 67d380d1..70445e90 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -288,9 +288,9 @@ - Removed the deprecated workflow-event compatibility helpers: `emitWith(...)`, `emitEventBuilder(...)`, `waitForEventRef(...)`, `waitForEventRefValue(...)`, `awaitEventRef(...)`, `waitValueWith(...)`, and - `waitWith(...)`. The direct `event.emit(...)`, `event.call(...).emit(...)`, - `event.wait(...)`, `event.waitValue(...)`, and `event.awaitOn(...)` surfaces - are now the only supported forms. + `waitWith(...)`. The direct `event.emit(...)`, `event.wait(...)`, + `event.waitValue(...)`, and `event.awaitOn(...)` surfaces are now the only + supported forms. - Removed the deprecated workflow-start compatibility helpers: `startWith(...)`, `startAndWaitWith(...)`, `startWorkflowBuilder(...)`, and `startNoArgsWorkflowBuilder(...)`. The direct `start(...)`, @@ -322,7 +322,7 @@ `waitForEventRefValue(...)` in favor of `event.waitValue(ctx)` and `event.wait(ctx)`. - Deprecated `emitEventBuilder(...)` in favor of direct typed event calls via - `event.emit(...)` or `event.call(value).emit(...)`. + `event.emit(...)`. - Deprecated the older workflow-start `startWith(...)` and `startAndWaitWith(...)` helpers in favor of direct `start(...)` and `startAndWait(...)` aliases. @@ -453,9 +453,8 @@ the lower-level interop option. - Added `TaskEnqueueBuilder.enqueueAndWait(...)` so fluent per-call task overrides no longer require a separate manual wait step. -- Added `WorkflowEventRef.call(value)` plus `WorkflowEventCall.emitWith(...)` - so typed workflow events now have the same prebuilt-call ergonomics as tasks - and workflow starts. +- Added direct typed workflow event helper APIs so event refs can emit + payloads without repeating raw topic strings or separate codecs. - Added `WorkflowStartBuilder` plus `WorkflowRef.startBuilder(...)` / `NoArgsWorkflowRef.startBuilder()` so typed workflow refs can fluently set `parentRunId`, `ttl`, and `WorkflowCancellationPolicy` without dropping to diff --git a/packages/stem/README.md b/packages/stem/README.md index 90a8d819..9e67f6b5 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -1292,8 +1292,6 @@ backend metadata under `stem.unique.duplicates`. runtime) with a `PayloadCodec`, or use `WorkflowEventRef.json(...)` / `WorkflowEventRef.versionedJson(...)` as the shortest typed event forms and call `event.emit(emitter, dto)` as the happy path. - `event.call(value).emit(...)` remains available as the lower-level - prebuilt-call variant. Pair that with `await event.wait(ctx)`. Event payloads still serialize onto a string-keyed JSON-like map. - Only return values you want persisted. If a handler returns `null`, the diff --git a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart index 6c4ab86f..72d76acd 100644 --- a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart @@ -80,27 +80,6 @@ class WorkflowEventRef { /// Optional codec for encoding and decoding event payloads. final PayloadCodec? codec; - /// Builds a typed event emission call from [value]. - WorkflowEventCall call(T value) { - return WorkflowEventCall._(event: this, value: value); - } -} - -/// Typed event emission request built from a [WorkflowEventRef]. -class WorkflowEventCall { - const WorkflowEventCall._({ - required this.event, - required this.value, - }); - - /// Reference used to build this event emission. - final WorkflowEventRef event; - - /// Typed event payload. - final T value; - - /// Durable topic name derived from [event]. - String get topic => event.topic; } /// Convenience helpers for dispatching typed workflow events. @@ -110,11 +89,3 @@ extension WorkflowEventRefExtension on WorkflowEventRef { return emitter.emitEvent(this, value); } } - -/// Convenience helpers for dispatching prebuilt [WorkflowEventCall] instances. -extension WorkflowEventCallExtension on WorkflowEventCall { - /// Emits this typed event with the provided [emitter]. - Future emit(WorkflowEventEmitter emitter) { - return emitter.emitEvent(event, value); - } -} diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index eefe675e..421235d8 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -632,9 +632,10 @@ void main() { final runId = await flow.ref0().start(workflowApp); await workflowApp.runtime.executeRun(runId); - await _userUpdatedEvent - .call(const _GreetingParams(name: 'call')) - .emit(workflowApp); + await _userUpdatedEvent.emit( + workflowApp, + const _GreetingParams(name: 'call'), + ); await workflowApp.runtime.executeRun(runId); final result = await workflowApp.waitForCompletion( @@ -670,12 +671,12 @@ void main() { final runId = await flow.ref0().start(workflowApp); await workflowApp.runtime.executeRun(runId); - final call = _userUpdatedEvent.call( + expect(_userUpdatedEvent.topic, 'runtime.ref.event'); + + await _userUpdatedEvent.emit( + workflowApp, const _GreetingParams(name: 'bound'), ); - expect(call.topic, 'runtime.ref.event'); - - await call.emit(workflowApp); await workflowApp.runtime.executeRun(runId); final result = await workflowApp.waitForCompletion( From 883d5556f91b6475457a932b16de71a2d6442132 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 14:47:43 -0500 Subject: [PATCH 257/302] Remove workflow start call helper --- packages/stem/CHANGELOG.md | 3 ++ .../lib/src/workflow/core/workflow_ref.dart | 31 +++++------------ .../stem/test/bootstrap/stem_app_test.dart | 9 +++-- .../stem/test/bootstrap/stem_client_test.dart | 15 ++++---- ...workflow_runtime_call_extensions_test.dart | 10 +++--- .../test/workflow/workflow_runtime_test.dart | 34 ++++++++++--------- 6 files changed, 48 insertions(+), 54 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 70445e90..be86c420 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Removed `WorkflowRef.call(...)` as a duplicate workflow-start convenience. + The direct `start(...)` / `startAndWait(...)` helpers remain the happy path, + and `prepareStart(...).build()` remains the explicit prebuilt-call path. - Renamed read-side `...VersionedJson(...)` fallback args to `defaultVersion:` so decode helpers no longer imply they are choosing the persisted schema version on already-stored payloads. diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 60740861..86e3fb69 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -138,22 +138,6 @@ class WorkflowRef { return Map.from(payload); } - /// Builds a workflow start call from typed arguments. - WorkflowStartCall call( - TParams params, { - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return WorkflowStartCall._( - definition: this, - params: params, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ); - } - /// Creates a fluent builder for this workflow start. WorkflowStartBuilder prepareStart(TParams params) { return WorkflowStartBuilder(definition: this, params: params); @@ -179,8 +163,9 @@ class WorkflowRef { Duration? ttl, WorkflowCancellationPolicy? cancellationPolicy, }) { - return call( - params, + return WorkflowStartCall._( + definition: this, + params: params, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy, @@ -198,8 +183,9 @@ class WorkflowRef { Duration pollInterval = const Duration(milliseconds: 100), Duration? timeout, }) { - return call( - params, + return WorkflowStartCall._( + definition: this, + params: params, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy, @@ -403,8 +389,9 @@ class WorkflowStartBuilder { /// Builds the [WorkflowStartCall] with accumulated overrides. WorkflowStartCall build() { - return definition.call( - params, + return WorkflowStartCall._( + definition: definition, + params: params, parentRunId: _parentRunId, ttl: _ttl, cancellationPolicy: _cancellationPolicy, diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index 8e61077d..6e709d33 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -921,11 +921,10 @@ void main() { final workflowApp = await StemWorkflowApp.inMemory(flows: [moduleFlow]); try { - final runId = await workflowRef - .call( - const {'name': 'stem'}, - ) - .start(workflowApp); + final runId = await workflowRef.start( + workflowApp, + params: const {'name': 'stem'}, + ); final result = await workflowRef.waitFor( workflowApp, runId, diff --git a/packages/stem/test/bootstrap/stem_client_test.dart b/packages/stem/test/bootstrap/stem_client_test.dart index f6db03ea..7176e131 100644 --- a/packages/stem/test/bootstrap/stem_client_test.dart +++ b/packages/stem/test/bootstrap/stem_client_test.dart @@ -368,8 +368,9 @@ void main() { final app = await client.createWorkflowApp(flows: [flow]); await app.start(); - final runId = await app.startWorkflowCall( - workflowRef.call(const {'name': 'ref'}), + final runId = await workflowRef.start( + app, + params: const {'name': 'ref'}, ); final result = await app.waitForWorkflowRef( runId, @@ -443,11 +444,11 @@ void main() { final app = await client.createWorkflowApp(flows: [flow]); await app.start(); - final result = await workflowRef - .call( - const {'name': 'one-shot'}, - ) - .startAndWait(app, timeout: const Duration(seconds: 2)); + final result = await workflowRef.startAndWait( + app, + params: const {'name': 'one-shot'}, + timeout: const Duration(seconds: 2), + ); expect(result?.value, 'ok:one-shot'); diff --git a/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart b/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart index 05e25b03..fbbb017f 100644 --- a/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart @@ -2,9 +2,9 @@ import 'package:stem/stem.dart'; import 'package:test/test.dart'; void main() { - group('runtime workflow call extensions', () { + group('runtime workflow start call extensions', () { test( - 'start/startAndWait/waitFor use typed workflow refs', + 'prepareStart().build() start/startAndWait/waitFor use typed workflow refs', () async { final flow = Flow( name: 'runtime.extension.flow', @@ -25,7 +25,8 @@ void main() { await workflowApp.start(); final runId = await workflowRef - .call(const {'name': 'runtime'}) + .prepareStart(const {'name': 'runtime'}) + .build() .start(workflowApp.runtime); final waited = await workflowRef.waitFor( workflowApp.runtime, @@ -36,7 +37,8 @@ void main() { expect(waited?.value, 'hello runtime'); final oneShot = await workflowRef - .call(const {'name': 'inline'}) + .prepareStart(const {'name': 'inline'}) + .build() .startAndWait( workflowApp.runtime, timeout: const Duration(seconds: 2), diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index e22bd5d1..125dca4e 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -156,7 +156,10 @@ void main() { name: 'parent.runtime.flow', build: (flow) { flow.step('spawn', (context) async { - return childRef.call(const {'value': 'spawned'}).start(context); + return childRef.start( + context, + params: const {'value': 'spawned'}, + ); }); }, ).definition, @@ -201,9 +204,10 @@ void main() { ], run: (script) async { return script.step('spawn', (context) async { - return childRef - .call(const {'value': 'script-child'}) - .start(context); + return childRef.start( + context, + params: const {'value': 'script-child'}, + ); }); }, ).definition, @@ -247,12 +251,11 @@ void main() { name: 'parent.runtime.wait.flow', build: (flow) { flow.step('spawn', (context) async { - final childResult = await childRef - .call(const {'value': 'spawned'}) - .startAndWait( - context, - timeout: const Duration(seconds: 2), - ); + final childResult = await childRef.startAndWait( + context, + params: const {'value': 'spawned'}, + timeout: const Duration(seconds: 2), + ); return { 'childRunId': childResult?.runId, 'childValue': childResult?.value, @@ -302,12 +305,11 @@ void main() { return script.step>('spawn', ( context, ) async { - final childResult = await childRef - .call(const {'value': 'script-child'}) - .startAndWait( - context, - timeout: const Duration(seconds: 2), - ); + final childResult = await childRef.startAndWait( + context, + params: const {'value': 'script-child'}, + timeout: const Duration(seconds: 2), + ); return { 'childRunId': childResult?.runId, 'childValue': childResult?.value, From 5187b76d981b060c61d1fdf119fcd79a267b904d Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 14:48:51 -0500 Subject: [PATCH 258/302] Remove no-args workflow ref builder wrapper --- .site/docs/workflows/flows-and-scripts.md | 4 ++-- packages/stem/CHANGELOG.md | 3 +++ .../stem/lib/src/workflow/core/workflow_ref.dart | 5 ----- .../test/workflow/workflow_runtime_ref_test.dart | 14 +++++++++----- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.site/docs/workflows/flows-and-scripts.md b/.site/docs/workflows/flows-and-scripts.md index 9880f766..ed5c9b7d 100644 --- a/.site/docs/workflows/flows-and-scripts.md +++ b/.site/docs/workflows/flows-and-scripts.md @@ -37,7 +37,7 @@ final approvalsRef = approvalsFlow.ref>( When a flow has no start params, start directly from the flow itself with `flow.start(...)` or `flow.startAndWait(...)`. Keep -`flow.ref0().prepareStart()` for the rarer cases where you want to assemble +`flow.ref0().asRef.prepareStart(())` for the rarer cases where you want to assemble overrides incrementally before dispatch. Use `ref0()` only when another API specifically needs a `NoArgsWorkflowRef`. @@ -63,7 +63,7 @@ final retryRef = retryScript.ref>( When a script has no start params, start directly from the script itself with `retryScript.start(...)` or `retryScript.startAndWait(...)`. Keep -`retryScript.ref0().prepareStart()` for the rarer cases where you want to +`retryScript.ref0().asRef.prepareStart(())` for the rarer cases where you want to assemble overrides incrementally before dispatch. Use `ref0()` only when another API specifically needs a `NoArgsWorkflowRef`. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index be86c420..45e5c053 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -5,6 +5,9 @@ - Removed `WorkflowRef.call(...)` as a duplicate workflow-start convenience. The direct `start(...)` / `startAndWait(...)` helpers remain the happy path, and `prepareStart(...).build()` remains the explicit prebuilt-call path. +- Removed `NoArgsWorkflowRef.prepareStart()` as a duplicate no-args builder + wrapper. Use direct `start(...)` / `startAndWait(...)` for the happy path, + or `ref0().asRef.prepareStart(())` when you need an explicit prebuilt call. - Renamed read-side `...VersionedJson(...)` fallback args to `defaultVersion:` so decode helpers no longer imply they are choosing the persisted schema version on already-stored payloads. diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 86e3fb69..adacf3a6 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -219,11 +219,6 @@ class NoArgsWorkflowRef { static Map _encodeParams(() _) => const {}; - /// Creates a fluent builder for this workflow start. - WorkflowStartBuilder<(), TResult> prepareStart() { - return asRef.prepareStart(()); - } - /// Starts this workflow ref directly with [caller]. Future start( WorkflowCaller caller, { diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 421235d8..c88d36c9 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -483,11 +483,15 @@ void main() { expect(result?.value, 'hello builder'); expect(state?.parentRunId, 'parent-builder'); - final scriptBuilder = script.ref0().prepareStart().cancellationPolicy( - const WorkflowCancellationPolicy( - maxRunDuration: Duration(seconds: 5), - ), - ); + final scriptBuilder = script + .ref0() + .asRef + .prepareStart(()) + .cancellationPolicy( + const WorkflowCancellationPolicy( + maxRunDuration: Duration(seconds: 5), + ), + ); final builtScriptCall = scriptBuilder.build(); final oneShot = await scriptBuilder.startAndWait( workflowApp.runtime, From 9ae26410e3d02cb86ebf516bfc41f85ead1aa965 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 14:53:58 -0500 Subject: [PATCH 259/302] Remove task definition call helper --- .site/docs/core-concepts/tasks.md | 10 +-- packages/stem/CHANGELOG.md | 4 + packages/stem/lib/src/canvas/canvas.dart | 21 +++-- packages/stem/lib/src/core/contracts.dart | 5 +- packages/stem/lib/src/core/stem.dart | 77 ++++++++++++------- .../stem/test/unit/core/fake_stem_test.dart | 2 +- .../stem/test/unit/core/stem_core_test.dart | 24 +++--- .../unit/core/task_enqueue_builder_test.dart | 10 +-- .../test/unit/core/task_invocation_test.dart | 12 +-- .../test/unit/core/task_registry_test.dart | 14 ++-- 10 files changed, 109 insertions(+), 70 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 0015d9ea..79ad455c 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -36,12 +36,12 @@ routing, retry behavior, timeouts, and isolation. Stem ships with `TaskDefinition` so producers get compile-time checks for required arguments and result types. A definition bundles the task -name, argument encoder, optional metadata, and default `TaskOptions`. Build a -call with `.call(args)` or `TaskEnqueueBuilder` and hand it to any -`TaskResultCaller` / `TaskEnqueuer` surface. For the common path, use the direct +name, argument encoder, optional metadata, and default `TaskOptions`. For the +common path, use the direct `definition.enqueue(stem, args)` / `definition.enqueueAndWait(...)` -helpers and drop down to `.call(args)` only when you need a reusable prebuilt -request: +helpers. When you need a reusable prebuilt request, use +`definition.prepareEnqueue(args).build()` and hand the resulting `TaskCall` to +any `TaskResultCaller` / `TaskEnqueuer` surface: ```dart file=/../packages/stem/example/docs_snippets/lib/tasks.dart#tasks-typed-definition diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 45e5c053..fd81d0e9 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -8,6 +8,10 @@ - Removed `NoArgsWorkflowRef.prepareStart()` as a duplicate no-args builder wrapper. Use direct `start(...)` / `startAndWait(...)` for the happy path, or `ref0().asRef.prepareStart(())` when you need an explicit prebuilt call. +- Removed `TaskDefinition.call(...)` as a duplicate task-enqueue convenience. + The direct `enqueue(...)` / `enqueueAndWait(...)` helpers remain the happy + path, and `prepareEnqueue(args).build()` remains the explicit prebuilt-call + path. - Renamed read-side `...VersionedJson(...)` fallback args to `defaultVersion:` so decode helpers no longer imply they are choosing the persisted schema version on already-stored payloads. diff --git a/packages/stem/lib/src/canvas/canvas.dart b/packages/stem/lib/src/canvas/canvas.dart index 5e79500c..99e9a92e 100644 --- a/packages/stem/lib/src/canvas/canvas.dart +++ b/packages/stem/lib/src/canvas/canvas.dart @@ -934,13 +934,20 @@ extension TaskDefinitionCanvasX Map? meta, TResult Function(Object? payload)? decode, }) { - final call = this.call( - args, - headers: headers, - options: options, - notBefore: notBefore, - meta: meta, - ); + final builder = prepareEnqueue(args); + if (headers.isNotEmpty) { + builder.headers(headers); + } + if (options != null) { + builder.options(options); + } + if (notBefore != null) { + builder.notBefore(notBefore); + } + if (meta != null) { + builder.metadata(meta); + } + final call = builder.build(); return task( name, args: call.encodeArgs(), diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index c3f18958..67302927 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2930,8 +2930,7 @@ class TaskDefinition { ); } - /// Build a typed call which can be passed to `Stem.enqueueCall`. - TaskCall call( + TaskCall _buildCall( TArgs args, { Map headers = const {}, TaskOptions? options, @@ -3164,7 +3163,7 @@ class TaskEnqueueBuilder { /// Builds the [TaskCall] with accumulated overrides. TaskCall build() { - final base = definition(args); + final base = definition._buildCall(args); final mergedHeaders = Map.from(base.headers); if (_headers != null) { mergedHeaders.addAll(_headers!); diff --git a/packages/stem/lib/src/core/stem.dart b/packages/stem/lib/src/core/stem.dart index 663bbf42..11a00655 100644 --- a/packages/stem/lib/src/core/stem.dart +++ b/packages/stem/lib/src/core/stem.dart @@ -1224,14 +1224,23 @@ extension TaskDefinitionExtension Map? meta, TaskEnqueueOptions? enqueueOptions, }) { - return call( - args, - headers: headers, - options: options, - notBefore: notBefore, - meta: meta, - enqueueOptions: enqueueOptions, - ).enqueue(enqueuer, enqueueOptions: enqueueOptions); + final builder = prepareEnqueue(args); + if (headers.isNotEmpty) { + builder.headers(headers); + } + if (options != null) { + builder.options(options); + } + if (notBefore != null) { + builder.notBefore(notBefore); + } + if (meta != null) { + builder.metadata(meta); + } + if (enqueueOptions != null) { + builder.enqueueOptions(enqueueOptions); + } + return builder.build().enqueue(enqueuer, enqueueOptions: enqueueOptions); } /// Enqueues this typed task definition and waits for its typed result. @@ -1245,14 +1254,23 @@ extension TaskDefinitionExtension TaskEnqueueOptions? enqueueOptions, Duration? timeout, }) { - return call( - args, - headers: headers, - options: options, - notBefore: notBefore, - meta: meta, - enqueueOptions: enqueueOptions, - ).enqueueAndWait( + final builder = prepareEnqueue(args); + if (headers.isNotEmpty) { + builder.headers(headers); + } + if (options != null) { + builder.options(options); + } + if (notBefore != null) { + builder.notBefore(notBefore); + } + if (meta != null) { + builder.metadata(meta); + } + if (enqueueOptions != null) { + builder.enqueueOptions(enqueueOptions); + } + return builder.build().enqueueAndWait( caller, enqueueOptions: enqueueOptions, timeout: timeout, @@ -1281,16 +1299,23 @@ extension NoArgsTaskDefinitionExtension Map? meta, TaskEnqueueOptions? enqueueOptions, }) { - return asDefinition - .call( - (), - headers: headers, - options: options, - notBefore: notBefore, - meta: meta, - enqueueOptions: enqueueOptions, - ) - .enqueue(enqueuer, enqueueOptions: enqueueOptions); + final builder = asDefinition.prepareEnqueue(()); + if (headers.isNotEmpty) { + builder.headers(headers); + } + if (options != null) { + builder.options(options); + } + if (notBefore != null) { + builder.notBefore(notBefore); + } + if (meta != null) { + builder.metadata(meta); + } + if (enqueueOptions != null) { + builder.enqueueOptions(enqueueOptions); + } + return builder.build().enqueue(enqueuer, enqueueOptions: enqueueOptions); } /// Waits for [taskId] using this definition's decoding rules. diff --git a/packages/stem/test/unit/core/fake_stem_test.dart b/packages/stem/test/unit/core/fake_stem_test.dart index c57a5cb8..3e63debc 100644 --- a/packages/stem/test/unit/core/fake_stem_test.dart +++ b/packages/stem/test/unit/core/fake_stem_test.dart @@ -15,7 +15,7 @@ void main() { encodeArgs: (args) => {'value': args.value}, ); - final call = definition(const _Args(42)); + final call = definition.prepareEnqueue(const _Args(42)).build(); final id = await fake.enqueueCall(call); expect(id, isNotEmpty); diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index e6c90951..543492f6 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -131,7 +131,9 @@ void main() { defaultOptions: const TaskOptions(queue: 'typed'), ); - final id = await stem.enqueueCall(definition.call((value: 'ok'))); + final id = await stem.enqueueCall( + definition.prepareEnqueue((value: 'ok')).build(), + ); expect(id, isNotEmpty); expect(broker.published.single.envelope.name, 'sample.typed'); @@ -152,7 +154,7 @@ void main() { ); final id = await stem.enqueueCall( - definition.call(const _CodecTaskArgs('encoded')), + definition.prepareEnqueue(const _CodecTaskArgs('encoded')).build(), ); expect(id, isNotEmpty); @@ -173,7 +175,7 @@ void main() { ); final id = await stem.enqueueCall( - definition.call(const _CodecTaskArgs('encoded')), + definition.prepareEnqueue(const _CodecTaskArgs('encoded')).build(), ); expect(id, isNotEmpty); @@ -195,7 +197,7 @@ void main() { ); final id = await stem.enqueueCall( - definition.call(const _CodecTaskArgs('encoded')), + definition.prepareEnqueue(const _CodecTaskArgs('encoded')).build(), ); expect(id, isNotEmpty); @@ -282,7 +284,7 @@ void main() { ); final id = await stem.enqueueCall( - definition.call((value: 'encoded')), + definition.prepareEnqueue((value: 'encoded')).build(), ); expect( @@ -310,7 +312,7 @@ void main() { ); final id = await stem.enqueueCall( - definition.call(const _CodecTaskArgs('encoded')), + definition.prepareEnqueue(const _CodecTaskArgs('encoded')).build(), ); expect( @@ -518,7 +520,7 @@ void main() { ); final taskId = await TaskEnqueueScope.run({'traceId': 'scope-1'}, () { - return definition.call((value: 'ok')).enqueue(stem); + return definition.enqueue(stem, (value: 'ok')); }); expect(taskId, isNotEmpty); @@ -549,9 +551,11 @@ void main() { }), ); - final result = await definition - .call((value: 'ok')) - .enqueueAndWait(stem, timeout: const Duration(seconds: 1)); + final result = await definition.enqueueAndWait( + stem, + (value: 'ok'), + timeout: const Duration(seconds: 1), + ); expect(result?.isSucceeded, isTrue); expect(result?.value, 'done'); diff --git a/packages/stem/test/unit/core/task_enqueue_builder_test.dart b/packages/stem/test/unit/core/task_enqueue_builder_test.dart index 3bc09a8a..16822d80 100644 --- a/packages/stem/test/unit/core/task_enqueue_builder_test.dart +++ b/packages/stem/test/unit/core/task_enqueue_builder_test.dart @@ -171,11 +171,11 @@ void main() { name: 'demo.task', encodeArgs: (args) => args, ); - final call = definition.call( - const {'a': 1}, - headers: const {'h': 'v'}, - meta: const {'m': 1}, - ); + final call = definition + .prepareEnqueue(const {'a': 1}) + .headers(const {'h': 'v'}) + .metadata(const {'m': 1}) + .build(); final updated = call.copyWith( headers: const {'h2': 'v2'}, diff --git a/packages/stem/test/unit/core/task_invocation_test.dart b/packages/stem/test/unit/core/task_invocation_test.dart index 03c6550a..f6b6747d 100644 --- a/packages/stem/test/unit/core/task_invocation_test.dart +++ b/packages/stem/test/unit/core/task_invocation_test.dart @@ -375,7 +375,7 @@ void main() { const TaskDefinition, Object?>( name: 'demo', encodeArgs: _encodeArgs, - ).call(const {'a': 1}), + ).prepareEnqueue(const {'a': 1}).build(), ), throwsA(isA()), ); @@ -400,11 +400,11 @@ void main() { name: 'demo.call', encodeArgs: (args) => args, ); - final call = definition.call( - const {'value': 1}, - headers: const {'h2': 'v2'}, - meta: const {'m2': 'v2'}, - ); + final call = definition + .prepareEnqueue(const {'value': 1}) + .headers(const {'h2': 'v2'}) + .metadata(const {'m2': 'v2'}) + .build(); await context.enqueueCall(call); diff --git a/packages/stem/test/unit/core/task_registry_test.dart b/packages/stem/test/unit/core/task_registry_test.dart index b229c555..2b9daf41 100644 --- a/packages/stem/test/unit/core/task_registry_test.dart +++ b/packages/stem/test/unit/core/task_registry_test.dart @@ -201,7 +201,7 @@ void main() { encodeArgs: (args) => {'value': args.value}, ); - final call = definition(_Args(42)); + final call = definition.prepareEnqueue(_Args(42)).build(); expect(call.name, 'demo.task'); expect(call.encodeArgs(), {'value': 42}); expect(call.resolveOptions(), const TaskOptions()); @@ -219,12 +219,12 @@ void main() { encodeArgs: (args) => {'value': args.value}, ); - final call = definition( - _Args(99), - headers: {'x-id': 'abc'}, - options: const TaskOptions(queue: 'custom'), - meta: const {'source': 'test'}, - ); + final call = definition + .prepareEnqueue(_Args(99)) + .headers({'x-id': 'abc'}) + .options(const TaskOptions(queue: 'custom')) + .metadata(const {'source': 'test'}) + .build(); final id = await stem.enqueueCall(call); expect(id, isNotEmpty); From 0b979a914b1754133eb7049a49f529f5b660935a Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 14:57:04 -0500 Subject: [PATCH 260/302] Remove prebuilt call dispatch wrappers --- packages/stem/CHANGELOG.md | 4 ++ packages/stem/lib/src/core/stem.dart | 55 +++++----------- .../lib/src/workflow/core/workflow_ref.dart | 62 +++++++------------ ...workflow_runtime_call_extensions_test.dart | 29 +++++---- 4 files changed, 57 insertions(+), 93 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index fd81d0e9..2c6e1f5f 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -12,6 +12,10 @@ The direct `enqueue(...)` / `enqueueAndWait(...)` helpers remain the happy path, and `prepareEnqueue(args).build()` remains the explicit prebuilt-call path. +- Removed the `TaskCall.enqueue(...)` / `enqueueAndWait(...)` and + `WorkflowStartCall.start(...)` / `startAndWait(...)` dispatch wrappers. + Prebuilt transport objects now dispatch explicitly through + `TaskEnqueuer.enqueueCall(...)` and `WorkflowCaller.startWorkflowCall(...)`. - Renamed read-side `...VersionedJson(...)` fallback args to `defaultVersion:` so decode helpers no longer imply they are choosing the persisted schema version on already-stored payloads. diff --git a/packages/stem/lib/src/core/stem.dart b/packages/stem/lib/src/core/stem.dart index 11a00655..19572ba4 100644 --- a/packages/stem/lib/src/core/stem.dart +++ b/packages/stem/lib/src/core/stem.dart @@ -1128,15 +1128,22 @@ class Stem implements TaskResultCaller { extension TaskEnqueueBuilderExtension on TaskEnqueueBuilder { /// Builds the call and enqueues it with the provided [enqueuer] instance. - Future enqueue(TaskEnqueuer enqueuer) { + Future enqueue( + TaskEnqueuer enqueuer, { + TaskEnqueueOptions? enqueueOptions, + }) { final call = build(); final scopeMeta = TaskEnqueueScope.currentMeta(); if (scopeMeta == null || scopeMeta.isEmpty) { - return enqueuer.enqueueCall(call); + return enqueuer.enqueueCall( + call, + enqueueOptions: enqueueOptions ?? call.enqueueOptions, + ); } final mergedMeta = Map.from(scopeMeta)..addAll(call.meta); return enqueuer.enqueueCall( call.copyWith(meta: Map.unmodifiable(mergedMeta)), + enqueueOptions: enqueueOptions ?? call.enqueueOptions, ); } @@ -1147,12 +1154,11 @@ extension TaskEnqueueBuilderExtension Duration? timeout, TaskEnqueueOptions? enqueueOptions, }) async { - final call = build(); - final taskId = await call.enqueue( + final taskId = await enqueue( caller, enqueueOptions: enqueueOptions, ); - return call.definition.waitFor(caller, taskId, timeout: timeout); + return build().definition.waitFor(caller, taskId, timeout: timeout); } } @@ -1177,39 +1183,6 @@ extension BoundTaskEnqueueBuilderExtension } } -/// Convenience helpers for dispatching prebuilt [TaskCall] instances. -extension TaskCallExtension - on TaskCall { - /// Enqueues this typed call with the provided [enqueuer]. - /// - /// Ambient [TaskEnqueueScope] metadata is merged the same way as the fluent - /// [TaskEnqueueBuilder] helper so producers and task contexts behave - /// consistently. - Future enqueue( - TaskEnqueuer enqueuer, { - TaskEnqueueOptions? enqueueOptions, - }) { - final scopeMeta = TaskEnqueueScope.currentMeta(); - if (scopeMeta == null || scopeMeta.isEmpty) { - return enqueuer.enqueueCall(this, enqueueOptions: enqueueOptions); - } - final mergedMeta = Map.from(scopeMeta)..addAll(meta); - return enqueuer.enqueueCall( - copyWith(meta: Map.unmodifiable(mergedMeta)), - enqueueOptions: enqueueOptions, - ); - } - - /// Enqueues this call on [caller] and waits for the typed task result. - Future?> enqueueAndWait( - TaskResultCaller caller, { - TaskEnqueueOptions? enqueueOptions, - Duration? timeout, - }) async { - final taskId = await enqueue(caller, enqueueOptions: enqueueOptions); - return definition.waitFor(caller, taskId, timeout: timeout); - } -} /// Convenience helpers for waiting on typed task definitions. extension TaskDefinitionExtension @@ -1240,7 +1213,7 @@ extension TaskDefinitionExtension if (enqueueOptions != null) { builder.enqueueOptions(enqueueOptions); } - return builder.build().enqueue(enqueuer, enqueueOptions: enqueueOptions); + return builder.enqueue(enqueuer, enqueueOptions: enqueueOptions); } /// Enqueues this typed task definition and waits for its typed result. @@ -1270,7 +1243,7 @@ extension TaskDefinitionExtension if (enqueueOptions != null) { builder.enqueueOptions(enqueueOptions); } - return builder.build().enqueueAndWait( + return builder.enqueueAndWait( caller, enqueueOptions: enqueueOptions, timeout: timeout, @@ -1315,7 +1288,7 @@ extension NoArgsTaskDefinitionExtension if (enqueueOptions != null) { builder.enqueueOptions(enqueueOptions); } - return builder.build().enqueue(enqueuer, enqueueOptions: enqueueOptions); + return builder.enqueue(enqueuer, enqueueOptions: enqueueOptions); } /// Waits for [taskId] using this definition's decoding rules. diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index adacf3a6..7a13a0e9 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -163,13 +163,15 @@ class WorkflowRef { Duration? ttl, WorkflowCancellationPolicy? cancellationPolicy, }) { - return WorkflowStartCall._( + return caller.startWorkflowCall( + WorkflowStartCall._( definition: this, params: params, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy, - ).start(caller); + ), + ); } /// Starts this workflow ref with [caller] and waits for the result using @@ -183,17 +185,21 @@ class WorkflowRef { Duration pollInterval = const Duration(milliseconds: 100), Duration? timeout, }) { - return WorkflowStartCall._( + final call = WorkflowStartCall._( definition: this, params: params, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy, - ).startAndWait( - caller, - pollInterval: pollInterval, - timeout: timeout, ); + return caller.startWorkflowCall(call).then((runId) { + return call.definition.waitFor( + caller, + runId, + pollInterval: pollInterval, + timeout: timeout, + ); + }); } } @@ -394,38 +400,12 @@ class WorkflowStartBuilder { } } -/// Convenience helpers for dispatching prebuilt [WorkflowStartCall] instances. -extension WorkflowStartCallExtension - on WorkflowStartCall { - /// Starts this typed workflow call with the provided [caller]. - Future start(WorkflowCaller caller) { - return caller.startWorkflowCall(this); - } - - /// Starts this typed workflow call with [caller] and waits for the result. - Future?> startAndWait( - WorkflowCaller caller, { - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - final runIdFuture = start(caller); - return runIdFuture.then((runId) { - return definition.waitFor( - caller, - runId, - pollInterval: pollInterval, - timeout: timeout, - ); - }); - } -} - /// Convenience helpers for dispatching [WorkflowStartBuilder] instances. extension WorkflowStartBuilderExtension on WorkflowStartBuilder { /// Builds this workflow call and starts it with the provided [caller]. Future start(WorkflowCaller caller) { - return build().start(caller); + return caller.startWorkflowCall(build()); } /// Builds this workflow call, starts it with [caller], and waits for the @@ -435,11 +415,15 @@ extension WorkflowStartBuilderExtension Duration pollInterval = const Duration(milliseconds: 100), Duration? timeout, }) { - return build().startAndWait( - caller, - pollInterval: pollInterval, - timeout: timeout, - ); + final call = build(); + return caller.startWorkflowCall(call).then((runId) { + return call.definition.waitFor( + caller, + runId, + pollInterval: pollInterval, + timeout: timeout, + ); + }); } } diff --git a/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart b/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart index fbbb017f..8d71a0e3 100644 --- a/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart @@ -2,9 +2,9 @@ import 'package:stem/stem.dart'; import 'package:test/test.dart'; void main() { - group('runtime workflow start call extensions', () { + group('runtime workflow start call dispatch', () { test( - 'prepareStart().build() start/startAndWait/waitFor use typed workflow refs', + 'prepareStart().build() can be dispatched through WorkflowCaller', () async { final flow = Flow( name: 'runtime.extension.flow', @@ -24,10 +24,9 @@ void main() { try { await workflowApp.start(); - final runId = await workflowRef - .prepareStart(const {'name': 'runtime'}) - .build() - .start(workflowApp.runtime); + final runId = await workflowApp.runtime.startWorkflowCall( + workflowRef.prepareStart(const {'name': 'runtime'}).build(), + ); final waited = await workflowRef.waitFor( workflowApp.runtime, runId, @@ -36,13 +35,17 @@ void main() { expect(waited?.value, 'hello runtime'); - final oneShot = await workflowRef + final inlineCall = workflowRef .prepareStart(const {'name': 'inline'}) - .build() - .startAndWait( - workflowApp.runtime, - timeout: const Duration(seconds: 2), - ); + .build(); + final inlineRunId = await workflowApp.runtime.startWorkflowCall( + inlineCall, + ); + final oneShot = await workflowRef.waitFor( + workflowApp.runtime, + inlineRunId, + timeout: const Duration(seconds: 2), + ); expect(oneShot?.value, 'hello inline'); } finally { @@ -52,7 +55,7 @@ void main() { ); test( - 'WorkflowRef direct helpers mirror WorkflowStartCall dispatch', + 'WorkflowRef direct helpers mirror WorkflowCaller startWorkflowCall', () async { final flow = Flow( name: 'runtime.extension.direct.flow', From 270e1a0b56b828046a58d38baa073e98f0783b74 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 15:02:32 -0500 Subject: [PATCH 261/302] Remove builder dispatch helpers --- .site/docs/core-concepts/tasks.md | 14 +-- .site/docs/workflows/starting-and-waiting.md | 4 +- packages/stem/CHANGELOG.md | 6 +- packages/stem/README.md | 3 +- packages/stem/lib/src/core/contracts.dart | 27 +----- packages/stem/lib/src/core/stem.dart | 92 +++++++------------ .../lib/src/workflow/core/workflow_ref.dart | 46 +--------- .../unit/core/task_context_enqueue_test.dart | 9 +- .../unit/core/task_enqueue_builder_test.dart | 49 +++------- ...task_context_enqueue_integration_test.dart | 10 +- .../workflow/workflow_runtime_ref_test.dart | 21 ++++- .../test/workflow/workflow_runtime_test.dart | 5 +- 12 files changed, 101 insertions(+), 185 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 79ad455c..21520a98 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -96,13 +96,13 @@ final request = context.argsJson( ); ``` -`TaskEnqueueBuilder` also supports `enqueueAndWait(...)`, and typed task -definitions can now create a fluent builder directly through -`definition.prepareEnqueue(...)`. `TaskEnqueuer.prepareEnqueue(...)` remains -available when you want the caller-bound variant that keeps the enqueue target -attached to the builder. Treat both `prepareEnqueue(...)` forms as the -advanced override path; for the normal case, prefer direct -`enqueue(...)` / `enqueueAndWait(...)`. +`TaskEnqueueBuilder` now only builds a `TaskCall`. Typed task definitions can +create that fluent builder directly through `definition.prepareEnqueue(...)`. +`TaskEnqueuer.prepareEnqueue(...)` remains available when you want the +caller-bound variant that keeps the enqueue target attached to the builder. +Treat both `prepareEnqueue(...)` forms as the advanced override path, then +dispatch the built call with `enqueueCall(...)`. For the normal case, prefer +direct `enqueue(...)` / `enqueueAndWait(...)`. For tasks with no producer inputs, use `TaskDefinition.noArgs(...)` instead. That gives you direct `enqueue(...)` / diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 1a35f835..0c6b653a 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -85,8 +85,8 @@ payloads that do not yet carry a stored version marker. For workflows without start params, start directly from the flow or script itself with `start(...)` or `startAndWait(...)`. Keep `prepareStart()` for the -rarer cases where you want to assemble overrides incrementally before -dispatch. Use `ref0()` when another API specifically needs a +rarer cases where you want to assemble overrides incrementally before calling +`startWorkflowCall(...)`. Use `ref0()` when another API specifically needs a `NoArgsWorkflowRef`. ## Wait for completion diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 2c6e1f5f..314ac809 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -465,8 +465,10 @@ - Reframed the producer docs to treat `TaskDefinition` and shared `TaskEnqueuer` surfaces as the happy path, keeping raw name-based enqueue as the lower-level interop option. -- Added `TaskEnqueueBuilder.enqueueAndWait(...)` so fluent per-call task - overrides no longer require a separate manual wait step. +- Tightened advanced builder dispatch so `TaskEnqueueBuilder` and + `WorkflowStartBuilder` now only build `TaskCall` / `WorkflowStartCall` + values; dispatch goes through direct `enqueue(...)` / `start(...)` helpers + or explicit `enqueueCall(...)` / `startWorkflowCall(...)`. - Added direct typed workflow event helper APIs so event refs can emit payloads without repeating raw topic strings or separate codecs. - Added `WorkflowStartBuilder` plus `WorkflowRef.startBuilder(...)` / diff --git a/packages/stem/README.md b/packages/stem/README.md index 9e67f6b5..cb1ff028 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -312,7 +312,8 @@ print(result?.value); Treat `prepareEnqueue(...)` as the advanced path when you need to assemble headers, metadata, delay, priority, or other overrides incrementally. For the -normal case, prefer direct `enqueue(...)` or `enqueueAndWait(...)`. +normal case, prefer direct `enqueue(...)` or `enqueueAndWait(...)`. Builders +now only produce `TaskCall`; dispatch those with `enqueueCall(...)`. ### Enqueue from inside a task diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 67302927..48930662 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -3185,8 +3185,7 @@ class TaskEnqueueBuilder { /// Caller-bound fluent task enqueue builder. /// /// This keeps the enqueue target attached to the builder so producers and -/// contexts can stay on the enqueuer surface instead of bouncing back through -/// `builder.enqueue(enqueuer)`. +/// contexts can stay on the enqueuer surface while assembling a call. class BoundTaskEnqueueBuilder { /// Creates a caller-bound task enqueue builder. BoundTaskEnqueueBuilder({ @@ -3263,30 +3262,6 @@ class BoundTaskEnqueueBuilder { /// Builds the [TaskCall] with accumulated overrides. TaskCall build() => _builder.build(); - Future _enqueueBuiltCall( - TaskCall call, { - TaskEnqueueOptions? enqueueOptions, - }) { - final resolvedEnqueueOptions = enqueueOptions ?? call.enqueueOptions; - final scopeMeta = TaskEnqueueScope.currentMeta(); - if (scopeMeta == null || scopeMeta.isEmpty) { - return enqueuer.enqueueCall( - call, - enqueueOptions: resolvedEnqueueOptions, - ); - } - final mergedMeta = Map.from(scopeMeta)..addAll(call.meta); - return enqueuer.enqueueCall( - call.copyWith(meta: Map.unmodifiable(mergedMeta)), - enqueueOptions: resolvedEnqueueOptions, - ); - } - - /// Builds the call and enqueues it with the bound enqueuer. - Future enqueue({TaskEnqueueOptions? enqueueOptions}) { - final call = build(); - return _enqueueBuiltCall(call, enqueueOptions: enqueueOptions); - } } /// Convenience helpers for building typed enqueue requests directly from a task diff --git a/packages/stem/lib/src/core/stem.dart b/packages/stem/lib/src/core/stem.dart index 19572ba4..263d6e5c 100644 --- a/packages/stem/lib/src/core/stem.dart +++ b/packages/stem/lib/src/core/stem.dart @@ -1124,63 +1124,24 @@ class Stem implements TaskResultCaller { } } -/// Convenience helpers for enqueuing [TaskEnqueueBuilder] instances. -extension TaskEnqueueBuilderExtension - on TaskEnqueueBuilder { - /// Builds the call and enqueues it with the provided [enqueuer] instance. - Future enqueue( - TaskEnqueuer enqueuer, { - TaskEnqueueOptions? enqueueOptions, - }) { - final call = build(); - final scopeMeta = TaskEnqueueScope.currentMeta(); - if (scopeMeta == null || scopeMeta.isEmpty) { - return enqueuer.enqueueCall( - call, - enqueueOptions: enqueueOptions ?? call.enqueueOptions, - ); - } - final mergedMeta = Map.from(scopeMeta)..addAll(call.meta); +Future _enqueueBuiltTaskCall( + TaskEnqueuer enqueuer, + TaskCall call, { + TaskEnqueueOptions? enqueueOptions, +}) { + final resolvedEnqueueOptions = enqueueOptions ?? call.enqueueOptions; + final scopeMeta = TaskEnqueueScope.currentMeta(); + if (scopeMeta == null || scopeMeta.isEmpty) { return enqueuer.enqueueCall( - call.copyWith(meta: Map.unmodifiable(mergedMeta)), - enqueueOptions: enqueueOptions ?? call.enqueueOptions, - ); - } - - /// Builds this request, enqueues it with [caller], and waits for the typed - /// task result. - Future?> enqueueAndWait( - TaskResultCaller caller, { - Duration? timeout, - TaskEnqueueOptions? enqueueOptions, - }) async { - final taskId = await enqueue( - caller, - enqueueOptions: enqueueOptions, + call, + enqueueOptions: resolvedEnqueueOptions, ); - return build().definition.waitFor(caller, taskId, timeout: timeout); - } -} - -/// Convenience helpers for waiting on caller-bound task enqueue builders. -extension BoundTaskEnqueueBuilderExtension - on BoundTaskEnqueueBuilder { - /// Enqueues this bound request and waits for the typed task result. - Future?> enqueueAndWait({ - Duration? timeout, - TaskEnqueueOptions? enqueueOptions, - }) async { - final boundEnqueuer = enqueuer; - if (boundEnqueuer is! TaskResultCaller) { - throw StateError( - 'BoundTaskEnqueueBuilder requires a TaskResultCaller to wait for ' - 'results', - ); - } - final call = build(); - final taskId = await enqueue(enqueueOptions: enqueueOptions); - return call.definition.waitFor(boundEnqueuer, taskId, timeout: timeout); } + final mergedMeta = Map.from(scopeMeta)..addAll(call.meta); + return enqueuer.enqueueCall( + call.copyWith(meta: Map.unmodifiable(mergedMeta)), + enqueueOptions: resolvedEnqueueOptions, + ); } @@ -1213,7 +1174,11 @@ extension TaskDefinitionExtension if (enqueueOptions != null) { builder.enqueueOptions(enqueueOptions); } - return builder.enqueue(enqueuer, enqueueOptions: enqueueOptions); + return _enqueueBuiltTaskCall( + enqueuer, + builder.build(), + enqueueOptions: enqueueOptions, + ); } /// Enqueues this typed task definition and waits for its typed result. @@ -1243,10 +1208,17 @@ extension TaskDefinitionExtension if (enqueueOptions != null) { builder.enqueueOptions(enqueueOptions); } - return builder.enqueueAndWait( + final call = builder.build(); + return _enqueueBuiltTaskCall( caller, + call, enqueueOptions: enqueueOptions, - timeout: timeout, + ).then( + (taskId) => call.definition.waitFor( + caller, + taskId, + timeout: timeout, + ), ); } @@ -1288,7 +1260,11 @@ extension NoArgsTaskDefinitionExtension if (enqueueOptions != null) { builder.enqueueOptions(enqueueOptions); } - return builder.enqueue(enqueuer, enqueueOptions: enqueueOptions); + return _enqueueBuiltTaskCall( + enqueuer, + builder.build(), + enqueueOptions: enqueueOptions, + ); } /// Waits for [taskId] using this definition's decoding rules. diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 7a13a0e9..7ca6c58e 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -400,38 +400,11 @@ class WorkflowStartBuilder { } } -/// Convenience helpers for dispatching [WorkflowStartBuilder] instances. -extension WorkflowStartBuilderExtension - on WorkflowStartBuilder { - /// Builds this workflow call and starts it with the provided [caller]. - Future start(WorkflowCaller caller) { - return caller.startWorkflowCall(build()); - } - - /// Builds this workflow call, starts it with [caller], and waits for the - /// result. - Future?> startAndWait( - WorkflowCaller caller, { - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - final call = build(); - return caller.startWorkflowCall(call).then((runId) { - return call.definition.waitFor( - caller, - runId, - pollInterval: pollInterval, - timeout: timeout, - ); - }); - } -} - /// Caller-bound fluent workflow start builder. /// /// This mirrors the role `TaskInvocationContext.prepareEnqueue(...)` plays for /// tasks: a workflow-capable caller can create a fluent start request without -/// pivoting back through the workflow ref for dispatch. +/// pivoting back through the workflow ref. class BoundWorkflowStartBuilder { /// Creates a caller-bound workflow start builder. BoundWorkflowStartBuilder._({ @@ -466,21 +439,8 @@ class BoundWorkflowStartBuilder { /// Builds the [WorkflowStartCall] with accumulated overrides. WorkflowStartCall build() => _builder.build(); - /// Starts the built workflow call with the bound caller. - Future start() => _builder.start(_caller); - - /// Starts the built workflow call with the bound caller and waits for the - /// typed workflow result. - Future?> startAndWait({ - Duration pollInterval = const Duration(milliseconds: 100), - Duration? timeout, - }) { - return _builder.startAndWait( - _caller, - pollInterval: pollInterval, - timeout: timeout, - ); - } + /// Workflow caller attached to this builder. + WorkflowCaller get caller => _caller; } /// Convenience helpers for building typed workflow starts directly from a diff --git a/packages/stem/test/unit/core/task_context_enqueue_test.dart b/packages/stem/test/unit/core/task_context_enqueue_test.dart index 0d1a907c..5d78517f 100644 --- a/packages/stem/test/unit/core/task_context_enqueue_test.dart +++ b/packages/stem/test/unit/core/task_context_enqueue_test.dart @@ -254,7 +254,8 @@ void main() { args: const _ExampleArgs('hello'), ); - await builder.queue('priority').priority(7).enqueue(); + final call = builder.queue('priority').priority(7).build(); + await enqueuer.enqueueCall(call); final record = enqueuer.last!; expect(record.name, equals('tasks.typed')); @@ -343,13 +344,15 @@ void main() { encodeParams: (params) => params, ); - final result = await context + final call = context .prepareStart( definition: definition, params: const {'value': 'child'}, ) .parentRunId('parent-task') - .startAndWait(); + .build(); + final runId = await context.startWorkflowCall(call); + final result = await call.definition.waitFor(context, runId); expect(workflows.lastWorkflowName, 'workflow.child'); expect(workflows.lastWorkflowParams, {'value': 'child'}); diff --git a/packages/stem/test/unit/core/task_enqueue_builder_test.dart b/packages/stem/test/unit/core/task_enqueue_builder_test.dart index 16822d80..2929671a 100644 --- a/packages/stem/test/unit/core/task_enqueue_builder_test.dart +++ b/packages/stem/test/unit/core/task_enqueue_builder_test.dart @@ -81,20 +81,19 @@ void main() { expect(call.encodeArgs(), containsPair('a', 1)); }); - test( - 'TaskEnqueueBuilder.enqueueAndWait reuses typed result decoding', - () async { + test('TaskEnqueueBuilder.build composes with enqueueCall and typed waits', () async { final definition = TaskDefinition, String>( name: 'demo.task', encodeArgs: (args) => args, decodeResult: (payload) => 'decoded:$payload', ); final caller = _RecordingTaskResultCaller(); - - final result = await TaskEnqueueBuilder( + final call = TaskEnqueueBuilder( definition: definition, args: const {'a': 1}, - ).header('h1', 'v1').enqueueAndWait(caller); + ).header('h1', 'v1').build(); + final taskId = await caller.enqueueCall(call); + final result = await call.definition.waitFor(caller, taskId); expect(caller.lastCall, isNotNull); expect(caller.lastCall!.name, 'demo.task'); @@ -104,7 +103,7 @@ void main() { }, ); - test('TaskEnqueuer.prepareEnqueue binds enqueue to the enqueuer', () async { + test('TaskEnqueuer.prepareEnqueue binds builder assembly to the enqueuer', () async { final enqueuer = _RecordingTaskEnqueuer(); final definition = TaskDefinition, String>( name: 'demo.task', @@ -112,11 +111,11 @@ void main() { decodeResult: (payload) => 'decoded:$payload', ); - final taskId = await enqueuer + final builder = enqueuer .prepareEnqueue(definition: definition, args: const {'a': 1}) .header('h1', 'v1') - .queue('critical') - .enqueue(); + .queue('critical'); + final taskId = await enqueuer.enqueueCall(builder.build()); expect(taskId, 'task-1'); expect(enqueuer.lastCall, isNotNull); @@ -125,9 +124,7 @@ void main() { expect(enqueuer.lastCall!.resolveOptions().queue, 'critical'); }); - test( - 'BoundTaskEnqueueBuilder.enqueueAndWait reuses typed result decoding', - () async { + test('BoundTaskEnqueueBuilder.build composes with typed waits', () async { final caller = _RecordingTaskResultCaller(); final definition = TaskDefinition, String>( name: 'demo.task', @@ -135,10 +132,12 @@ void main() { decodeResult: (payload) => 'decoded:$payload', ); - final result = await caller + final builder = caller .prepareEnqueue(definition: definition, args: const {'a': 1}) - .header('h1', 'v1') - .enqueueAndWait(); + .header('h1', 'v1'); + final call = builder.build(); + final taskId = await caller.enqueueCall(call); + final result = await call.definition.waitFor(caller, taskId); expect(caller.lastCall, isNotNull); expect(caller.lastCall!.name, 'demo.task'); @@ -148,24 +147,6 @@ void main() { }, ); - test( - 'BoundTaskEnqueueBuilder.enqueueAndWait throws without a result caller', - () async { - final enqueuer = _RecordingTaskEnqueuer(); - final definition = TaskDefinition, String>( - name: 'demo.task', - encodeArgs: (args) => args, - ); - - expect( - () => enqueuer - .prepareEnqueue(definition: definition, args: const {'a': 1}) - .enqueueAndWait(), - throwsStateError, - ); - }, - ); - test('TaskCall.copyWith updates headers and meta', () { final definition = TaskDefinition, Object?>( name: 'demo.task', diff --git a/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart b/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart index 5b62e8ea..d1cb07b9 100644 --- a/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart +++ b/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart @@ -221,7 +221,9 @@ void main() { await stem.enqueue( 'tasks.primary.success', enqueueOptions: TaskEnqueueOptions( - link: [linkDefinition(const _ChildArgs('linked'))], + link: [ + linkDefinition.prepareEnqueue(const _ChildArgs('linked')).build(), + ], ), ); @@ -278,7 +280,9 @@ void main() { await stem.enqueue( 'tasks.primary.fail', enqueueOptions: TaskEnqueueOptions( - linkError: [linkDefinition(const _ChildArgs('linked'))], + linkError: [ + linkDefinition.prepareEnqueue(const _ChildArgs('linked')).build(), + ], ), ); @@ -539,7 +543,7 @@ FutureOr _isolateEnqueueEntrypoint( definition: _childDefinition, args: const _ChildArgs('from-isolate'), ); - await builder.enqueue(); + await context.enqueueCall(builder.build()); return null; } diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index c88d36c9..58f9c8bd 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -470,7 +470,9 @@ void main() { .ttl(const Duration(minutes: 5)) .parentRunId('parent-builder'); final builtFlowCall = flowBuilder.build(); - final runId = await flowBuilder.start(workflowApp.runtime); + final runId = await workflowApp.runtime.startWorkflowCall( + builtFlowCall, + ); final result = await workflowRef.waitFor( workflowApp.runtime, runId, @@ -493,8 +495,12 @@ void main() { ), ); final builtScriptCall = scriptBuilder.build(); - final oneShot = await scriptBuilder.startAndWait( + final scriptRunId = await workflowApp.runtime.startWorkflowCall( + builtScriptCall, + ); + final oneShot = await builtScriptCall.definition.waitFor( workflowApp.runtime, + scriptRunId, timeout: const Duration(seconds: 2), ); @@ -543,7 +549,9 @@ void main() { .ttl(const Duration(minutes: 5)) .parentRunId('parent-bound'); final builtFlowCall = flowBuilder.build(); - final runId = await flowBuilder.start(); + final runId = await workflowApp.runtime.startWorkflowCall( + builtFlowCall, + ); final result = await workflowRef.waitFor( workflowApp.runtime, runId, @@ -564,7 +572,12 @@ void main() { ), ); final builtScriptCall = scriptBuilder.build(); - final oneShot = await scriptBuilder.startAndWait( + final scriptRunId = await workflowApp.runtime.startWorkflowCall( + builtScriptCall, + ); + final oneShot = await builtScriptCall.definition.waitFor( + workflowApp.runtime, + scriptRunId, timeout: const Duration(seconds: 2), ); diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index 125dca4e..a866a276 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -1623,10 +1623,11 @@ void main() { name: 'meta.builder.workflow', build: (flow) { flow.step('dispatch', (context) async { - await TaskEnqueueBuilder( + final call = TaskEnqueueBuilder( definition: definition, args: const {}, - ).meta('origin', 'builder').enqueue(stem); + ).meta('origin', 'builder').build(); + await stem.enqueueCall(call); return 'done'; }); }, From 3115f3b8e029bd60299f186ab2e3c0ad47cd9936 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 15:05:25 -0500 Subject: [PATCH 262/302] Remove bound builder wrappers --- .site/docs/core-concepts/producer.md | 4 +- .site/docs/core-concepts/tasks.md | 9 +- packages/stem/lib/src/core/contracts.dart | 91 +------------------ .../stem/lib/src/core/task_invocation.dart | 9 +- .../lib/src/workflow/core/workflow_ref.dart | 52 +---------- .../unit/core/task_enqueue_builder_test.dart | 12 ++- .../workflow/workflow_runtime_ref_test.dart | 2 +- 7 files changed, 25 insertions(+), 154 deletions(-) diff --git a/.site/docs/core-concepts/producer.md b/.site/docs/core-concepts/producer.md index c8e81a25..b46b8e56 100644 --- a/.site/docs/core-concepts/producer.md +++ b/.site/docs/core-concepts/producer.md @@ -52,8 +52,8 @@ metadata, while exposing direct helpers and a fluent builder for overrides Typed helpers are also available on `Canvas` (`definition.toSignature`) so group/chain/chord APIs produce strongly typed `TaskResult` streams. Need to tweak headers/meta/queue at call sites? Start from -`definition.prepareEnqueue(args)` for the neutral builder, or use the -caller-bound `prepareEnqueue(...)` when you want the enqueue target baked in. +`definition.prepareEnqueue(args)`, or call `prepareEnqueue(...)` on an +enqueuer/context when that is the surface you already have. Raw task-name strings still work, but they are the lower-level interop path. Reach for them when the task name is truly dynamic or you are crossing a diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 21520a98..6525c34a 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -98,11 +98,10 @@ final request = context.argsJson( `TaskEnqueueBuilder` now only builds a `TaskCall`. Typed task definitions can create that fluent builder directly through `definition.prepareEnqueue(...)`. -`TaskEnqueuer.prepareEnqueue(...)` remains available when you want the -caller-bound variant that keeps the enqueue target attached to the builder. -Treat both `prepareEnqueue(...)` forms as the advanced override path, then -dispatch the built call with `enqueueCall(...)`. For the normal case, prefer -direct `enqueue(...)` / `enqueueAndWait(...)`. +`TaskEnqueuer.prepareEnqueue(...)` remains available when that is the surface +you already have. Treat both `prepareEnqueue(...)` forms as the advanced +override path, then dispatch the built call with `enqueueCall(...)`. For the +normal case, prefer direct `enqueue(...)` / `enqueueAndWait(...)`. For tasks with no producer inputs, use `TaskDefinition.noArgs(...)` instead. That gives you direct `enqueue(...)` / diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 48930662..93324d39 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -3182,88 +3182,6 @@ class TaskEnqueueBuilder { } } -/// Caller-bound fluent task enqueue builder. -/// -/// This keeps the enqueue target attached to the builder so producers and -/// contexts can stay on the enqueuer surface while assembling a call. -class BoundTaskEnqueueBuilder { - /// Creates a caller-bound task enqueue builder. - BoundTaskEnqueueBuilder({ - required this.enqueuer, - required TaskEnqueueBuilder builder, - }) : _builder = builder; - - /// Bound enqueuer used for dispatch. - final TaskEnqueuer enqueuer; - final TaskEnqueueBuilder _builder; - - /// Replaces headers entirely. - BoundTaskEnqueueBuilder headers(Map headers) { - _builder.headers(headers); - return this; - } - - /// Adds or overrides a single header entry. - BoundTaskEnqueueBuilder header(String key, String value) { - _builder.header(key, value); - return this; - } - - /// Replaces metadata entirely. - BoundTaskEnqueueBuilder metadata(Map meta) { - _builder.metadata(meta); - return this; - } - - /// Adds or overrides a metadata entry. - BoundTaskEnqueueBuilder meta(String key, Object? value) { - _builder.meta(key, value); - return this; - } - - /// Replaces the options for this call. - BoundTaskEnqueueBuilder options(TaskOptions options) { - _builder.options(options); - return this; - } - - /// Sets the queue for this enqueue. - BoundTaskEnqueueBuilder queue(String queue) { - _builder.queue(queue); - return this; - } - - /// Sets the priority for this enqueue. - BoundTaskEnqueueBuilder priority(int priority) { - _builder.priority(priority); - return this; - } - - /// Sets the earliest execution time. - BoundTaskEnqueueBuilder notBefore(DateTime instant) { - _builder.notBefore(instant); - return this; - } - - /// Sets a relative delay before execution. - BoundTaskEnqueueBuilder delay(Duration duration) { - _builder.delay(duration); - return this; - } - - /// Replaces the enqueue options for this call. - BoundTaskEnqueueBuilder enqueueOptions( - TaskEnqueueOptions options, - ) { - _builder.enqueueOptions(options); - return this; - } - - /// Builds the [TaskCall] with accumulated overrides. - TaskCall build() => _builder.build(); - -} - /// Convenience helpers for building typed enqueue requests directly from a task /// enqueuer. extension TaskEnqueuerBuilderExtension on TaskEnqueuer { @@ -3324,15 +3242,12 @@ extension TaskEnqueuerBuilderExtension on TaskEnqueuer { ); } - /// Creates a caller-bound fluent builder for a typed task definition. - BoundTaskEnqueueBuilder prepareEnqueue({ + /// Creates a fluent builder for a typed task definition. + TaskEnqueueBuilder prepareEnqueue({ required TaskDefinition definition, required TArgs args, }) { - return BoundTaskEnqueueBuilder( - enqueuer: this, - builder: TaskEnqueueBuilder(definition: definition, args: args), - ); + return TaskEnqueueBuilder(definition: definition, args: args); } } diff --git a/packages/stem/lib/src/core/task_invocation.dart b/packages/stem/lib/src/core/task_invocation.dart index 8de3989e..8a2ee037 100644 --- a/packages/stem/lib/src/core/task_invocation.dart +++ b/packages/stem/lib/src/core/task_invocation.dart @@ -845,15 +845,12 @@ class TaskInvocationContext implements TaskExecutionContext { return delegate.emitEvent(event, value); } - /// Build a caller-bound fluent enqueue request for this invocation. - BoundTaskEnqueueBuilder prepareEnqueue({ + /// Build a fluent enqueue request for this invocation. + TaskEnqueueBuilder prepareEnqueue({ required TaskDefinition definition, required TArgs args, }) { - return BoundTaskEnqueueBuilder( - enqueuer: this, - builder: TaskEnqueueBuilder(definition: definition, args: args), - ); + return TaskEnqueueBuilder(definition: definition, args: args); } /// Alias for enqueue. diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 7ca6c58e..cba61a64 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -400,62 +400,16 @@ class WorkflowStartBuilder { } } -/// Caller-bound fluent workflow start builder. -/// -/// This mirrors the role `TaskInvocationContext.prepareEnqueue(...)` plays for -/// tasks: a workflow-capable caller can create a fluent start request without -/// pivoting back through the workflow ref. -class BoundWorkflowStartBuilder { - /// Creates a caller-bound workflow start builder. - BoundWorkflowStartBuilder._({ - required WorkflowCaller caller, - required WorkflowStartBuilder builder, - }) : _caller = caller, - _builder = builder; - - final WorkflowCaller _caller; - final WorkflowStartBuilder _builder; - - /// Sets the parent workflow run id for this start. - BoundWorkflowStartBuilder parentRunId(String parentRunId) { - _builder.parentRunId(parentRunId); - return this; - } - - /// Sets the retention TTL for this run. - BoundWorkflowStartBuilder ttl(Duration ttl) { - _builder.ttl(ttl); - return this; - } - - /// Sets the cancellation policy for this run. - BoundWorkflowStartBuilder cancellationPolicy( - WorkflowCancellationPolicy cancellationPolicy, - ) { - _builder.cancellationPolicy(cancellationPolicy); - return this; - } - - /// Builds the [WorkflowStartCall] with accumulated overrides. - WorkflowStartCall build() => _builder.build(); - - /// Workflow caller attached to this builder. - WorkflowCaller get caller => _caller; -} - /// Convenience helpers for building typed workflow starts directly from a /// workflow-capable caller. extension WorkflowCallerBuilderExtension on WorkflowCaller { - /// Creates a caller-bound fluent start builder for a typed workflow ref. - BoundWorkflowStartBuilder + /// Creates a fluent start builder for a typed workflow ref. + WorkflowStartBuilder prepareStart({ required WorkflowRef definition, required TParams params, }) { - return BoundWorkflowStartBuilder._( - caller: this, - builder: definition.prepareStart(params), - ); + return definition.prepareStart(params); } } diff --git a/packages/stem/test/unit/core/task_enqueue_builder_test.dart b/packages/stem/test/unit/core/task_enqueue_builder_test.dart index 2929671a..a827dc63 100644 --- a/packages/stem/test/unit/core/task_enqueue_builder_test.dart +++ b/packages/stem/test/unit/core/task_enqueue_builder_test.dart @@ -81,7 +81,9 @@ void main() { expect(call.encodeArgs(), containsPair('a', 1)); }); - test('TaskEnqueueBuilder.build composes with enqueueCall and typed waits', () async { + test( + 'TaskEnqueueBuilder.build composes with enqueueCall and typed waits', + () async { final definition = TaskDefinition, String>( name: 'demo.task', encodeArgs: (args) => args, @@ -103,7 +105,9 @@ void main() { }, ); - test('TaskEnqueuer.prepareEnqueue binds builder assembly to the enqueuer', () async { + test( + 'TaskEnqueuer.prepareEnqueue binds builder assembly to the enqueuer', + () async { final enqueuer = _RecordingTaskEnqueuer(); final definition = TaskDefinition, String>( name: 'demo.task', @@ -124,7 +128,9 @@ void main() { expect(enqueuer.lastCall!.resolveOptions().queue, 'critical'); }); - test('BoundTaskEnqueueBuilder.build composes with typed waits', () async { + test( + 'TaskEnqueuer.prepareEnqueue builders compose with typed waits', + () async { final caller = _RecordingTaskResultCaller(); final definition = TaskDefinition, String>( name: 'demo.task', diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 58f9c8bd..96b7c2e7 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -514,7 +514,7 @@ void main() { } }); - test('workflow callers expose bound workflow builders', () async { + test('workflow callers expose workflow start builders', () async { final flow = Flow( name: 'runtime.ref.bound.builder.flow', build: (builder) { From e7c073b50802379940299ba4606d45f5d22ba1b0 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 15:15:03 -0500 Subject: [PATCH 263/302] Remove caller prepare builder wrappers --- .site/docs/core-concepts/producer.md | 3 +-- .site/docs/core-concepts/tasks.md | 7 +++--- .site/docs/workflows/annotated-workflows.md | 4 ++-- .../workflows/context-and-serialization.md | 4 ++-- .site/docs/workflows/starting-and-waiting.md | 2 +- packages/stem/README.md | 4 ++-- packages/stem/lib/src/core/contracts.dart | 8 ------- .../stem/lib/src/core/task_invocation.dart | 8 ------- .../lib/src/workflow/core/workflow_ref.dart | 14 ------------ .../unit/core/task_context_enqueue_test.dart | 22 +++---------------- .../unit/core/task_enqueue_builder_test.dart | 14 +++++------- ...task_context_enqueue_integration_test.dart | 5 ++--- .../workflow/workflow_runtime_ref_test.dart | 13 +++++------ 13 files changed, 27 insertions(+), 81 deletions(-) diff --git a/.site/docs/core-concepts/producer.md b/.site/docs/core-concepts/producer.md index b46b8e56..a2042778 100644 --- a/.site/docs/core-concepts/producer.md +++ b/.site/docs/core-concepts/producer.md @@ -52,8 +52,7 @@ metadata, while exposing direct helpers and a fluent builder for overrides Typed helpers are also available on `Canvas` (`definition.toSignature`) so group/chain/chord APIs produce strongly typed `TaskResult` streams. Need to tweak headers/meta/queue at call sites? Start from -`definition.prepareEnqueue(args)`, or call `prepareEnqueue(...)` on an -enqueuer/context when that is the surface you already have. +`definition.prepareEnqueue(args)` when you need the advanced builder path. Raw task-name strings still work, but they are the lower-level interop path. Reach for them when the task name is truly dynamic or you are crossing a diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 6525c34a..87c9fbbd 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -98,10 +98,9 @@ final request = context.argsJson( `TaskEnqueueBuilder` now only builds a `TaskCall`. Typed task definitions can create that fluent builder directly through `definition.prepareEnqueue(...)`. -`TaskEnqueuer.prepareEnqueue(...)` remains available when that is the surface -you already have. Treat both `prepareEnqueue(...)` forms as the advanced -override path, then dispatch the built call with `enqueueCall(...)`. For the -normal case, prefer direct `enqueue(...)` / `enqueueAndWait(...)`. +Treat `prepareEnqueue(...)` as the advanced override path, then dispatch the +built call with `enqueueCall(...)`. For the normal case, prefer direct +`enqueue(...)` / `enqueueAndWait(...)`. For tasks with no producer inputs, use `TaskDefinition.noArgs(...)` instead. That gives you direct `enqueue(...)` / diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index b1327516..43518c72 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -136,8 +136,8 @@ When a workflow needs to start another workflow, do it from a durable boundary: methods - pass `ttl:`, `parentRunId:`, or `cancellationPolicy:` directly to `ref.start(...)` / `ref.startAndWait(...)` for the normal override cases -- keep `context.prepareStart(...)` for the rarer incremental-call - cases where you actually want to build the start request step by step +- keep `ref.prepareStart(params)` for the rarer incremental-call cases where + you actually want to build the start request step by step Avoid starting child workflows from the raw `WorkflowScriptContext` body. diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index 01b32686..a16c22f4 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -88,8 +88,8 @@ Child workflow starts belong in durable boundaries: - `ref.startAndWait(context, params: value)` inside script checkpoints - pass `ttl:`, `parentRunId:`, or `cancellationPolicy:` directly to those helpers for the normal override cases -- keep `context.prepareStart(...)` for incremental-call assembly when - you genuinely need to build the start request step by step +- keep `ref.prepareStart(params)` for incremental-call assembly when you + genuinely need to build the start request step by step Do not treat the raw `WorkflowScriptContext` body as a safe place for child starts or other replay-sensitive side effects. diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 0c6b653a..bad53e18 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -167,7 +167,7 @@ final result = await StemWorkflowDefinitions.userSignup.startAndWait( If you still need the run identifier for inspection or operator tooling, read it from `result.runId`. -Keep `context.prepareStart(...)` for the rarer cases where you want to +Keep `ref.prepareStart(params)` for the rarer cases where you want to assemble a start request incrementally before dispatch. ## Parent runs and TTL diff --git a/packages/stem/README.md b/packages/stem/README.md index cb1ff028..12215c1b 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -729,8 +729,8 @@ Child workflows belong in durable execution boundaries: checkpoints - pass `ttl:`, `parentRunId:`, or `cancellationPolicy:` directly to `ref.start(...)` / `ref.startAndWait(...)` for normal override cases -- keep `context.prepareStart(...)` for the rarer incremental-call - cases where you actually want to build the start request step by step +- keep `ref.prepareStart(params)` for the rarer incremental-call cases where + you actually want to build the start request step by step - do not start child workflows from the raw `WorkflowScriptContext` body unless you are deliberately managing replay/idempotency yourself diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 93324d39..738b636a 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -3242,14 +3242,6 @@ extension TaskEnqueuerBuilderExtension on TaskEnqueuer { ); } - /// Creates a fluent builder for a typed task definition. - TaskEnqueueBuilder prepareEnqueue({ - required TaskDefinition definition, - required TArgs args, - }) { - return TaskEnqueueBuilder(definition: definition, args: args); - } - } /// Retry strategy used to compute the next backoff delay. diff --git a/packages/stem/lib/src/core/task_invocation.dart b/packages/stem/lib/src/core/task_invocation.dart index 8a2ee037..544b67ed 100644 --- a/packages/stem/lib/src/core/task_invocation.dart +++ b/packages/stem/lib/src/core/task_invocation.dart @@ -845,14 +845,6 @@ class TaskInvocationContext implements TaskExecutionContext { return delegate.emitEvent(event, value); } - /// Build a fluent enqueue request for this invocation. - TaskEnqueueBuilder prepareEnqueue({ - required TaskDefinition definition, - required TArgs args, - }) { - return TaskEnqueueBuilder(definition: definition, args: args); - } - /// Alias for enqueue. @override Future spawn( diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index cba61a64..cd235d62 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -400,20 +400,6 @@ class WorkflowStartBuilder { } } -/// Convenience helpers for building typed workflow starts directly from a -/// workflow-capable caller. -extension WorkflowCallerBuilderExtension on WorkflowCaller { - /// Creates a fluent start builder for a typed workflow ref. - WorkflowStartBuilder - prepareStart({ - required WorkflowRef definition, - required TParams params, - }) { - return definition.prepareStart(params); - } - -} - /// Convenience helpers for waiting on typed workflow refs using a generic /// [WorkflowCaller]. extension WorkflowRefExtension diff --git a/packages/stem/test/unit/core/task_context_enqueue_test.dart b/packages/stem/test/unit/core/task_context_enqueue_test.dart index 5d78517f..695f9fc1 100644 --- a/packages/stem/test/unit/core/task_context_enqueue_test.dart +++ b/packages/stem/test/unit/core/task_context_enqueue_test.dart @@ -233,26 +233,13 @@ void main() { group('TaskInvocationContext builder', () { test('supports fluent enqueue builder API', () async { final enqueuer = _RecordingEnqueuer(); - final context = TaskInvocationContext.local( - id: 'invocation-1', - headers: const {}, - meta: const {}, - attempt: 0, - heartbeat: () {}, - extendLease: (_) async {}, - progress: (_, {data}) async {}, - enqueuer: enqueuer, - ); final definition = TaskDefinition<_ExampleArgs, void>( name: 'tasks.typed', encodeArgs: (args) => {'value': args.value}, ); - final builder = context.prepareEnqueue( - definition: definition, - args: const _ExampleArgs('hello'), - ); + final builder = definition.prepareEnqueue(const _ExampleArgs('hello')); final call = builder.queue('priority').priority(7).build(); await enqueuer.enqueueCall(call); @@ -344,11 +331,8 @@ void main() { encodeParams: (params) => params, ); - final call = context - .prepareStart( - definition: definition, - params: const {'value': 'child'}, - ) + final call = definition + .prepareStart(const {'value': 'child'}) .parentRunId('parent-task') .build(); final runId = await context.startWorkflowCall(call); diff --git a/packages/stem/test/unit/core/task_enqueue_builder_test.dart b/packages/stem/test/unit/core/task_enqueue_builder_test.dart index a827dc63..6ad1085b 100644 --- a/packages/stem/test/unit/core/task_enqueue_builder_test.dart +++ b/packages/stem/test/unit/core/task_enqueue_builder_test.dart @@ -106,7 +106,7 @@ void main() { ); test( - 'TaskEnqueuer.prepareEnqueue binds builder assembly to the enqueuer', + 'TaskEnqueueBuilder.build preserves enqueuer dispatch semantics', () async { final enqueuer = _RecordingTaskEnqueuer(); final definition = TaskDefinition, String>( @@ -115,8 +115,8 @@ void main() { decodeResult: (payload) => 'decoded:$payload', ); - final builder = enqueuer - .prepareEnqueue(definition: definition, args: const {'a': 1}) + final builder = definition + .prepareEnqueue(const {'a': 1}) .header('h1', 'v1') .queue('critical'); final taskId = await enqueuer.enqueueCall(builder.build()); @@ -128,9 +128,7 @@ void main() { expect(enqueuer.lastCall!.resolveOptions().queue, 'critical'); }); - test( - 'TaskEnqueuer.prepareEnqueue builders compose with typed waits', - () async { + test('TaskEnqueueBuilder composes with typed waits', () async { final caller = _RecordingTaskResultCaller(); final definition = TaskDefinition, String>( name: 'demo.task', @@ -138,8 +136,8 @@ void main() { decodeResult: (payload) => 'decoded:$payload', ); - final builder = caller - .prepareEnqueue(definition: definition, args: const {'a': 1}) + final builder = definition + .prepareEnqueue(const {'a': 1}) .header('h1', 'v1'); final call = builder.build(); final taskId = await caller.enqueueCall(call); diff --git a/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart b/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart index d1cb07b9..aed6ff5c 100644 --- a/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart +++ b/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart @@ -539,9 +539,8 @@ FutureOr _isolateEnqueueEntrypoint( TaskInvocationContext context, Map args, ) async { - final builder = context.prepareEnqueue( - definition: _childDefinition, - args: const _ChildArgs('from-isolate'), + final builder = _childDefinition.prepareEnqueue( + const _ChildArgs('from-isolate'), ); await context.enqueueCall(builder.build()); return null; diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 96b7c2e7..ee479d25 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -514,7 +514,7 @@ void main() { } }); - test('workflow callers expose workflow start builders', () async { + test('workflow refs expose workflow start builders', () async { final flow = Flow( name: 'runtime.ref.bound.builder.flow', build: (builder) { @@ -541,11 +541,8 @@ void main() { try { await workflowApp.start(); - final flowBuilder = workflowApp.runtime - .prepareStart( - definition: workflowRef, - params: const {'name': 'builder'}, - ) + final flowBuilder = workflowRef + .prepareStart(const {'name': 'builder'}) .ttl(const Duration(minutes: 5)) .parentRunId('parent-bound'); final builtFlowCall = flowBuilder.build(); @@ -564,8 +561,8 @@ void main() { expect(result?.value, 'hello builder'); expect(state?.parentRunId, 'parent-bound'); - final scriptBuilder = workflowApp.runtime - .prepareStart(definition: scriptRef.asRef, params: ()) + final scriptBuilder = scriptRef.asRef + .prepareStart(()) .cancellationPolicy( const WorkflowCancellationPolicy( maxRunDuration: Duration(seconds: 5), From e0daf526beba46725d42e07b602f6070a096db36 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 15:16:02 -0500 Subject: [PATCH 264/302] Remove caller prepare builder wrappers --- packages/stem/CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 314ac809..220532ad 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -29,6 +29,9 @@ `enqueue(...)` / `enqueueAndWait(...)` are the default happy path, with `prepareStart(...)` and `prepareEnqueue(...)` positioned as advanced override builders. +- Removed the caller/context `prepareStart(...)` and `prepareEnqueue(...)` + wrapper entrypoints so the advanced builder path now hangs directly off + `WorkflowRef` and `TaskDefinition`. - Added `QueueCustomEvent.metaJson(...)`, `metaVersionedJson(...)`, and `metaAs(codec: ...)` so queue-event metadata can decode DTO payloads without raw map casts. From 421fedcdbc6e0a666ba08129b27d0c00f99a8e7c Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 15:18:42 -0500 Subject: [PATCH 265/302] Clarify transport objects as advanced APIs --- .site/docs/core-concepts/stem-builder.md | 4 ++-- .site/docs/core-concepts/tasks.md | 3 ++- .site/docs/workflows/annotated-workflows.md | 11 ++++++----- .site/docs/workflows/starting-and-waiting.md | 5 +++-- packages/stem/CHANGELOG.md | 3 +++ packages/stem/README.md | 12 ++++++------ packages/stem/example/annotated_workflows/README.md | 4 ++-- packages/stem_builder/README.md | 4 ++-- 8 files changed, 26 insertions(+), 20 deletions(-) diff --git a/.site/docs/core-concepts/stem-builder.md b/.site/docs/core-concepts/stem-builder.md index d9b7f9ac..4addd92c 100644 --- a/.site/docs/core-concepts/stem-builder.md +++ b/.site/docs/core-concepts/stem-builder.md @@ -73,8 +73,8 @@ Generated output (`workflow_defs.stem.g.dart`) includes: - `stemModule` - typed workflow refs like `StemWorkflowDefinitions.userSignup` -- typed task definitions that use the shared `TaskCall` / - `TaskDefinition.waitFor(...)` APIs +- typed task definitions whose advanced explicit transport path uses + `TaskCall` ## Wire Into StemWorkflowApp diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 87c9fbbd..9d550eee 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -41,7 +41,8 @@ common path, use the direct `definition.enqueue(stem, args)` / `definition.enqueueAndWait(...)` helpers. When you need a reusable prebuilt request, use `definition.prepareEnqueue(args).build()` and hand the resulting `TaskCall` to -any `TaskResultCaller` / `TaskEnqueuer` surface: +any `TaskResultCaller` / `TaskEnqueuer` surface. Treat `TaskCall` as the +explicit low-level transport object, not the normal happy path: ```dart file=/../packages/stem/example/docs_snippets/lib/tasks.dart#tasks-typed-definition diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index 43518c72..8dd31b42 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -14,12 +14,13 @@ generated file exposes: - `StemWorkflowDefinitions` - `StemTaskDefinitions` - typed workflow refs like `StemWorkflowDefinitions.userSignup` -- typed task definitions that use the shared `TaskCall` / - `TaskDefinition.waitFor(...)` APIs +- typed task definitions whose advanced explicit transport path uses + `TaskCall` -The generated task definitions are producer-safe: `Stem.enqueueCall(...)` can -publish from the definition metadata, so producer processes do not need to -register the worker handler locally just to enqueue typed task calls. +The generated task definitions are producer-safe: `Stem.enqueueCall(...)` +remains the explicit low-level transport path, and it can publish from the +definition metadata so producer processes do not need to register the worker +handler locally just to enqueue typed task calls. Wire the bundle directly into `StemWorkflowApp`: diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index bad53e18..c62496f7 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -86,8 +86,9 @@ payloads that do not yet carry a stored version marker. For workflows without start params, start directly from the flow or script itself with `start(...)` or `startAndWait(...)`. Keep `prepareStart()` for the rarer cases where you want to assemble overrides incrementally before calling -`startWorkflowCall(...)`. Use `ref0()` when another API specifically needs a -`NoArgsWorkflowRef`. +`startWorkflowCall(...)`. Treat `WorkflowStartCall` as the explicit low-level +transport object, not the normal happy path. Use `ref0()` when another API +specifically needs a `NoArgsWorkflowRef`. ## Wait for completion diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 220532ad..277ab4fa 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -32,6 +32,9 @@ - Removed the caller/context `prepareStart(...)` and `prepareEnqueue(...)` wrapper entrypoints so the advanced builder path now hangs directly off `WorkflowRef` and `TaskDefinition`. +- Clarified docs so `TaskCall` and `WorkflowStartCall` are described as the + explicit low-level transport path, not peer happy-path APIs beside direct + `enqueue(...)` / `start(...)` helpers. - Added `QueueCustomEvent.metaJson(...)`, `metaVersionedJson(...)`, and `metaAs(codec: ...)` so queue-event metadata can decode DTO payloads without raw map casts. diff --git a/packages/stem/README.md b/packages/stem/README.md index 12215c1b..c565f96d 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -214,9 +214,10 @@ Future main() async { } ``` -`Stem.enqueueCall(...)` can publish from the `TaskDefinition` metadata alone, so -producer-only processes do not need to register the worker handler locally just -to enqueue typed calls. +`Stem.enqueueCall(...)` remains the explicit low-level transport path for a +prebuilt `TaskCall`, and it can publish from the `TaskDefinition` metadata +alone. Producer-only processes therefore do not need to register the worker +handler locally just to enqueue typed calls. Use `TaskDefinition.json(...)` when your manual task args are normal DTOs with `toJson()`. Use `TaskDefinition.versionedJson(...)` when the DTO @@ -798,9 +799,8 @@ Generated output gives you: - `stemModule` - `StemWorkflowDefinitions` - `StemTaskDefinitions` -- typed workflow refs and task definitions that use the shared - `WorkflowStartCall`, `TaskCall`, `WorkflowRef`, and - `TaskDefinition.waitFor(...)` APIs +- typed workflow refs and task definitions whose advanced explicit transport + path uses `WorkflowStartCall` / `TaskCall` The same bundle also works for plain task apps: diff --git a/packages/stem/example/annotated_workflows/README.md b/packages/stem/example/annotated_workflows/README.md index b6608c69..c4463a6f 100644 --- a/packages/stem/example/annotated_workflows/README.md +++ b/packages/stem/example/annotated_workflows/README.md @@ -40,8 +40,8 @@ The generated file exposes: - `stemModule` - `StemWorkflowDefinitions` - typed workflow refs for `StemWorkflowApp` and `WorkflowRuntime` -- typed task definitions that use the shared `TaskCall` / - `TaskDefinition.waitFor(...)` APIs +- typed task definitions whose advanced explicit transport path uses + `TaskCall` When you pass `module: stemModule` into `StemWorkflowApp`, or create a `StemClient` with `module: stemModule` and then call diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index a129187e..a459d4d0 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -184,8 +184,8 @@ Generated output includes: - `stemModule` - `StemWorkflowDefinitions` - `StemTaskDefinitions` -- typed `TaskDefinition` objects that use the shared `TaskCall` / - `TaskDefinition.waitFor(...)` APIs from `stem` +- typed `TaskDefinition` objects whose advanced explicit transport path uses + `TaskCall`, alongside direct `enqueue(...)` / `enqueueAndWait(...)` Generated task definitions are producer-safe. `Stem.enqueueCall(...)` can use the definition metadata directly, so a producer can publish typed task calls From 53c6a6e5b26ea6176bbbbcaeadd9e95f1a2301c2 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 15:22:49 -0500 Subject: [PATCH 266/302] Add direct explicit transport builders --- .site/docs/core-concepts/tasks.md | 6 ++- .site/docs/workflows/starting-and-waiting.md | 3 ++ packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 4 +- packages/stem/lib/src/core/contracts.dart | 23 +++++++++- packages/stem/lib/src/core/stem.dart | 4 +- .../lib/src/workflow/core/workflow_ref.dart | 44 +++++++++++++++---- .../test/unit/core/task_registry_test.dart | 14 +++--- ...workflow_runtime_call_extensions_test.dart | 10 ++--- 9 files changed, 84 insertions(+), 27 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 9d550eee..0327fb23 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -40,6 +40,7 @@ name, argument encoder, optional metadata, and default `TaskOptions`. For the common path, use the direct `definition.enqueue(stem, args)` / `definition.enqueueAndWait(...)` helpers. When you need a reusable prebuilt request, use +`definition.buildCall(args, ...)` or `definition.prepareEnqueue(args).build()` and hand the resulting `TaskCall` to any `TaskResultCaller` / `TaskEnqueuer` surface. Treat `TaskCall` as the explicit low-level transport object, not the normal happy path: @@ -99,8 +100,9 @@ final request = context.argsJson( `TaskEnqueueBuilder` now only builds a `TaskCall`. Typed task definitions can create that fluent builder directly through `definition.prepareEnqueue(...)`. -Treat `prepareEnqueue(...)` as the advanced override path, then dispatch the -built call with `enqueueCall(...)`. For the normal case, prefer direct +Use `buildCall(...)` when you already know the full overrides up front. +Treat `prepareEnqueue(...)` as the incremental advanced path, then dispatch +the built call with `enqueueCall(...)`. For the normal case, prefer direct `enqueue(...)` / `enqueueAndWait(...)`. For tasks with no producer inputs, use `TaskDefinition.noArgs(...)` diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index c62496f7..7be9e4f2 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -90,6 +90,9 @@ rarer cases where you want to assemble overrides incrementally before calling transport object, not the normal happy path. Use `ref0()` when another API specifically needs a `NoArgsWorkflowRef`. +When you already know the full override set up front, prefer +`ref.buildStart(...)` over `prepareStart(...).build()`. + ## Wait for completion For workflows defined in code, prefer direct workflow helpers or typed refs diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 277ab4fa..7fe8748f 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -35,6 +35,9 @@ - Clarified docs so `TaskCall` and `WorkflowStartCall` are described as the explicit low-level transport path, not peer happy-path APIs beside direct `enqueue(...)` / `start(...)` helpers. +- Added direct `buildCall(...)` / `buildStart(...)` helpers on task/workflow + definitions so the explicit transport path no longer requires + `prepare...().build()` when all overrides are already known. - Added `QueueCustomEvent.metaJson(...)`, `metaVersionedJson(...)`, and `metaAs(codec: ...)` so queue-event metadata can decode DTO payloads without raw map casts. diff --git a/packages/stem/README.md b/packages/stem/README.md index c565f96d..e27def07 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -314,7 +314,9 @@ print(result?.value); Treat `prepareEnqueue(...)` as the advanced path when you need to assemble headers, metadata, delay, priority, or other overrides incrementally. For the normal case, prefer direct `enqueue(...)` or `enqueueAndWait(...)`. Builders -now only produce `TaskCall`; dispatch those with `enqueueCall(...)`. +now only produce `TaskCall`; dispatch those with `enqueueCall(...)`. If you +already know the full override set up front, prefer `definition.buildCall(...)` +for the explicit transport path. ### Enqueue from inside a task diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 738b636a..bea28011 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2930,7 +2930,8 @@ class TaskDefinition { ); } - TaskCall _buildCall( + /// Builds an explicit [TaskCall] from this definition and [args]. + TaskCall buildCall( TArgs args, { Map headers = const {}, TaskOptions? options, @@ -3010,6 +3011,24 @@ class NoArgsTaskDefinition { /// Decodes a persisted payload into a typed result. TResult? decode(Object? payload) => asDefinition.decode(payload); + + /// Builds an explicit [TaskCall] for this no-arg task definition. + TaskCall<(), TResult> buildCall({ + Map headers = const {}, + TaskOptions? options, + DateTime? notBefore, + Map? meta, + TaskEnqueueOptions? enqueueOptions, + }) { + return asDefinition.buildCall( + (), + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ); + } } /// Represents a pending enqueue operation built from a [TaskDefinition]. @@ -3163,7 +3182,7 @@ class TaskEnqueueBuilder { /// Builds the [TaskCall] with accumulated overrides. TaskCall build() { - final base = definition._buildCall(args); + final base = definition.buildCall(args); final mergedHeaders = Map.from(base.headers); if (_headers != null) { mergedHeaders.addAll(_headers!); diff --git a/packages/stem/lib/src/core/stem.dart b/packages/stem/lib/src/core/stem.dart index 263d6e5c..7dc263c2 100644 --- a/packages/stem/lib/src/core/stem.dart +++ b/packages/stem/lib/src/core/stem.dart @@ -197,8 +197,8 @@ class Stem implements TaskResultCaller { return resolved.getGroup(groupId); } - /// Enqueue a typed task using a [TaskCall] wrapper produced by a - /// [TaskDefinition]. + /// Enqueue a typed task using an explicit [TaskCall] transport object, + /// typically produced by `TaskDefinition.buildCall(...)`. @override Future enqueueCall( TaskCall call, { diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index cd235d62..ba602c58 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -164,12 +164,11 @@ class WorkflowRef { WorkflowCancellationPolicy? cancellationPolicy, }) { return caller.startWorkflowCall( - WorkflowStartCall._( - definition: this, - params: params, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, + buildStart( + params: params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, ), ); } @@ -185,8 +184,7 @@ class WorkflowRef { Duration pollInterval = const Duration(milliseconds: 100), Duration? timeout, }) { - final call = WorkflowStartCall._( - definition: this, + final call = buildStart( params: params, parentRunId: parentRunId, ttl: ttl, @@ -201,6 +199,22 @@ class WorkflowRef { ); }); } + + /// Builds an explicit [WorkflowStartCall] for this workflow ref. + WorkflowStartCall buildStart({ + required TParams params, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return WorkflowStartCall._( + definition: this, + params: params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } } /// Typed producer-facing reference for workflows that take no input params. @@ -261,6 +275,20 @@ class NoArgsWorkflowRef { ); } + /// Builds an explicit [WorkflowStartCall] for this no-args workflow ref. + WorkflowStartCall<(), TResult> buildStart({ + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return asRef.buildStart( + params: (), + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + /// Decodes a final workflow result payload. TResult decode(Object? payload) => asRef.decode(payload); diff --git a/packages/stem/test/unit/core/task_registry_test.dart b/packages/stem/test/unit/core/task_registry_test.dart index 2b9daf41..cc1234ba 100644 --- a/packages/stem/test/unit/core/task_registry_test.dart +++ b/packages/stem/test/unit/core/task_registry_test.dart @@ -201,7 +201,7 @@ void main() { encodeArgs: (args) => {'value': args.value}, ); - final call = definition.prepareEnqueue(_Args(42)).build(); + final call = definition.buildCall(_Args(42)); expect(call.name, 'demo.task'); expect(call.encodeArgs(), {'value': 42}); expect(call.resolveOptions(), const TaskOptions()); @@ -219,12 +219,12 @@ void main() { encodeArgs: (args) => {'value': args.value}, ); - final call = definition - .prepareEnqueue(_Args(99)) - .headers({'x-id': 'abc'}) - .options(const TaskOptions(queue: 'custom')) - .metadata(const {'source': 'test'}) - .build(); + final call = definition.buildCall( + _Args(99), + headers: {'x-id': 'abc'}, + options: const TaskOptions(queue: 'custom'), + meta: const {'source': 'test'}, + ); final id = await stem.enqueueCall(call); expect(id, isNotEmpty); diff --git a/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart b/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart index 8d71a0e3..c818a9bc 100644 --- a/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_call_extensions_test.dart @@ -4,7 +4,7 @@ import 'package:test/test.dart'; void main() { group('runtime workflow start call dispatch', () { test( - 'prepareStart().build() can be dispatched through WorkflowCaller', + 'buildStart() can be dispatched through WorkflowCaller', () async { final flow = Flow( name: 'runtime.extension.flow', @@ -25,7 +25,7 @@ void main() { await workflowApp.start(); final runId = await workflowApp.runtime.startWorkflowCall( - workflowRef.prepareStart(const {'name': 'runtime'}).build(), + workflowRef.buildStart(params: const {'name': 'runtime'}), ); final waited = await workflowRef.waitFor( workflowApp.runtime, @@ -35,9 +35,9 @@ void main() { expect(waited?.value, 'hello runtime'); - final inlineCall = workflowRef - .prepareStart(const {'name': 'inline'}) - .build(); + final inlineCall = workflowRef.buildStart( + params: const {'name': 'inline'}, + ); final inlineRunId = await workflowApp.runtime.startWorkflowCall( inlineCall, ); From 025797ac298fea01182fe14123031806e253cdcd Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 15:25:34 -0500 Subject: [PATCH 267/302] Remove workflow start builder path --- .site/docs/workflows/annotated-workflows.md | 5 +- .../workflows/context-and-serialization.md | 4 +- .site/docs/workflows/flows-and-scripts.md | 8 +-- .site/docs/workflows/starting-and-waiting.md | 14 ++--- packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 5 +- .../lib/src/workflow/core/workflow_ref.dart | 52 ------------------ .../unit/core/task_context_enqueue_test.dart | 8 +-- .../workflow/workflow_runtime_ref_test.dart | 54 +++++++++---------- 9 files changed, 50 insertions(+), 103 deletions(-) diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index 8dd31b42..4344adc7 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -137,8 +137,9 @@ When a workflow needs to start another workflow, do it from a durable boundary: methods - pass `ttl:`, `parentRunId:`, or `cancellationPolicy:` directly to `ref.start(...)` / `ref.startAndWait(...)` for the normal override cases -- keep `ref.prepareStart(params)` for the rarer incremental-call cases where - you actually want to build the start request step by step +- when you need an explicit low-level transport object, prefer + `ref.buildStart(...)` and then `copyWith(...)` for the rarer + override-heavy cases Avoid starting child workflows from the raw `WorkflowScriptContext` body. diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index a16c22f4..c7fcb475 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -88,8 +88,8 @@ Child workflow starts belong in durable boundaries: - `ref.startAndWait(context, params: value)` inside script checkpoints - pass `ttl:`, `parentRunId:`, or `cancellationPolicy:` directly to those helpers for the normal override cases -- keep `ref.prepareStart(params)` for incremental-call assembly when you - genuinely need to build the start request step by step +- keep `ref.buildStart(...)` for the rarer cases where you explicitly want a + reusable `WorkflowStartCall` Do not treat the raw `WorkflowScriptContext` body as a safe place for child starts or other replay-sensitive side effects. diff --git a/.site/docs/workflows/flows-and-scripts.md b/.site/docs/workflows/flows-and-scripts.md index ed5c9b7d..d68b44e4 100644 --- a/.site/docs/workflows/flows-and-scripts.md +++ b/.site/docs/workflows/flows-and-scripts.md @@ -37,8 +37,8 @@ final approvalsRef = approvalsFlow.ref>( When a flow has no start params, start directly from the flow itself with `flow.start(...)` or `flow.startAndWait(...)`. Keep -`flow.ref0().asRef.prepareStart(())` for the rarer cases where you want to assemble -overrides incrementally before dispatch. +`flow.ref0().buildStart(...)` for the rarer cases where you want to assemble +or adjust overrides before dispatch. Use `ref0()` only when another API specifically needs a `NoArgsWorkflowRef`. Use `Flow` when: @@ -63,8 +63,8 @@ final retryRef = retryScript.ref>( When a script has no start params, start directly from the script itself with `retryScript.start(...)` or `retryScript.startAndWait(...)`. Keep -`retryScript.ref0().asRef.prepareStart(())` for the rarer cases where you want to -assemble overrides incrementally before dispatch. Use `ref0()` only when another API +`retryScript.ref0().buildStart(...)` for the rarer cases where you want to +assemble or adjust overrides before dispatch. Use `ref0()` only when another API specifically needs a `NoArgsWorkflowRef`. Use `WorkflowScript` when: diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 7be9e4f2..b0ecfc6a 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -84,14 +84,14 @@ Those read-side helpers take `defaultVersion:` as the fallback for older payloads that do not yet carry a stored version marker. For workflows without start params, start directly from the flow or script -itself with `start(...)` or `startAndWait(...)`. Keep `prepareStart()` for the -rarer cases where you want to assemble overrides incrementally before calling -`startWorkflowCall(...)`. Treat `WorkflowStartCall` as the explicit low-level +itself with `start(...)` or `startAndWait(...)`. When you need an explicit +low-level transport object for `startWorkflowCall(...)`, build it with +`ref0().buildStart(...)`. Treat `WorkflowStartCall` as the explicit low-level transport object, not the normal happy path. Use `ref0()` when another API specifically needs a `NoArgsWorkflowRef`. -When you already know the full override set up front, prefer -`ref.buildStart(...)` over `prepareStart(...).build()`. +When you need to adjust an explicit start request after construction, prefer +`ref.buildStart(...)` plus `copyWith(...)`. ## Wait for completion @@ -171,8 +171,8 @@ final result = await StemWorkflowDefinitions.userSignup.startAndWait( If you still need the run identifier for inspection or operator tooling, read it from `result.runId`. -Keep `ref.prepareStart(params)` for the rarer cases where you want to -assemble a start request incrementally before dispatch. +Keep `ref.buildStart(...)` for the rarer cases where you need to assemble or +adjust an explicit start request before dispatch. ## Parent runs and TTL diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 7fe8748f..a287ecb3 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -38,6 +38,9 @@ - Added direct `buildCall(...)` / `buildStart(...)` helpers on task/workflow definitions so the explicit transport path no longer requires `prepare...().build()` when all overrides are already known. +- Removed `WorkflowRef.prepareStart(...)` and `WorkflowStartBuilder`; the + explicit workflow transport path now uses `buildStart(...)` plus + `copyWith(...)` when advanced overrides are needed. - Added `QueueCustomEvent.metaJson(...)`, `metaVersionedJson(...)`, and `metaAs(codec: ...)` so queue-event metadata can decode DTO payloads without raw map casts. diff --git a/packages/stem/README.md b/packages/stem/README.md index e27def07..f279fa80 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -732,8 +732,9 @@ Child workflows belong in durable execution boundaries: checkpoints - pass `ttl:`, `parentRunId:`, or `cancellationPolicy:` directly to `ref.start(...)` / `ref.startAndWait(...)` for normal override cases -- keep `ref.prepareStart(params)` for the rarer incremental-call cases where - you actually want to build the start request step by step +- when you need an explicit low-level transport object, prefer + `ref.buildStart(...)` and then `copyWith(...)` for the rarer + override-heavy cases - do not start child workflows from the raw `WorkflowScriptContext` body unless you are deliberately managing replay/idempotency yourself diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index ba602c58..6f3c46e9 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -138,11 +138,6 @@ class WorkflowRef { return Map.from(payload); } - /// Creates a fluent builder for this workflow start. - WorkflowStartBuilder prepareStart(TParams params) { - return WorkflowStartBuilder(definition: this, params: params); - } - /// Decodes a final workflow result payload. TResult decode(Object? payload) { if (payload == null) { @@ -381,53 +376,6 @@ class WorkflowStartCall { } } -/// Fluent builder used to construct rich workflow start requests. -class WorkflowStartBuilder { - /// Creates a fluent builder for workflow starts. - WorkflowStartBuilder({required this.definition, required this.params}); - - /// Workflow definition used to construct the start call. - final WorkflowRef definition; - - /// Typed parameters for the workflow invocation. - final TParams params; - - String? _parentRunId; - Duration? _ttl; - WorkflowCancellationPolicy? _cancellationPolicy; - - /// Sets the parent workflow run id for this start. - WorkflowStartBuilder parentRunId(String parentRunId) { - _parentRunId = parentRunId; - return this; - } - - /// Sets the retention TTL for this run. - WorkflowStartBuilder ttl(Duration ttl) { - _ttl = ttl; - return this; - } - - /// Sets the cancellation policy for this run. - WorkflowStartBuilder cancellationPolicy( - WorkflowCancellationPolicy cancellationPolicy, - ) { - _cancellationPolicy = cancellationPolicy; - return this; - } - - /// Builds the [WorkflowStartCall] with accumulated overrides. - WorkflowStartCall build() { - return WorkflowStartCall._( - definition: definition, - params: params, - parentRunId: _parentRunId, - ttl: _ttl, - cancellationPolicy: _cancellationPolicy, - ); - } -} - /// Convenience helpers for waiting on typed workflow refs using a generic /// [WorkflowCaller]. extension WorkflowRefExtension diff --git a/packages/stem/test/unit/core/task_context_enqueue_test.dart b/packages/stem/test/unit/core/task_context_enqueue_test.dart index 695f9fc1..6f1b2603 100644 --- a/packages/stem/test/unit/core/task_context_enqueue_test.dart +++ b/packages/stem/test/unit/core/task_context_enqueue_test.dart @@ -331,10 +331,10 @@ void main() { encodeParams: (params) => params, ); - final call = definition - .prepareStart(const {'value': 'child'}) - .parentRunId('parent-task') - .build(); + final call = definition.buildStart( + params: const {'value': 'child'}, + parentRunId: 'parent-task', + ); final runId = await context.startWorkflowCall(call); final result = await call.definition.waitFor(context, runId); diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index ee479d25..b1c2c201 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -440,7 +440,7 @@ void main() { } }); - test('workflow refs expose fluent start builders', () async { + test('workflow refs build explicit start calls', () async { final flow = Flow( name: 'runtime.ref.builder.flow', build: (builder) { @@ -465,11 +465,12 @@ void main() { try { await workflowApp.start(); - final flowBuilder = workflowRef - .prepareStart(const {'name': 'builder'}) - .ttl(const Duration(minutes: 5)) - .parentRunId('parent-builder'); - final builtFlowCall = flowBuilder.build(); + final builtFlowCall = workflowRef.buildStart( + params: const {'name': 'builder'}, + ).copyWith( + ttl: const Duration(minutes: 5), + parentRunId: 'parent-builder', + ); final runId = await workflowApp.runtime.startWorkflowCall( builtFlowCall, ); @@ -485,16 +486,11 @@ void main() { expect(result?.value, 'hello builder'); expect(state?.parentRunId, 'parent-builder'); - final scriptBuilder = script - .ref0() - .asRef - .prepareStart(()) - .cancellationPolicy( - const WorkflowCancellationPolicy( - maxRunDuration: Duration(seconds: 5), - ), - ); - final builtScriptCall = scriptBuilder.build(); + final builtScriptCall = script.ref0().buildStart( + cancellationPolicy: const WorkflowCancellationPolicy( + maxRunDuration: Duration(seconds: 5), + ), + ); final scriptRunId = await workflowApp.runtime.startWorkflowCall( builtScriptCall, ); @@ -514,7 +510,7 @@ void main() { } }); - test('workflow refs expose workflow start builders', () async { + test('workflow refs build explicit workflow start calls', () async { final flow = Flow( name: 'runtime.ref.bound.builder.flow', build: (builder) { @@ -541,11 +537,12 @@ void main() { try { await workflowApp.start(); - final flowBuilder = workflowRef - .prepareStart(const {'name': 'builder'}) - .ttl(const Duration(minutes: 5)) - .parentRunId('parent-bound'); - final builtFlowCall = flowBuilder.build(); + final builtFlowCall = workflowRef.buildStart( + params: const {'name': 'builder'}, + ).copyWith( + ttl: const Duration(minutes: 5), + parentRunId: 'parent-bound', + ); final runId = await workflowApp.runtime.startWorkflowCall( builtFlowCall, ); @@ -561,14 +558,11 @@ void main() { expect(result?.value, 'hello builder'); expect(state?.parentRunId, 'parent-bound'); - final scriptBuilder = scriptRef.asRef - .prepareStart(()) - .cancellationPolicy( - const WorkflowCancellationPolicy( - maxRunDuration: Duration(seconds: 5), - ), - ); - final builtScriptCall = scriptBuilder.build(); + final builtScriptCall = scriptRef.buildStart( + cancellationPolicy: const WorkflowCancellationPolicy( + maxRunDuration: Duration(seconds: 5), + ), + ); final scriptRunId = await workflowApp.runtime.startWorkflowCall( builtScriptCall, ); From 2e3847b5404b68595002a141618564ca5b39f3b2 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 15:34:25 -0500 Subject: [PATCH 268/302] Remove task enqueue builder path --- .site/docs/core-concepts/producer.md | 3 +- .site/docs/core-concepts/tasks.md | 15 +- packages/stem/CHANGELOG.md | 3 + packages/stem/README.md | 10 +- .../stem/example/docs_snippets/lib/tasks.dart | 28 +- packages/stem/lib/src/canvas/canvas.dart | 21 +- packages/stem/lib/src/core/contracts.dart | 114 ------ packages/stem/lib/src/core/stem.dart | 74 ++-- .../stem/test/unit/core/fake_stem_test.dart | 2 +- .../stem/test/unit/core/stem_core_test.dart | 14 +- .../unit/core/task_context_enqueue_test.dart | 11 +- .../unit/core/task_enqueue_builder_test.dart | 333 +++++++++--------- .../test/unit/core/task_invocation_test.dart | 12 +- .../test/unit/core/task_registry_test.dart | 24 +- ...task_context_enqueue_integration_test.dart | 8 +- .../test/workflow/workflow_runtime_test.dart | 8 +- 16 files changed, 261 insertions(+), 419 deletions(-) diff --git a/.site/docs/core-concepts/producer.md b/.site/docs/core-concepts/producer.md index a2042778..887fc2b1 100644 --- a/.site/docs/core-concepts/producer.md +++ b/.site/docs/core-concepts/producer.md @@ -52,7 +52,8 @@ metadata, while exposing direct helpers and a fluent builder for overrides Typed helpers are also available on `Canvas` (`definition.toSignature`) so group/chain/chord APIs produce strongly typed `TaskResult` streams. Need to tweak headers/meta/queue at call sites? Start from -`definition.prepareEnqueue(args)` when you need the advanced builder path. +`definition.buildCall(args, ...)` when you need the explicit advanced +transport path. Raw task-name strings still work, but they are the lower-level interop path. Reach for them when the task name is truly dynamic or you are crossing a diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 0327fb23..6a118315 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -40,9 +40,8 @@ name, argument encoder, optional metadata, and default `TaskOptions`. For the common path, use the direct `definition.enqueue(stem, args)` / `definition.enqueueAndWait(...)` helpers. When you need a reusable prebuilt request, use -`definition.buildCall(args, ...)` or -`definition.prepareEnqueue(args).build()` and hand the resulting `TaskCall` to -any `TaskResultCaller` / `TaskEnqueuer` surface. Treat `TaskCall` as the +`definition.buildCall(args, ...)` and hand the resulting `TaskCall` to any +`TaskResultCaller` / `TaskEnqueuer` surface. Treat `TaskCall` as the explicit low-level transport object, not the normal happy path: ```dart file=/../packages/stem/example/docs_snippets/lib/tasks.dart#tasks-typed-definition @@ -98,12 +97,10 @@ final request = context.argsJson( ); ``` -`TaskEnqueueBuilder` now only builds a `TaskCall`. Typed task definitions can -create that fluent builder directly through `definition.prepareEnqueue(...)`. -Use `buildCall(...)` when you already know the full overrides up front. -Treat `prepareEnqueue(...)` as the incremental advanced path, then dispatch -the built call with `enqueueCall(...)`. For the normal case, prefer direct -`enqueue(...)` / `enqueueAndWait(...)`. +Use `buildCall(...)` when you need an explicit low-level transport object, and +then `copyWith(...)` if you need to adjust headers, metadata, options, or +scheduling before dispatch. For the normal case, prefer direct `enqueue(...)` +/ `enqueueAndWait(...)`. For tasks with no producer inputs, use `TaskDefinition.noArgs(...)` instead. That gives you direct `enqueue(...)` / diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index a287ecb3..6d954db6 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -41,6 +41,9 @@ - Removed `WorkflowRef.prepareStart(...)` and `WorkflowStartBuilder`; the explicit workflow transport path now uses `buildStart(...)` plus `copyWith(...)` when advanced overrides are needed. +- Removed `TaskDefinition.prepareEnqueue(...)` and `TaskEnqueueBuilder`; the + explicit task transport path now uses `buildCall(...)` plus `copyWith(...)` + when advanced overrides are needed. - Added `QueueCustomEvent.metaJson(...)`, `metaVersionedJson(...)`, and `metaAs(codec: ...)` so queue-event metadata can decode DTO payloads without raw map casts. diff --git a/packages/stem/README.md b/packages/stem/README.md index f279fa80..e1a76fb9 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -311,12 +311,10 @@ final result = await HelloTask.definition.enqueueAndWait( print(result?.value); ``` -Treat `prepareEnqueue(...)` as the advanced path when you need to assemble -headers, metadata, delay, priority, or other overrides incrementally. For the -normal case, prefer direct `enqueue(...)` or `enqueueAndWait(...)`. Builders -now only produce `TaskCall`; dispatch those with `enqueueCall(...)`. If you -already know the full override set up front, prefer `definition.buildCall(...)` -for the explicit transport path. +Treat `buildCall(...)` as the advanced path when you need an explicit +transport object with custom headers, metadata, delay, priority, or other +overrides. Use `copyWith(...)` if you need to adjust it before dispatch. For +the normal case, prefer direct `enqueue(...)` or `enqueueAndWait(...)`. ### Enqueue from inside a task diff --git a/packages/stem/example/docs_snippets/lib/tasks.dart b/packages/stem/example/docs_snippets/lib/tasks.dart index 7a4b54b8..936fe229 100644 --- a/packages/stem/example/docs_snippets/lib/tasks.dart +++ b/packages/stem/example/docs_snippets/lib/tasks.dart @@ -130,21 +130,19 @@ final childDefinition = TaskDefinition( // #region tasks-invocation-builder Future enqueueWithBuilder(TaskExecutionContext context) async { - await childDefinition - .prepareEnqueue(const ChildArgs('value')) - .queue('critical') - .priority(9) - .delay(const Duration(seconds: 5)) - .enqueueOptions( - const TaskEnqueueOptions( - retry: true, - retryPolicy: TaskRetryPolicy( - backoff: true, - defaultDelay: Duration(seconds: 1), - ), - ), - ) - .enqueue(context); + final call = childDefinition.buildCall( + const ChildArgs('value'), + options: const TaskOptions(queue: 'critical', priority: 9), + notBefore: DateTime.now().add(const Duration(seconds: 5)), + enqueueOptions: const TaskEnqueueOptions( + retry: true, + retryPolicy: TaskRetryPolicy( + backoff: true, + defaultDelay: Duration(seconds: 1), + ), + ), + ); + await context.enqueueCall(call); } // #endregion tasks-invocation-builder diff --git a/packages/stem/lib/src/canvas/canvas.dart b/packages/stem/lib/src/canvas/canvas.dart index 99e9a92e..6ee5c1e4 100644 --- a/packages/stem/lib/src/canvas/canvas.dart +++ b/packages/stem/lib/src/canvas/canvas.dart @@ -934,20 +934,13 @@ extension TaskDefinitionCanvasX Map? meta, TResult Function(Object? payload)? decode, }) { - final builder = prepareEnqueue(args); - if (headers.isNotEmpty) { - builder.headers(headers); - } - if (options != null) { - builder.options(options); - } - if (notBefore != null) { - builder.notBefore(notBefore); - } - if (meta != null) { - builder.metadata(meta); - } - final call = builder.build(); + final call = buildCall( + args, + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + ); return task( name, args: call.encodeArgs(), diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index bea28011..f4edb10c 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -33,7 +33,6 @@ library; import 'dart:async'; import 'dart:collection'; -import 'package:stem/src/core/clock.dart'; import 'package:stem/src/core/envelope.dart'; import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/core/payload_map.dart'; @@ -2953,11 +2952,6 @@ class TaskDefinition { ); } - /// Creates a fluent enqueue builder from this definition and [args]. - TaskEnqueueBuilder prepareEnqueue(TArgs args) { - return TaskEnqueueBuilder(definition: this, args: args); - } - /// Encodes arguments into a JSON-ready map. Map encodeArgs(TArgs args) => _encodeArgs(args); @@ -3093,114 +3087,6 @@ class TaskCall { } } -/// Fluent builder used to construct rich enqueue requests. -/// -/// Build a [TaskCall] and dispatch it via `TaskEnqueuer.enqueueCall`. -class TaskEnqueueBuilder { - /// Creates a fluent builder for enqueue calls. - TaskEnqueueBuilder({required this.definition, required this.args}); - - /// Task definition used to construct the call. - final TaskDefinition definition; - - /// Typed arguments for the task invocation. - final TArgs args; - - Map? _headers; - TaskOptions? _optionsOverride; - DateTime? _notBefore; - Map? _meta; - TaskEnqueueOptions? _enqueueOptions; - - /// Replaces headers entirely. - TaskEnqueueBuilder headers(Map headers) { - _headers = Map.from(headers); - return this; - } - - /// Adds or overrides a single header entry. - TaskEnqueueBuilder header(String key, String value) { - final current = Map.from(_headers ?? const {}); - current[key] = value; - _headers = current; - return this; - } - - /// Replaces metadata entirely. - TaskEnqueueBuilder metadata(Map meta) { - _meta = Map.from(meta); - return this; - } - - /// Adds or overrides a metadata entry. - TaskEnqueueBuilder meta(String key, Object? value) { - final current = Map.from(_meta ?? const {}); - current[key] = value; - _meta = current; - return this; - } - - /// Replaces the options for this call. - TaskEnqueueBuilder options(TaskOptions options) { - _optionsOverride = options; - return this; - } - - /// Sets the queue for this enqueue. - TaskEnqueueBuilder queue(String queue) { - final base = _optionsOverride ?? definition.defaultOptions; - _optionsOverride = base.copyWith(queue: queue); - return this; - } - - /// Sets the priority for this enqueue. - TaskEnqueueBuilder priority(int priority) { - final base = _optionsOverride ?? definition.defaultOptions; - _optionsOverride = base.copyWith(priority: priority); - return this; - } - - /// Sets the earliest execution time. - TaskEnqueueBuilder notBefore(DateTime instant) { - _notBefore = instant; - return this; - } - - /// Sets a relative delay before execution. - TaskEnqueueBuilder delay(Duration duration) { - _notBefore = stemNow().add(duration); - return this; - } - - /// Replaces the enqueue options for this call. - TaskEnqueueBuilder enqueueOptions( - TaskEnqueueOptions options, - ) { - _enqueueOptions = options; - return this; - } - - /// Builds the [TaskCall] with accumulated overrides. - TaskCall build() { - final base = definition.buildCall(args); - final mergedHeaders = Map.from(base.headers); - if (_headers != null) { - mergedHeaders.addAll(_headers!); - } - final mergedMeta = Map.from(base.meta); - if (_meta != null) { - mergedMeta.addAll(_meta!); - } - return base.copyWith( - headers: Map.unmodifiable(mergedHeaders), - options: _optionsOverride ?? base.options, - notBefore: _notBefore ?? base.notBefore, - meta: Map.unmodifiable(mergedMeta), - enqueueOptions: _enqueueOptions ?? base.enqueueOptions, - ); - } -} - /// Convenience helpers for building typed enqueue requests directly from a task /// enqueuer. extension TaskEnqueuerBuilderExtension on TaskEnqueuer { diff --git a/packages/stem/lib/src/core/stem.dart b/packages/stem/lib/src/core/stem.dart index 7dc263c2..2da33eab 100644 --- a/packages/stem/lib/src/core/stem.dart +++ b/packages/stem/lib/src/core/stem.dart @@ -1158,25 +1158,16 @@ extension TaskDefinitionExtension Map? meta, TaskEnqueueOptions? enqueueOptions, }) { - final builder = prepareEnqueue(args); - if (headers.isNotEmpty) { - builder.headers(headers); - } - if (options != null) { - builder.options(options); - } - if (notBefore != null) { - builder.notBefore(notBefore); - } - if (meta != null) { - builder.metadata(meta); - } - if (enqueueOptions != null) { - builder.enqueueOptions(enqueueOptions); - } return _enqueueBuiltTaskCall( enqueuer, - builder.build(), + buildCall( + args, + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ), enqueueOptions: enqueueOptions, ); } @@ -1192,23 +1183,14 @@ extension TaskDefinitionExtension TaskEnqueueOptions? enqueueOptions, Duration? timeout, }) { - final builder = prepareEnqueue(args); - if (headers.isNotEmpty) { - builder.headers(headers); - } - if (options != null) { - builder.options(options); - } - if (notBefore != null) { - builder.notBefore(notBefore); - } - if (meta != null) { - builder.metadata(meta); - } - if (enqueueOptions != null) { - builder.enqueueOptions(enqueueOptions); - } - final call = builder.build(); + final call = buildCall( + args, + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ); return _enqueueBuiltTaskCall( caller, call, @@ -1244,25 +1226,15 @@ extension NoArgsTaskDefinitionExtension Map? meta, TaskEnqueueOptions? enqueueOptions, }) { - final builder = asDefinition.prepareEnqueue(()); - if (headers.isNotEmpty) { - builder.headers(headers); - } - if (options != null) { - builder.options(options); - } - if (notBefore != null) { - builder.notBefore(notBefore); - } - if (meta != null) { - builder.metadata(meta); - } - if (enqueueOptions != null) { - builder.enqueueOptions(enqueueOptions); - } return _enqueueBuiltTaskCall( enqueuer, - builder.build(), + buildCall( + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ), enqueueOptions: enqueueOptions, ); } diff --git a/packages/stem/test/unit/core/fake_stem_test.dart b/packages/stem/test/unit/core/fake_stem_test.dart index 3e63debc..d170b467 100644 --- a/packages/stem/test/unit/core/fake_stem_test.dart +++ b/packages/stem/test/unit/core/fake_stem_test.dart @@ -15,7 +15,7 @@ void main() { encodeArgs: (args) => {'value': args.value}, ); - final call = definition.prepareEnqueue(const _Args(42)).build(); + final call = definition.buildCall(const _Args(42)); final id = await fake.enqueueCall(call); expect(id, isNotEmpty); diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index 543492f6..ac8ca2bb 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -131,9 +131,7 @@ void main() { defaultOptions: const TaskOptions(queue: 'typed'), ); - final id = await stem.enqueueCall( - definition.prepareEnqueue((value: 'ok')).build(), - ); + final id = await stem.enqueueCall(definition.buildCall((value: 'ok'))); expect(id, isNotEmpty); expect(broker.published.single.envelope.name, 'sample.typed'); @@ -154,7 +152,7 @@ void main() { ); final id = await stem.enqueueCall( - definition.prepareEnqueue(const _CodecTaskArgs('encoded')).build(), + definition.buildCall(const _CodecTaskArgs('encoded')), ); expect(id, isNotEmpty); @@ -175,7 +173,7 @@ void main() { ); final id = await stem.enqueueCall( - definition.prepareEnqueue(const _CodecTaskArgs('encoded')).build(), + definition.buildCall(const _CodecTaskArgs('encoded')), ); expect(id, isNotEmpty); @@ -197,7 +195,7 @@ void main() { ); final id = await stem.enqueueCall( - definition.prepareEnqueue(const _CodecTaskArgs('encoded')).build(), + definition.buildCall(const _CodecTaskArgs('encoded')), ); expect(id, isNotEmpty); @@ -284,7 +282,7 @@ void main() { ); final id = await stem.enqueueCall( - definition.prepareEnqueue((value: 'encoded')).build(), + definition.buildCall((value: 'encoded')), ); expect( @@ -312,7 +310,7 @@ void main() { ); final id = await stem.enqueueCall( - definition.prepareEnqueue(const _CodecTaskArgs('encoded')).build(), + definition.buildCall(const _CodecTaskArgs('encoded')), ); expect( diff --git a/packages/stem/test/unit/core/task_context_enqueue_test.dart b/packages/stem/test/unit/core/task_context_enqueue_test.dart index 6f1b2603..24c1ad99 100644 --- a/packages/stem/test/unit/core/task_context_enqueue_test.dart +++ b/packages/stem/test/unit/core/task_context_enqueue_test.dart @@ -230,8 +230,8 @@ void main() { }); }); - group('TaskInvocationContext builder', () { - test('supports fluent enqueue builder API', () async { + group('TaskInvocationContext explicit task calls', () { + test('supports explicit enqueue call overrides', () async { final enqueuer = _RecordingEnqueuer(); final definition = TaskDefinition<_ExampleArgs, void>( @@ -239,9 +239,10 @@ void main() { encodeArgs: (args) => {'value': args.value}, ); - final builder = definition.prepareEnqueue(const _ExampleArgs('hello')); - - final call = builder.queue('priority').priority(7).build(); + final call = definition.buildCall( + const _ExampleArgs('hello'), + options: const TaskOptions(queue: 'priority', priority: 7), + ); await enqueuer.enqueueCall(call); final record = enqueuer.last!; diff --git a/packages/stem/test/unit/core/task_enqueue_builder_test.dart b/packages/stem/test/unit/core/task_enqueue_builder_test.dart index 6ad1085b..62e82530 100644 --- a/packages/stem/test/unit/core/task_enqueue_builder_test.dart +++ b/packages/stem/test/unit/core/task_enqueue_builder_test.dart @@ -2,133 +2,127 @@ import 'package:stem/stem.dart'; import 'package:test/test.dart'; void main() { - test('TaskEnqueueBuilder merges headers/meta and options', () { - final definition = TaskDefinition, Object?>( - name: 'demo.task', - encodeArgs: (args) => args, - encodeMeta: (args) => {'from': 'definition'}, - ); + group('TaskCall builders', () { + test('buildCall stores headers/meta and options', () { + final definition = TaskDefinition, Object?>( + name: 'demo.task', + encodeArgs: (args) => args, + encodeMeta: (args) => {'from': 'definition'}, + ); - final call = - TaskEnqueueBuilder(definition: definition, args: const {'a': 1}) - .header('h1', 'v1') - .meta('m1', 'v1') - .queue('critical') - .priority(5) - .notBefore(DateTime.parse('2025-01-01T00:00:00Z')) - .enqueueOptions(const TaskEnqueueOptions(addToParent: false)) - .build(); - - expect(call.headers['h1'], 'v1'); - expect(call.meta['from'], 'definition'); - expect(call.meta['m1'], 'v1'); - expect(call.options?.queue, 'critical'); - expect(call.options?.priority, 5); - expect(call.notBefore, DateTime.parse('2025-01-01T00:00:00Z')); - expect(call.enqueueOptions?.addToParent, isFalse); - }); + final call = definition.buildCall( + const {'a': 1}, + headers: const {'h1': 'v1'}, + options: const TaskOptions(queue: 'critical', priority: 5), + notBefore: DateTime.parse('2025-01-01T00:00:00Z'), + meta: const {'m1': 'v1'}, + enqueueOptions: const TaskEnqueueOptions(addToParent: false), + ); - test('TaskEnqueueBuilder delay sets notBefore in the future', () { - final definition = TaskDefinition, Object?>( - name: 'demo.task', - encodeArgs: (args) => args, - ); + expect(call.headers['h1'], 'v1'); + expect(call.meta['m1'], 'v1'); + expect(call.options?.queue, 'critical'); + expect(call.options?.priority, 5); + expect(call.notBefore, DateTime.parse('2025-01-01T00:00:00Z')); + expect(call.enqueueOptions?.addToParent, isFalse); + }); - final start = DateTime.now(); - final call = TaskEnqueueBuilder( - definition: definition, - args: const {'a': 1}, - ).delay(const Duration(seconds: 2)).build(); + test('buildCall preserves definition metadata by default', () { + final definition = TaskDefinition, Object?>( + name: 'demo.task', + encodeArgs: (args) => args, + encodeMeta: (args) => {'from': 'definition'}, + ); - expect(call.notBefore, isNotNull); - expect(call.notBefore!.isAfter(start), isTrue); - }); + final call = definition.buildCall(const {'a': 1}); - test('TaskEnqueueBuilder replaces headers, metadata, and options', () { - final definition = TaskDefinition, Object?>( - name: 'demo.task', - encodeArgs: (args) => args, - ); + expect(call.meta, containsPair('from', 'definition')); + }); - final call = - TaskEnqueueBuilder(definition: definition, args: const {'a': 1}) - .headers(const {'h': 'v'}) - .metadata(const {'m': 1}) - .options(const TaskOptions(queue: 'q', priority: 9)) - .build(); - - expect(call.headers, containsPair('h', 'v')); - expect(call.meta, containsPair('m', 1)); - expect(call.options?.queue, 'q'); - expect(call.options?.priority, 9); - }); + test('TaskCall.copyWith replaces headers, metadata, and options', () { + final definition = TaskDefinition, Object?>( + name: 'demo.task', + encodeArgs: (args) => args, + ); - test('TaskDefinition.prepareEnqueue creates a fluent builder', () { - final definition = TaskDefinition, Object?>( - name: 'demo.task', - encodeArgs: (args) => args, - ); + final call = definition.buildCall(const {'a': 1}).copyWith( + headers: const {'h': 'v'}, + meta: const {'m': 1}, + options: const TaskOptions(queue: 'q', priority: 9), + ); - final call = definition - .prepareEnqueue(const {'a': 1}) - .priority(7) - .header('h1', 'v1') - .build(); + expect(call.headers, containsPair('h', 'v')); + expect(call.meta, containsPair('m', 1)); + expect(call.options?.queue, 'q'); + expect(call.options?.priority, 9); + }); - expect(call.name, 'demo.task'); - expect(call.resolveOptions().priority, 7); - expect(call.headers, containsPair('h1', 'v1')); - expect(call.encodeArgs(), containsPair('a', 1)); - }); + test('buildCall creates an explicit transport object', () { + final definition = TaskDefinition, Object?>( + name: 'demo.task', + encodeArgs: (args) => args, + ); + + final call = definition.buildCall( + const {'a': 1}, + headers: const {'h1': 'v1'}, + options: const TaskOptions(priority: 7), + ); + + expect(call.name, 'demo.task'); + expect(call.resolveOptions().priority, 7); + expect(call.headers, containsPair('h1', 'v1')); + expect(call.encodeArgs(), containsPair('a', 1)); + }); + + test( + 'TaskCall from buildCall composes with enqueueCall and typed waits', + () async { + final definition = TaskDefinition, String>( + name: 'demo.task', + encodeArgs: (args) => args, + decodeResult: (payload) => 'decoded:$payload', + ); + final caller = _RecordingTaskResultCaller(); + final call = definition.buildCall( + const {'a': 1}, + headers: const {'h1': 'v1'}, + ); + final taskId = await caller.enqueueCall(call); + final result = await call.definition.waitFor(caller, taskId); + + expect(caller.lastCall, isNotNull); + expect(caller.lastCall!.name, 'demo.task'); + expect(caller.lastCall!.headers, containsPair('h1', 'v1')); + expect(caller.waitedTaskId, 'task-1'); + expect(result?.value, 'decoded:stored'); + }, + ); - test( - 'TaskEnqueueBuilder.build composes with enqueueCall and typed waits', - () async { + test('buildCall preserves enqueuer dispatch semantics', () async { + final enqueuer = _RecordingTaskEnqueuer(); final definition = TaskDefinition, String>( name: 'demo.task', encodeArgs: (args) => args, decodeResult: (payload) => 'decoded:$payload', ); - final caller = _RecordingTaskResultCaller(); - final call = TaskEnqueueBuilder( - definition: definition, - args: const {'a': 1}, - ).header('h1', 'v1').build(); - final taskId = await caller.enqueueCall(call); - final result = await call.definition.waitFor(caller, taskId); - expect(caller.lastCall, isNotNull); - expect(caller.lastCall!.name, 'demo.task'); - expect(caller.lastCall!.headers, containsPair('h1', 'v1')); - expect(caller.waitedTaskId, 'task-1'); - expect(result?.value, 'decoded:stored'); - }, - ); - - test( - 'TaskEnqueueBuilder.build preserves enqueuer dispatch semantics', - () async { - final enqueuer = _RecordingTaskEnqueuer(); - final definition = TaskDefinition, String>( - name: 'demo.task', - encodeArgs: (args) => args, - decodeResult: (payload) => 'decoded:$payload', - ); + final taskId = await enqueuer.enqueueCall( + definition.buildCall( + const {'a': 1}, + headers: const {'h1': 'v1'}, + options: const TaskOptions(queue: 'critical'), + ), + ); - final builder = definition - .prepareEnqueue(const {'a': 1}) - .header('h1', 'v1') - .queue('critical'); - final taskId = await enqueuer.enqueueCall(builder.build()); - - expect(taskId, 'task-1'); - expect(enqueuer.lastCall, isNotNull); - expect(enqueuer.lastCall!.name, 'demo.task'); - expect(enqueuer.lastCall!.headers, containsPair('h1', 'v1')); - expect(enqueuer.lastCall!.resolveOptions().queue, 'critical'); - }); + expect(taskId, 'task-1'); + expect(enqueuer.lastCall, isNotNull); + expect(enqueuer.lastCall!.name, 'demo.task'); + expect(enqueuer.lastCall!.headers, containsPair('h1', 'v1')); + expect(enqueuer.lastCall!.resolveOptions().queue, 'critical'); + }); - test('TaskEnqueueBuilder composes with typed waits', () async { + test('TaskCall composes with typed waits', () async { final caller = _RecordingTaskResultCaller(); final definition = TaskDefinition, String>( name: 'demo.task', @@ -136,10 +130,10 @@ void main() { decodeResult: (payload) => 'decoded:$payload', ); - final builder = definition - .prepareEnqueue(const {'a': 1}) - .header('h1', 'v1'); - final call = builder.build(); + final call = definition.buildCall( + const {'a': 1}, + headers: const {'h1': 'v1'}, + ); final taskId = await caller.enqueueCall(call); final result = await call.definition.waitFor(caller, taskId); @@ -148,58 +142,55 @@ void main() { expect(caller.lastCall!.headers, containsPair('h1', 'v1')); expect(caller.waitedTaskId, 'task-1'); expect(result?.value, 'decoded:stored'); - }, - ); + }); - test('TaskCall.copyWith updates headers and meta', () { - final definition = TaskDefinition, Object?>( - name: 'demo.task', - encodeArgs: (args) => args, - ); - final call = definition - .prepareEnqueue(const {'a': 1}) - .headers(const {'h': 'v'}) - .metadata(const {'m': 1}) - .build(); - - final updated = call.copyWith( - headers: const {'h2': 'v2'}, - meta: const {'m2': 2}, - ); + test('TaskCall.copyWith updates headers and meta', () { + final definition = TaskDefinition, Object?>( + name: 'demo.task', + encodeArgs: (args) => args, + ); + final call = definition.buildCall( + const {'a': 1}, + headers: const {'h': 'v'}, + meta: const {'m': 1}, + ); - expect(updated.headers['h2'], 'v2'); - expect(updated.meta['m2'], 2); - }); + final updated = call.copyWith( + headers: const {'h2': 'v2'}, + meta: const {'m2': 2}, + ); - test('NoArgsTaskDefinition.prepareEnqueue builds an empty payload call', () { - final definition = TaskDefinition.noArgs(name: 'demo.no_args'); + expect(updated.headers['h2'], 'v2'); + expect(updated.meta['m2'], 2); + }); - final call = definition - .asDefinition - .prepareEnqueue(()) - .headers(const {'h': 'v'}) - .metadata(const {'m': 1}) - .build(); + test('NoArgsTaskDefinition.buildCall builds an empty payload call', () { + final definition = TaskDefinition.noArgs(name: 'demo.no_args'); - expect(call.name, 'demo.no_args'); - expect(call.encodeArgs(), isEmpty); - expect(call.headers, containsPair('h', 'v')); - expect(call.meta, containsPair('m', 1)); - }); + final call = definition.buildCall( + headers: const {'h': 'v'}, + meta: const {'m': 1}, + ); - test('NoArgsTaskDefinition.prepareEnqueue creates a fluent builder', () { - final definition = TaskDefinition.noArgs(name: 'demo.no_args'); + expect(call.name, 'demo.no_args'); + expect(call.encodeArgs(), isEmpty); + expect(call.headers, containsPair('h', 'v')); + expect(call.meta, containsPair('m', 1)); + }); - final call = definition.asDefinition.prepareEnqueue(()).priority(4).build(); + test('NoArgsTaskDefinition.buildCall accepts direct overrides', () { + final definition = TaskDefinition.noArgs(name: 'demo.no_args'); - expect(call.name, 'demo.no_args'); - expect(call.resolveOptions().priority, 4); - expect(call.encodeArgs(), isEmpty); - }); + final call = definition.buildCall( + options: const TaskOptions(priority: 4), + ); - test( - 'NoArgsTaskDefinition.enqueue uses the TaskEnqueuer surface', - () async { + expect(call.name, 'demo.no_args'); + expect(call.resolveOptions().priority, 4); + expect(call.encodeArgs(), isEmpty); + }); + + test('NoArgsTaskDefinition.enqueue uses the TaskEnqueuer surface', () async { final definition = TaskDefinition.noArgs(name: 'demo.no_args'); final enqueuer = _RecordingTaskEnqueuer(); @@ -215,8 +206,8 @@ void main() { expect(enqueuer.lastCall!.encodeArgs(), isEmpty); expect(enqueuer.lastCall!.headers, containsPair('h', 'v')); expect(enqueuer.lastCall!.meta, containsPair('m', 1)); - }, - ); + }); + }); } class _RecordingTaskEnqueuer implements TaskEnqueuer { @@ -260,30 +251,38 @@ class _RecordingTaskResultCaller extends _RecordingTaskEnqueuer String taskId, { Duration? timeout, TResult Function(Object? payload)? decode, - TResult Function(Map payload)? decodeJson, - TResult Function(Map payload, int version)? + TResult Function(Map json)? decodeJson, + TResult Function(Map json, int version)? decodeVersionedJson, }) async { waitedTaskId = taskId; + final value = + decode?.call('stored') ?? + decodeJson?.call(const {'value': 'stored'}) ?? + decodeVersionedJson?.call(const {'value': 'stored'}, 1); return TaskResult( taskId: taskId, - status: TaskStatus(id: taskId, state: TaskState.succeeded, attempt: 0), - value: decode?.call('stored'), + status: TaskStatus( + id: taskId, + state: TaskState.succeeded, + attempt: 0, + payload: 'stored', + ), + value: value, rawPayload: 'stored', ); } @override - Future?> - waitForTaskDefinition( + Future?> waitForTaskDefinition( String taskId, TaskDefinition definition, { Duration? timeout, - }) async { - return waitForTask( + }) { + return waitForTask( taskId, timeout: timeout, - decode: definition.decodeResult, + decode: (payload) => definition.decode(payload) as TResult, ); } } diff --git a/packages/stem/test/unit/core/task_invocation_test.dart b/packages/stem/test/unit/core/task_invocation_test.dart index f6b6747d..b9c912b0 100644 --- a/packages/stem/test/unit/core/task_invocation_test.dart +++ b/packages/stem/test/unit/core/task_invocation_test.dart @@ -375,7 +375,7 @@ void main() { const TaskDefinition, Object?>( name: 'demo', encodeArgs: _encodeArgs, - ).prepareEnqueue(const {'a': 1}).build(), + ).buildCall(const {'a': 1}), ), throwsA(isA()), ); @@ -400,11 +400,11 @@ void main() { name: 'demo.call', encodeArgs: (args) => args, ); - final call = definition - .prepareEnqueue(const {'value': 1}) - .headers(const {'h2': 'v2'}) - .metadata(const {'m2': 'v2'}) - .build(); + final call = definition.buildCall( + const {'value': 1}, + headers: const {'h2': 'v2'}, + meta: const {'m2': 'v2'}, + ); await context.enqueueCall(call); diff --git a/packages/stem/test/unit/core/task_registry_test.dart b/packages/stem/test/unit/core/task_registry_test.dart index cc1234ba..e829bf0f 100644 --- a/packages/stem/test/unit/core/task_registry_test.dart +++ b/packages/stem/test/unit/core/task_registry_test.dart @@ -237,25 +237,21 @@ void main() { }); }); - group('TaskEnqueueBuilder', () { - test('builds TaskCall with overrides', () { + group('TaskCall', () { + test('buildCall plus copyWith builds TaskCall with overrides', () { final definition = TaskDefinition<_Args, void>( name: 'demo.task', encodeArgs: (args) => {'value': args.value}, ); - final builder = - TaskEnqueueBuilder<_Args, void>( - definition: definition, - args: _Args(7), - ) - ..header('x-id', 'abc') - ..meta('source', 'test') - ..priority(5) - ..queue('fast') - ..delay(const Duration(seconds: 1)); - - final call = builder.build(); + final call = definition.buildCall( + _Args(7), + ).copyWith( + headers: const {'x-id': 'abc'}, + meta: const {'source': 'test'}, + options: const TaskOptions(priority: 5, queue: 'fast'), + notBefore: stemNow().add(const Duration(seconds: 1)), + ); expect(call.headers['x-id'], 'abc'); expect(call.meta['source'], 'test'); expect(call.resolveOptions().priority, 5); diff --git a/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart b/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart index aed6ff5c..e8b2f532 100644 --- a/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart +++ b/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart @@ -222,7 +222,7 @@ void main() { 'tasks.primary.success', enqueueOptions: TaskEnqueueOptions( link: [ - linkDefinition.prepareEnqueue(const _ChildArgs('linked')).build(), + linkDefinition.buildCall(const _ChildArgs('linked')), ], ), ); @@ -281,7 +281,7 @@ void main() { 'tasks.primary.fail', enqueueOptions: TaskEnqueueOptions( linkError: [ - linkDefinition.prepareEnqueue(const _ChildArgs('linked')).build(), + linkDefinition.buildCall(const _ChildArgs('linked')), ], ), ); @@ -539,10 +539,10 @@ FutureOr _isolateEnqueueEntrypoint( TaskInvocationContext context, Map args, ) async { - final builder = _childDefinition.prepareEnqueue( + final call = _childDefinition.buildCall( const _ChildArgs('from-isolate'), ); - await context.enqueueCall(builder.build()); + await context.enqueueCall(call); return null; } diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index a866a276..0f16fd12 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -1623,10 +1623,10 @@ void main() { name: 'meta.builder.workflow', build: (flow) { flow.step('dispatch', (context) async { - final call = TaskEnqueueBuilder( - definition: definition, - args: const {}, - ).meta('origin', 'builder').build(); + final call = definition.buildCall( + const {}, + meta: const {'origin': 'builder'}, + ); await stem.enqueueCall(call); return 'done'; }); From 6106f67a8009be0195c93d14a1ac5a1ca179d6b2 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 15:41:11 -0500 Subject: [PATCH 269/302] Remove duplicate typed wrapper helpers --- .site/docs/workflows/flows-and-scripts.md | 4 +- .site/docs/workflows/starting-and-waiting.md | 2 +- packages/stem/CHANGELOG.md | 18 +++-- packages/stem/lib/src/bootstrap/stem_app.dart | 11 --- .../stem/lib/src/bootstrap/stem_client.dart | 11 --- .../stem/lib/src/bootstrap/workflow_app.dart | 11 --- packages/stem/lib/src/core/contracts.dart | 17 ----- packages/stem/lib/src/core/stem.dart | 75 ++++++++----------- .../lib/src/workflow/core/workflow_ref.dart | 14 ---- .../stem/test/unit/core/stem_core_test.dart | 10 +-- .../unit/core/task_enqueue_builder_test.dart | 23 ++---- .../workflow/workflow_runtime_ref_test.dart | 6 +- 12 files changed, 60 insertions(+), 142 deletions(-) diff --git a/.site/docs/workflows/flows-and-scripts.md b/.site/docs/workflows/flows-and-scripts.md index d68b44e4..b922a1cc 100644 --- a/.site/docs/workflows/flows-and-scripts.md +++ b/.site/docs/workflows/flows-and-scripts.md @@ -37,7 +37,7 @@ final approvalsRef = approvalsFlow.ref>( When a flow has no start params, start directly from the flow itself with `flow.start(...)` or `flow.startAndWait(...)`. Keep -`flow.ref0().buildStart(...)` for the rarer cases where you want to assemble +`flow.ref0().asRef.buildStart(params: ())` for the rarer cases where you want to assemble or adjust overrides before dispatch. Use `ref0()` only when another API specifically needs a `NoArgsWorkflowRef`. @@ -63,7 +63,7 @@ final retryRef = retryScript.ref>( When a script has no start params, start directly from the script itself with `retryScript.start(...)` or `retryScript.startAndWait(...)`. Keep -`retryScript.ref0().buildStart(...)` for the rarer cases where you want to +`retryScript.ref0().asRef.buildStart(params: ())` for the rarer cases where you want to assemble or adjust overrides before dispatch. Use `ref0()` only when another API specifically needs a `NoArgsWorkflowRef`. diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index b0ecfc6a..3988646c 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -86,7 +86,7 @@ payloads that do not yet carry a stored version marker. For workflows without start params, start directly from the flow or script itself with `start(...)` or `startAndWait(...)`. When you need an explicit low-level transport object for `startWorkflowCall(...)`, build it with -`ref0().buildStart(...)`. Treat `WorkflowStartCall` as the explicit low-level +`ref0().asRef.buildStart(params: ())`. Treat `WorkflowStartCall` as the explicit low-level transport object, not the normal happy path. Use `ref0()` when another API specifically needs a `NoArgsWorkflowRef`. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 6d954db6..e3f629ee 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -4,14 +4,14 @@ - Removed `WorkflowRef.call(...)` as a duplicate workflow-start convenience. The direct `start(...)` / `startAndWait(...)` helpers remain the happy path, - and `prepareStart(...).build()` remains the explicit prebuilt-call path. + and `buildStart(...)` remains the explicit prebuilt-call path. - Removed `NoArgsWorkflowRef.prepareStart()` as a duplicate no-args builder wrapper. Use direct `start(...)` / `startAndWait(...)` for the happy path, - or `ref0().asRef.prepareStart(())` when you need an explicit prebuilt call. + or `ref0().asRef.buildStart(params: ())` when you need an explicit prebuilt + call. - Removed `TaskDefinition.call(...)` as a duplicate task-enqueue convenience. The direct `enqueue(...)` / `enqueueAndWait(...)` helpers remain the happy - path, and `prepareEnqueue(args).build()` remains the explicit prebuilt-call - path. + path, and `buildCall(args, ...)` remains the explicit prebuilt-call path. - Removed the `TaskCall.enqueue(...)` / `enqueueAndWait(...)` and `WorkflowStartCall.start(...)` / `startAndWait(...)` dispatch wrappers. Prebuilt transport objects now dispatch explicitly through @@ -27,10 +27,10 @@ needed. - Clarified docs so direct `start(...)` / `startAndWait(...)` and `enqueue(...)` / `enqueueAndWait(...)` are the default happy path, with - `prepareStart(...)` and `prepareEnqueue(...)` positioned as advanced - override builders. + `buildStart(...)` and `buildCall(...)` positioned as the explicit advanced + transport path. - Removed the caller/context `prepareStart(...)` and `prepareEnqueue(...)` - wrapper entrypoints so the advanced builder path now hangs directly off + wrapper entrypoints so the explicit transport path now hangs directly off `WorkflowRef` and `TaskDefinition`. - Clarified docs so `TaskCall` and `WorkflowStartCall` are described as the explicit low-level transport path, not peer happy-path APIs beside direct @@ -38,6 +38,10 @@ - Added direct `buildCall(...)` / `buildStart(...)` helpers on task/workflow definitions so the explicit transport path no longer requires `prepare...().build()` when all overrides are already known. +- Removed no-args `buildCall()` / `buildStart()` transport wrappers in favor + of the explicit typed surfaces: + `definition.asDefinition.buildCall(())` and + `ref0().asRef.buildStart(params: ())`. - Removed `WorkflowRef.prepareStart(...)` and `WorkflowStartBuilder`; the explicit workflow transport path now uses `buildStart(...)` plus `copyWith(...)` when advanced overrides are needed. diff --git a/packages/stem/lib/src/bootstrap/stem_app.dart b/packages/stem/lib/src/bootstrap/stem_app.dart index d8cb329e..2e788365 100644 --- a/packages/stem/lib/src/bootstrap/stem_app.dart +++ b/packages/stem/lib/src/bootstrap/stem_app.dart @@ -156,17 +156,6 @@ class StemApp implements StemTaskApp { ); } - @override - Future?> - waitForTaskDefinition( - String taskId, - TaskDefinition definition, { - Duration? timeout, - }) async { - await _ensureStarted(); - return stem.waitForTaskDefinition(taskId, definition, timeout: timeout); - } - void _insertAutoDisposers( List Function()> autoDisposers, ) { diff --git a/packages/stem/lib/src/bootstrap/stem_client.dart b/packages/stem/lib/src/bootstrap/stem_client.dart index 8c63d7bf..cceac542 100644 --- a/packages/stem/lib/src/bootstrap/stem_client.dart +++ b/packages/stem/lib/src/bootstrap/stem_client.dart @@ -213,17 +213,6 @@ abstract class StemClient implements TaskResultCaller { ); } - /// Waits for a task result using a typed [definition] for decoding. - @override - Future?> - waitForTaskDefinition( - String taskId, - TaskDefinition definition, { - Duration? timeout, - }) { - return stem.waitForTaskDefinition(taskId, definition, timeout: timeout); - } - /// Payload encoder registry used for task args/results. TaskPayloadEncoderRegistry get encoderRegistry; diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index 8fab87d9..489b7618 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -9,7 +9,6 @@ import 'package:stem/src/core/contracts.dart' show GroupStatus, TaskCall, - TaskDefinition, TaskEnqueueOptions, TaskHandler, TaskOptions, @@ -144,16 +143,6 @@ class StemWorkflowApp ); } - @override - Future?> - waitForTaskDefinition( - String taskId, - TaskDefinition definition, { - Duration? timeout, - }) { - return app.waitForTaskDefinition(taskId, definition, timeout: timeout); - } - /// Schedules a workflow run. /// /// Lazily starts the runtime on the first invocation so simple examples do diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index f4edb10c..b88c7de7 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -3006,23 +3006,6 @@ class NoArgsTaskDefinition { /// Decodes a persisted payload into a typed result. TResult? decode(Object? payload) => asDefinition.decode(payload); - /// Builds an explicit [TaskCall] for this no-arg task definition. - TaskCall<(), TResult> buildCall({ - Map headers = const {}, - TaskOptions? options, - DateTime? notBefore, - Map? meta, - TaskEnqueueOptions? enqueueOptions, - }) { - return asDefinition.buildCall( - (), - headers: headers, - options: options, - notBefore: notBefore, - meta: meta, - enqueueOptions: enqueueOptions, - ); - } } /// Represents a pending enqueue operation built from a [TaskDefinition]. diff --git a/packages/stem/lib/src/core/stem.dart b/packages/stem/lib/src/core/stem.dart index 2da33eab..f08a9363 100644 --- a/packages/stem/lib/src/core/stem.dart +++ b/packages/stem/lib/src/core/stem.dart @@ -92,14 +92,6 @@ abstract interface class TaskResultCaller implements TaskEnqueuer { TResult Function(Map payload, int version)? decodeVersionedJson, }); - - /// Waits for [taskId] using a typed [definition] for result decoding. - Future?> - waitForTaskDefinition( - String taskId, - TaskDefinition definition, { - Duration? timeout, - }); } /// Facade used by producer applications to enqueue tasks. @@ -609,39 +601,6 @@ class Stem implements TaskResultCaller { return completer.future; } - /// Waits for [taskId] using the decoding rules from a [TaskDefinition]. - @override - Future?> - waitForTaskDefinition( - String taskId, - TaskDefinition definition, { - Duration? timeout, - }) { - return waitForTask( - taskId, - timeout: timeout, - decode: (payload) { - TResult? value; - try { - value = definition.decode(payload); - } on Object { - if (payload is TResult) { - value = payload; - } else { - rethrow; - } - } - if (value == null && null is! TResult) { - throw StateError( - 'Task definition "${definition.name}" decoded a null result ' - 'for non-nullable type $TResult.', - ); - } - return value as TResult; - }, - ); - } - /// Executes the enqueue middleware chain in order. Future _runEnqueueMiddleware( Envelope envelope, @@ -1144,6 +1103,29 @@ Future _enqueueBuiltTaskCall( ); } +TResult _decodeTaskDefinitionResult( + TaskDefinition definition, + Object? payload, +) { + TResult? value; + try { + value = definition.decode(payload); + } on Object { + if (payload is TResult) { + value = payload; + } else { + rethrow; + } + } + if (value == null && null is! TResult) { + throw StateError( + 'Task definition "${definition.name}" decoded a null result ' + 'for non-nullable type $TResult.', + ); + } + return value as TResult; +} + /// Convenience helpers for waiting on typed task definitions. extension TaskDefinitionExtension @@ -1210,7 +1192,11 @@ extension TaskDefinitionExtension String taskId, { Duration? timeout, }) { - return caller.waitForTaskDefinition(taskId, this, timeout: timeout); + return caller.waitForTask( + taskId, + timeout: timeout, + decode: (payload) => _decodeTaskDefinitionResult(this, payload), + ); } } @@ -1228,7 +1214,8 @@ extension NoArgsTaskDefinitionExtension }) { return _enqueueBuiltTaskCall( enqueuer, - buildCall( + asDefinition.buildCall( + (), headers: headers, options: options, notBefore: notBefore, @@ -1245,7 +1232,7 @@ extension NoArgsTaskDefinitionExtension String taskId, { Duration? timeout, }) { - return caller.waitForTaskDefinition(taskId, asDefinition, timeout: timeout); + return asDefinition.waitFor(caller, taskId, timeout: timeout); } /// Enqueues this no-arg task definition and waits for the typed result. diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 6f3c46e9..6cf6c7ca 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -270,20 +270,6 @@ class NoArgsWorkflowRef { ); } - /// Builds an explicit [WorkflowStartCall] for this no-args workflow ref. - WorkflowStartCall<(), TResult> buildStart({ - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return asRef.buildStart( - params: (), - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ); - } - /// Decodes a final workflow result payload. TResult decode(Object? payload) => asRef.decode(payload); diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index ac8ca2bb..28e39731 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -657,7 +657,7 @@ void main() { }); }); - group('Stem.waitForTaskDefinition', () { + group('TaskDefinition.waitFor', () { test('does not double decode codec-backed terminal results', () async { final backend = _codecAwareBackend(); final stem = _codecAwareStem(backend); @@ -669,9 +669,9 @@ void main() { meta: {stemResultEncoderMetaKey: _codecReceiptEncoder.id}, ); - final result = await stem.waitForTaskDefinition( + final result = await _codecReceiptDefinition.waitFor( + stem, 'task-terminal', - _codecReceiptDefinition, ); expect(result?.value?.id, 'receipt-terminal'); @@ -693,9 +693,9 @@ void main() { }), ); - final result = await stem.waitForTaskDefinition( + final result = await _codecReceiptDefinition.waitFor( + stem, 'task-watched', - _codecReceiptDefinition, timeout: const Duration(seconds: 1), ); diff --git a/packages/stem/test/unit/core/task_enqueue_builder_test.dart b/packages/stem/test/unit/core/task_enqueue_builder_test.dart index 62e82530..29864b82 100644 --- a/packages/stem/test/unit/core/task_enqueue_builder_test.dart +++ b/packages/stem/test/unit/core/task_enqueue_builder_test.dart @@ -164,10 +164,11 @@ void main() { expect(updated.meta['m2'], 2); }); - test('NoArgsTaskDefinition.buildCall builds an empty payload call', () { + test('NoArgsTaskDefinition.asDefinition.buildCall builds an empty call', () { final definition = TaskDefinition.noArgs(name: 'demo.no_args'); - final call = definition.buildCall( + final call = definition.asDefinition.buildCall( + (), headers: const {'h': 'v'}, meta: const {'m': 1}, ); @@ -178,10 +179,11 @@ void main() { expect(call.meta, containsPair('m', 1)); }); - test('NoArgsTaskDefinition.buildCall accepts direct overrides', () { + test('NoArgsTaskDefinition.asDefinition.buildCall accepts overrides', () { final definition = TaskDefinition.noArgs(name: 'demo.no_args'); - final call = definition.buildCall( + final call = definition.asDefinition.buildCall( + (), options: const TaskOptions(priority: 4), ); @@ -272,17 +274,4 @@ class _RecordingTaskResultCaller extends _RecordingTaskEnqueuer rawPayload: 'stored', ); } - - @override - Future?> waitForTaskDefinition( - String taskId, - TaskDefinition definition, { - Duration? timeout, - }) { - return waitForTask( - taskId, - timeout: timeout, - decode: (payload) => definition.decode(payload) as TResult, - ); - } } diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index b1c2c201..8a32fe74 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -486,7 +486,8 @@ void main() { expect(result?.value, 'hello builder'); expect(state?.parentRunId, 'parent-builder'); - final builtScriptCall = script.ref0().buildStart( + final builtScriptCall = script.ref0().asRef.buildStart( + params: (), cancellationPolicy: const WorkflowCancellationPolicy( maxRunDuration: Duration(seconds: 5), ), @@ -558,7 +559,8 @@ void main() { expect(result?.value, 'hello builder'); expect(state?.parentRunId, 'parent-bound'); - final builtScriptCall = scriptRef.buildStart( + final builtScriptCall = scriptRef.asRef.buildStart( + params: (), cancellationPolicy: const WorkflowCancellationPolicy( maxRunDuration: Duration(seconds: 5), ), From 5f66cdcfcd582e005fe9d8a349ea3a97e9f04c3c Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 15:46:23 -0500 Subject: [PATCH 270/302] Remove transport call copy helpers --- .site/docs/core-concepts/tasks.md | 8 +++---- .site/docs/workflows/annotated-workflows.md | 3 +-- .../workflows/context-and-serialization.md | 2 +- .site/docs/workflows/starting-and-waiting.md | 4 ++-- packages/stem/CHANGELOG.md | 3 +++ packages/stem/README.md | 5 ++-- packages/stem/lib/src/core/contracts.dart | 24 ++++--------------- packages/stem/lib/src/core/stem.dart | 9 ++++++- .../stem/lib/src/core/task_invocation.dart | 6 ++++- .../lib/src/workflow/core/workflow_ref.dart | 14 ----------- .../workflow/runtime/workflow_runtime.dart | 13 ++++++++-- .../unit/core/task_enqueue_builder_test.dart | 14 ++++------- .../test/unit/core/task_registry_test.dart | 3 +-- .../workflow/workflow_runtime_ref_test.dart | 2 -- 14 files changed, 48 insertions(+), 62 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 6a118315..b2232b45 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -97,10 +97,10 @@ final request = context.argsJson( ); ``` -Use `buildCall(...)` when you need an explicit low-level transport object, and -then `copyWith(...)` if you need to adjust headers, metadata, options, or -scheduling before dispatch. For the normal case, prefer direct `enqueue(...)` -/ `enqueueAndWait(...)`. +Use `buildCall(...)` when you need an explicit low-level transport object and +provide the final headers, metadata, options, or scheduling overrides up +front. For the normal case, prefer direct `enqueue(...)` / +`enqueueAndWait(...)`. For tasks with no producer inputs, use `TaskDefinition.noArgs(...)` instead. That gives you direct `enqueue(...)` / diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index 4344adc7..e790cb9e 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -138,8 +138,7 @@ When a workflow needs to start another workflow, do it from a durable boundary: - pass `ttl:`, `parentRunId:`, or `cancellationPolicy:` directly to `ref.start(...)` / `ref.startAndWait(...)` for the normal override cases - when you need an explicit low-level transport object, prefer - `ref.buildStart(...)` and then `copyWith(...)` for the rarer - override-heavy cases + `ref.buildStart(...)` for the rarer explicit transport cases Avoid starting child workflows from the raw `WorkflowScriptContext` body. diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index c7fcb475..996a3305 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -89,7 +89,7 @@ Child workflow starts belong in durable boundaries: - pass `ttl:`, `parentRunId:`, or `cancellationPolicy:` directly to those helpers for the normal override cases - keep `ref.buildStart(...)` for the rarer cases where you explicitly want a - reusable `WorkflowStartCall` + reusable `WorkflowStartCall` built with its final overrides Do not treat the raw `WorkflowScriptContext` body as a safe place for child starts or other replay-sensitive side effects. diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 3988646c..ab3d473f 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -90,8 +90,8 @@ low-level transport object for `startWorkflowCall(...)`, build it with transport object, not the normal happy path. Use `ref0()` when another API specifically needs a `NoArgsWorkflowRef`. -When you need to adjust an explicit start request after construction, prefer -`ref.buildStart(...)` plus `copyWith(...)`. +When you need an explicit start request, prefer `ref.buildStart(...)` with the +final overrides you already know. ## Wait for completion diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index e3f629ee..eec5950c 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -38,6 +38,9 @@ - Added direct `buildCall(...)` / `buildStart(...)` helpers on task/workflow definitions so the explicit transport path no longer requires `prepare...().build()` when all overrides are already known. +- Removed `TaskCall.copyWith(...)` and `WorkflowStartCall.copyWith(...)`. + Explicit transport objects are now built with their final overrides up + front rather than mutated after construction. - Removed no-args `buildCall()` / `buildStart()` transport wrappers in favor of the explicit typed surfaces: `definition.asDefinition.buildCall(())` and diff --git a/packages/stem/README.md b/packages/stem/README.md index e1a76fb9..978e24a8 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -313,7 +313,7 @@ print(result?.value); Treat `buildCall(...)` as the advanced path when you need an explicit transport object with custom headers, metadata, delay, priority, or other -overrides. Use `copyWith(...)` if you need to adjust it before dispatch. For +overrides. Build the final call directly with the overrides you need. For the normal case, prefer direct `enqueue(...)` or `enqueueAndWait(...)`. ### Enqueue from inside a task @@ -731,8 +731,7 @@ Child workflows belong in durable execution boundaries: - pass `ttl:`, `parentRunId:`, or `cancellationPolicy:` directly to `ref.start(...)` / `ref.startAndWait(...)` for normal override cases - when you need an explicit low-level transport object, prefer - `ref.buildStart(...)` and then `copyWith(...)` for the rarer - override-heavy cases + `ref.buildStart(...)` for the rarer explicit transport cases - do not start child workflows from the raw `WorkflowScriptContext` body unless you are deliberately managing replay/idempotency yourself diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index b88c7de7..67970004 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2419,9 +2419,13 @@ class TaskContext implements TaskExecutionContext { mergedMeta.putIfAbsent('stem.rootTaskId', () => id); } - final mergedCall = call.copyWith( + final mergedCall = call.definition.buildCall( + call.args, headers: Map.unmodifiable(mergedHeaders), + options: call.options, + notBefore: call.notBefore, meta: Map.unmodifiable(mergedMeta), + enqueueOptions: call.enqueueOptions, ); return delegate.enqueueCall( @@ -3050,24 +3054,6 @@ class TaskCall { /// Resolve final options combining call overrides with defaults. TaskOptions resolveOptions() => options ?? definition.defaultOptions; - /// Returns a copy of this call with updated properties. - TaskCall copyWith({ - Map? headers, - TaskOptions? options, - DateTime? notBefore, - Map? meta, - TaskEnqueueOptions? enqueueOptions, - }) { - return TaskCall._( - definition: definition, - args: args, - headers: headers ?? this.headers, - options: options ?? this.options, - notBefore: notBefore ?? this.notBefore, - meta: meta ?? this.meta, - enqueueOptions: enqueueOptions ?? this.enqueueOptions, - ); - } } /// Convenience helpers for building typed enqueue requests directly from a task diff --git a/packages/stem/lib/src/core/stem.dart b/packages/stem/lib/src/core/stem.dart index f08a9363..07ac8012 100644 --- a/packages/stem/lib/src/core/stem.dart +++ b/packages/stem/lib/src/core/stem.dart @@ -1098,7 +1098,14 @@ Future _enqueueBuiltTaskCall( } final mergedMeta = Map.from(scopeMeta)..addAll(call.meta); return enqueuer.enqueueCall( - call.copyWith(meta: Map.unmodifiable(mergedMeta)), + call.definition.buildCall( + call.args, + headers: call.headers, + options: call.options, + notBefore: call.notBefore, + meta: Map.unmodifiable(mergedMeta), + enqueueOptions: call.enqueueOptions, + ), enqueueOptions: resolvedEnqueueOptions, ); } diff --git a/packages/stem/lib/src/core/task_invocation.dart b/packages/stem/lib/src/core/task_invocation.dart index 544b67ed..47a4926a 100644 --- a/packages/stem/lib/src/core/task_invocation.dart +++ b/packages/stem/lib/src/core/task_invocation.dart @@ -751,9 +751,13 @@ class TaskInvocationContext implements TaskExecutionContext { mergedMeta.putIfAbsent('stem.rootTaskId', () => id); } - final mergedCall = call.copyWith( + final mergedCall = call.definition.buildCall( + call.args, headers: Map.unmodifiable(mergedHeaders), + options: call.options, + notBefore: call.notBefore, meta: Map.unmodifiable(mergedMeta), + enqueueOptions: call.enqueueOptions, ); return delegate.enqueueCall( mergedCall, diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 6cf6c7ca..52cd98d6 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -346,20 +346,6 @@ class WorkflowStartCall { /// Encodes typed parameters into the workflow parameter map. Map encodeParams() => definition.encodeParams(params); - /// Returns a copy of this call with updated workflow start options. - WorkflowStartCall copyWith({ - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return WorkflowStartCall._( - definition: definition, - params: params, - parentRunId: parentRunId ?? this.parentRunId, - ttl: ttl ?? this.ttl, - cancellationPolicy: cancellationPolicy ?? this.cancellationPolicy, - ); - } } /// Convenience helpers for waiting on typed workflow refs using a generic diff --git a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart index 1e21c2f2..f60cbf47 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart @@ -2168,9 +2168,13 @@ class _WorkflowStepEnqueuer implements TaskEnqueuer { executionQueue != 'default') { resolvedOptions = resolvedOptions.copyWith(queue: executionQueue); } - final mergedCall = call.copyWith( + final mergedCall = call.definition.buildCall( + call.args, + headers: call.headers, options: resolvedOptions, + notBefore: call.notBefore, meta: Map.unmodifiable(mergedMeta), + enqueueOptions: call.enqueueOptions, ); return delegate.enqueueCall( mergedCall, @@ -2210,7 +2214,12 @@ class _ChildWorkflowCaller implements WorkflowCaller { WorkflowStartCall call, ) { return runtime.startWorkflowCall( - call.copyWith(parentRunId: parentRunId), + call.definition.buildStart( + params: call.params, + parentRunId: parentRunId, + ttl: call.ttl, + cancellationPolicy: call.cancellationPolicy, + ), ); } diff --git a/packages/stem/test/unit/core/task_enqueue_builder_test.dart b/packages/stem/test/unit/core/task_enqueue_builder_test.dart index 29864b82..6ff0dcc5 100644 --- a/packages/stem/test/unit/core/task_enqueue_builder_test.dart +++ b/packages/stem/test/unit/core/task_enqueue_builder_test.dart @@ -39,13 +39,14 @@ void main() { expect(call.meta, containsPair('from', 'definition')); }); - test('TaskCall.copyWith replaces headers, metadata, and options', () { + test('buildCall accepts direct headers, metadata, and options', () { final definition = TaskDefinition, Object?>( name: 'demo.task', encodeArgs: (args) => args, ); - final call = definition.buildCall(const {'a': 1}).copyWith( + final call = definition.buildCall( + const {'a': 1}, headers: const {'h': 'v'}, meta: const {'m': 1}, options: const TaskOptions(queue: 'q', priority: 9), @@ -144,18 +145,13 @@ void main() { expect(result?.value, 'decoded:stored'); }); - test('TaskCall.copyWith updates headers and meta', () { + test('buildCall can be rebuilt with updated headers and meta', () { final definition = TaskDefinition, Object?>( name: 'demo.task', encodeArgs: (args) => args, ); - final call = definition.buildCall( + final updated = definition.buildCall( const {'a': 1}, - headers: const {'h': 'v'}, - meta: const {'m': 1}, - ); - - final updated = call.copyWith( headers: const {'h2': 'v2'}, meta: const {'m2': 2}, ); diff --git a/packages/stem/test/unit/core/task_registry_test.dart b/packages/stem/test/unit/core/task_registry_test.dart index e829bf0f..bd63b56c 100644 --- a/packages/stem/test/unit/core/task_registry_test.dart +++ b/packages/stem/test/unit/core/task_registry_test.dart @@ -238,7 +238,7 @@ void main() { }); group('TaskCall', () { - test('buildCall plus copyWith builds TaskCall with overrides', () { + test('buildCall builds TaskCall with overrides', () { final definition = TaskDefinition<_Args, void>( name: 'demo.task', encodeArgs: (args) => {'value': args.value}, @@ -246,7 +246,6 @@ void main() { final call = definition.buildCall( _Args(7), - ).copyWith( headers: const {'x-id': 'abc'}, meta: const {'source': 'test'}, options: const TaskOptions(priority: 5, queue: 'fast'), diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 8a32fe74..9d9f642e 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -467,7 +467,6 @@ void main() { final builtFlowCall = workflowRef.buildStart( params: const {'name': 'builder'}, - ).copyWith( ttl: const Duration(minutes: 5), parentRunId: 'parent-builder', ); @@ -540,7 +539,6 @@ void main() { final builtFlowCall = workflowRef.buildStart( params: const {'name': 'builder'}, - ).copyWith( ttl: const Duration(minutes: 5), parentRunId: 'parent-bound', ); From a3f05b464560f55a174840013e4ee7f16fe5e3b6 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 15:51:13 -0500 Subject: [PATCH 271/302] Add versioned manual result helpers --- .site/docs/core-concepts/tasks.md | 4 +- .site/docs/workflows/getting-started.md | 6 +- .site/docs/workflows/starting-and-waiting.md | 5 +- packages/stem/CHANGELOG.md | 5 ++ packages/stem/README.md | 21 ++++--- packages/stem/lib/src/core/contracts.dart | 25 ++++++++ packages/stem/lib/src/workflow/core/flow.dart | 28 +++++++++ .../workflow/core/workflow_definition.dart | 58 +++++++++++++++++++ .../src/workflow/core/workflow_script.dart | 30 ++++++++++ .../stem/test/unit/core/stem_core_test.dart | 47 +++++++++++++++ .../workflow/workflow_runtime_ref_test.dart | 53 +++++++++++++++++ 11 files changed, 269 insertions(+), 13 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index b2232b45..8debc854 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -109,7 +109,9 @@ instead. That gives you direct `enqueue(...)` / If a no-arg task returns a DTO, prefer `TaskDefinition.noArgsJson(...)` when the result already has `toJson()` and `Type.fromJson(...)`. Use -`TaskDefinition.noArgsCodec(...)` only when you need a custom payload codec. +`TaskDefinition.noArgsVersionedJson(...)` when the stored result should carry +an explicit schema version, and `TaskDefinition.noArgsCodec(...)` only when +you need a custom payload codec. ## Configuring Retries diff --git a/.site/docs/workflows/getting-started.md b/.site/docs/workflows/getting-started.md index 9000bbc5..66dee242 100644 --- a/.site/docs/workflows/getting-started.md +++ b/.site/docs/workflows/getting-started.md @@ -72,8 +72,10 @@ runtime registry: If you are registering raw `WorkflowDefinition` values directly, prefer `WorkflowDefinition.flowJson(...)` / `.scriptJson(...)` for the common DTO -path and `WorkflowDefinition.flowCodec(...)` / `.scriptCodec(...)` when the -result needs a custom codec. +path, `WorkflowDefinition.flowVersionedJson(...)` / +`.scriptVersionedJson(...)` when the stored result should carry an explicit +schema version, and `WorkflowDefinition.flowCodec(...)` / `.scriptCodec(...)` +when the result needs a custom codec. ## 5. Move to the right next page diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index ab3d473f..b49ae5fe 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -74,8 +74,9 @@ Inside manual flow steps and script checkpoints, prefer If a manual flow or script returns a DTO, prefer `Flow.json(...)` or `WorkflowScript.json(...)` in the common `toJson()` / `Type.fromJson(...)` -case. Use `Flow.codec(...)` or `WorkflowScript.codec(...)` when the result -needs a custom payload codec. +case. Use `Flow.versionedJson(...)` / `WorkflowScript.versionedJson(...)` when +the stored result should carry an explicit schema version. Use `Flow.codec(...)` +or `WorkflowScript.codec(...)` when the result needs a custom payload codec. When the persisted workflow result or suspension payload carries an explicit `__stemPayloadVersion`, use `workflowResult.payloadVersionedJson(...)`, `runState.resultVersionedJson(...)`, or diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index eec5950c..22d6330c 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -41,6 +41,11 @@ - Removed `TaskCall.copyWith(...)` and `WorkflowStartCall.copyWith(...)`. Explicit transport objects are now built with their final overrides up front rather than mutated after construction. +- Added versioned manual-result convenience constructors: + `TaskDefinition.noArgsVersionedJson(...)`, + `WorkflowDefinition.flowVersionedJson(...)`, + `WorkflowDefinition.scriptVersionedJson(...)`, + `Flow.versionedJson(...)`, and `WorkflowScript.versionedJson(...)`. - Removed no-args `buildCall()` / `buildStart()` transport wrappers in favor of the explicit typed surfaces: `definition.asDefinition.buildCall(())` and diff --git a/packages/stem/README.md b/packages/stem/README.md index 978e24a8..85bfd29a 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -284,9 +284,10 @@ await healthcheckDefinition.enqueue(stem); If a no-arg task returns a DTO, prefer `TaskDefinition.noArgsJson(...)` in the common `toJson()` / `Type.fromJson(...)` case. Use -`TaskDefinition.noArgsCodec(...)` when you need a custom payload codec. Both -paths keep waiting helpers typed and advertise the right result encoder in task -metadata. +`TaskDefinition.noArgsVersionedJson(...)` when the stored result needs an +explicit schema version, and `TaskDefinition.noArgsCodec(...)` when you need a +custom payload codec. These paths keep waiting helpers typed and advertise the +right result encoder in task metadata. When a DTO payload needs an explicit persisted schema version, prefer `PayloadCodec.versionedJson(...)`. It stores `__stemPayloadVersion` beside the @@ -433,8 +434,10 @@ For late registration, prefer the app helpers: If you are registering raw `WorkflowDefinition` values directly, prefer `WorkflowDefinition.flowJson(...)` / `.scriptJson(...)` for the common DTO -path and `WorkflowDefinition.flowCodec(...)` / `.scriptCodec(...)` for custom -result codecs. +path, `WorkflowDefinition.flowVersionedJson(...)` / +`.scriptVersionedJson(...)` when the stored result needs an explicit schema +version, and `WorkflowDefinition.flowCodec(...)` / `.scriptCodec(...)` for +custom result codecs. ### Workflow script facade @@ -607,9 +610,11 @@ params still need to encode to a string-keyed map (typically `Map`) because they are persisted as JSON-shaped data. If a manual flow or script only needs DTO result decoding, prefer -`Flow.json(...)` or `WorkflowScript.json(...)`. If the final result needs a -custom codec, prefer `Flow.codec(...)` or `WorkflowScript.codec(...)` instead -of passing `resultCodec:` to the base constructor. +`Flow.json(...)` or `WorkflowScript.json(...)`. Use +`Flow.versionedJson(...)` / `WorkflowScript.versionedJson(...)` when the stored +result needs an explicit schema version. If the final result needs a custom +codec, prefer `Flow.codec(...)` or `WorkflowScript.codec(...)` instead of +passing `resultCodec:` to the base constructor. For workflows without start parameters, start directly from the flow or script itself: diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 67970004..ca6a3e4a 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2811,6 +2811,31 @@ class TaskDefinition { ); } + /// Creates a typed task definition for handlers with no producer args whose + /// result is a versioned DTO-backed JSON value. + static NoArgsTaskDefinition noArgsVersionedJson({ + required String name, + required int version, + required TResult Function(Map payload, int version) + decodeResult, + TaskOptions defaultOptions = const TaskOptions(), + TaskMetadata metadata = const TaskMetadata(), + int? defaultDecodeVersion, + String? resultTypeName, + }) { + return noArgs( + name: name, + defaultOptions: defaultOptions, + metadata: metadata, + resultCodec: PayloadCodec.versionedJson( + version: version, + decode: decodeResult, + defaultDecodeVersion: defaultDecodeVersion, + typeName: resultTypeName ?? '$TResult', + ), + ); + } + /// Creates a typed task definition for handlers with no producer args. static NoArgsTaskDefinition noArgs({ required String name, diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index 99c6f1e8..28db3dc0 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -73,6 +73,34 @@ class Flow { ); } + /// Creates a flow definition whose final result is a versioned DTO-backed + /// JSON value. + factory Flow.versionedJson({ + required String name, + required void Function(FlowBuilder builder) build, + required int version, + required T Function(Map payload, int version) decodeResult, + String? workflowVersion, + String? description, + Map? metadata, + int? defaultDecodeVersion, + String? resultTypeName, + }) { + return Flow( + name: name, + build: build, + version: workflowVersion, + description: description, + metadata: metadata, + resultCodec: PayloadCodec.versionedJson( + version: version, + decode: decodeResult, + defaultDecodeVersion: defaultDecodeVersion, + typeName: resultTypeName ?? '$T', + ), + ); + } + /// The constructed workflow definition. final WorkflowDefinition definition; diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index d7c08465..0a90ce01 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -245,6 +245,34 @@ class WorkflowDefinition { ); } + /// Creates a flow-based workflow definition whose final result is a + /// versioned DTO-backed JSON payload. + factory WorkflowDefinition.flowVersionedJson({ + required String name, + required void Function(FlowBuilder builder) build, + required int version, + required T Function(Map payload, int version) decodeResult, + String? workflowVersion, + String? description, + Map? metadata, + int? defaultDecodeVersion, + String? resultTypeName, + }) { + return WorkflowDefinition.flow( + name: name, + build: build, + version: workflowVersion, + description: description, + metadata: metadata, + resultCodec: PayloadCodec.versionedJson( + version: version, + decode: decodeResult, + defaultDecodeVersion: defaultDecodeVersion, + typeName: resultTypeName ?? '$T', + ), + ); + } + /// Creates a script-based workflow definition. factory WorkflowDefinition.script({ required String name, @@ -339,6 +367,36 @@ class WorkflowDefinition { ); } + /// Creates a script-based workflow definition whose final result is a + /// versioned DTO-backed JSON payload. + factory WorkflowDefinition.scriptVersionedJson({ + required String name, + required WorkflowScriptBody run, + required int version, + required T Function(Map payload, int version) decodeResult, + Iterable checkpoints = const [], + String? workflowVersion, + String? description, + Map? metadata, + int? defaultDecodeVersion, + String? resultTypeName, + }) { + return WorkflowDefinition.script( + name: name, + run: run, + checkpoints: checkpoints, + version: workflowVersion, + description: description, + metadata: metadata, + resultCodec: PayloadCodec.versionedJson( + version: version, + decode: decodeResult, + defaultDecodeVersion: defaultDecodeVersion, + typeName: resultTypeName ?? '$T', + ), + ); + } + /// Workflow name used for registration and scheduling. final String name; final WorkflowDefinitionKind _kind; diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index 95e501d8..458902a6 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -81,6 +81,36 @@ class WorkflowScript { ); } + /// Creates a script definition whose final result is a versioned DTO-backed + /// JSON value. + factory WorkflowScript.versionedJson({ + required String name, + required WorkflowScriptBody run, + required int version, + required T Function(Map payload, int version) decodeResult, + Iterable checkpoints = const [], + String? workflowVersion, + String? description, + Map? metadata, + int? defaultDecodeVersion, + String? resultTypeName, + }) { + return WorkflowScript( + name: name, + run: run, + checkpoints: checkpoints, + version: workflowVersion, + description: description, + metadata: metadata, + resultCodec: PayloadCodec.versionedJson( + version: version, + decode: decodeResult, + defaultDecodeVersion: defaultDecodeVersion, + typeName: resultTypeName ?? '$T', + ), + ); + } + /// The constructed workflow definition. final WorkflowDefinition definition; diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index 28e39731..21a4a1b9 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -478,6 +478,28 @@ void main() { expect(backend.records.single.id, id); }, ); + + test( + 'no-arg task definitions can derive versioned json-backed result metadata', + () async { + final broker = _RecordingBroker(); + final backend = _RecordingBackend(); + final stem = Stem(broker: broker, backend: backend); + final definition = TaskDefinition.noArgsVersionedJson<_CodecReceipt>( + name: 'sample.no_args.versioned_json', + version: 2, + decodeResult: _CodecReceipt.fromVersionedJson, + ); + + final id = await definition.enqueue(stem); + + expect( + backend.records.single.meta[stemResultEncoderMetaKey], + endsWith('.result.codec'), + ); + expect(backend.records.single.id, id); + }, + ); }); group('TaskCall helpers', () { @@ -631,6 +653,31 @@ void main() { expect(result?.rawPayload, isA<_CodecReceipt>()); }); + test('supports versioned no-arg task definitions', () async { + final backend = InMemoryResultBackend(); + final stem = Stem(broker: _RecordingBroker(), backend: backend); + final definition = TaskDefinition.noArgsVersionedJson<_CodecReceipt>( + name: 'no-args.versioned.wait', + version: 2, + decodeResult: _CodecReceipt.fromVersionedJson, + ); + + await backend.set( + 'task-no-args-versioned-wait', + TaskState.succeeded, + payload: {'id': 'done', PayloadCodec.versionKey: 2}, + meta: {stemResultEncoderMetaKey: _codecReceiptEncoder.id}, + ); + + final result = await definition.waitFor( + stem, + 'task-no-args-versioned-wait', + ); + + expect(result?.value?.id, 'done-v2'); + expect(result?.rawPayload, isA>()); + }); + test('enqueueAndWait supports no-arg task definitions', () async { final broker = _RecordingBroker(); final backend = _RecordingBackend(); diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 9d9f642e..f242eb2f 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -20,6 +20,15 @@ class _GreetingResult { return _GreetingResult(message: json['message']! as String); } + factory _GreetingResult.fromVersionedJson( + Map json, + int version, + ) { + return _GreetingResult( + message: '${json['message']! as String} v$version', + ); + } + final String message; Map toJson() => {'message': message}; @@ -403,6 +412,50 @@ void main() { }, ); + test( + 'raw workflow definitions expose direct versioned json result helpers', + () async { + final flow = WorkflowDefinition<_GreetingResult>.flowVersionedJson( + name: 'runtime.ref.definition.versioned.result.flow', + version: 2, + decodeResult: _GreetingResult.fromVersionedJson, + build: (builder) { + builder.step( + 'hello', + (ctx) async => const _GreetingResult(message: 'hello flow'), + ); + }, + ); + final script = WorkflowDefinition<_GreetingResult>.scriptVersionedJson( + name: 'runtime.ref.definition.versioned.result.script', + version: 2, + decodeResult: _GreetingResult.fromVersionedJson, + run: (context) async => + const _GreetingResult(message: 'hello script'), + ); + + final workflowApp = await StemWorkflowApp.inMemory(); + try { + workflowApp.registerWorkflows([flow, script]); + await workflowApp.start(); + + final flowResult = await flow.ref0().startAndWait( + workflowApp.runtime, + timeout: const Duration(seconds: 2), + ); + final scriptResult = await script.ref0().startAndWait( + workflowApp.runtime, + timeout: const Duration(seconds: 2), + ); + + expect(flowResult?.value?.message, 'hello flow v2'); + expect(scriptResult?.value?.message, 'hello script v2'); + } finally { + await workflowApp.shutdown(); + } + }, + ); + test('manual workflows expose direct no-args helpers', () async { final flow = Flow( name: 'runtime.ref.no-args.flow', From 43c878fd29285f8245644939d84cdcc4cbfa6025 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 15:54:23 -0500 Subject: [PATCH 272/302] Refresh stale transport call examples --- packages/stem/CHANGELOG.md | 3 +++ packages/stem/example/durable_watchers.dart | 7 ++++--- packages/stem/example/task_context_mixed/lib/shared.dart | 4 ++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 22d6330c..242b0498 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Refreshed runnable workflow/task examples to remove stale `.call(...)` + transport usage and prefer the narrowed direct `start(...)` / + `buildCall(...)` surfaces. - Removed `WorkflowRef.call(...)` as a duplicate workflow-start convenience. The direct `start(...)` / `startAndWait(...)` helpers remain the happy path, and `buildStart(...)` remains the explicit prebuilt-call path. diff --git a/packages/stem/example/durable_watchers.dart b/packages/stem/example/durable_watchers.dart index 3af51694..5098aab5 100644 --- a/packages/stem/example/durable_watchers.dart +++ b/packages/stem/example/durable_watchers.dart @@ -36,9 +36,10 @@ Future main() async { scripts: [shipmentWorkflow], ); - final runId = await shipmentWorkflowRef - .call(const {'orderId': 'A-123'}) - .start(app); + final runId = await shipmentWorkflowRef.start( + app, + params: const {'orderId': 'A-123'}, + ); // Drive the run until it suspends on the watcher. await app.executeRun(runId); diff --git a/packages/stem/example/task_context_mixed/lib/shared.dart b/packages/stem/example/task_context_mixed/lib/shared.dart index 7c72b888..aff34176 100644 --- a/packages/stem/example/task_context_mixed/lib/shared.dart +++ b/packages/stem/example/task_context_mixed/lib/shared.dart @@ -250,12 +250,12 @@ class InlineCoordinatorTask extends TaskHandler { publishConnection: const {'adapter': 'sqlite'}, producer: const {'app': 'task-context-mixed'}, link: [ - linkSuccessDefinition.call( + linkSuccessDefinition.buildCall( {'runId': runId, 'source': 'link'}, ), ], linkError: [ - linkErrorDefinition.call( + linkErrorDefinition.buildCall( {'runId': runId, 'source': 'link_error'}, ), ], From 57dd4ec195f273aa8f220beb79f1307827778eca Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 15:57:23 -0500 Subject: [PATCH 273/302] Add versioned result support to argful task definitions --- .site/docs/core-concepts/tasks.md | 4 ++ packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 4 ++ packages/stem/lib/src/core/contracts.dart | 25 +++++++-- .../stem/test/unit/core/stem_core_test.dart | 54 ++++++++++++++++++- 5 files changed, 84 insertions(+), 6 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 8debc854..1bea7189 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -113,6 +113,10 @@ the result already has `toJson()` and `Type.fromJson(...)`. Use an explicit schema version, and `TaskDefinition.noArgsCodec(...)` only when you need a custom payload codec. +For argful manual tasks, `TaskDefinition.versionedJson(...)` also accepts +`decodeResultVersionedJson:` when the stored result should carry an explicit +schema version. + ## Configuring Retries Workers apply an `ExponentialJitterRetryStrategy` by default. Each retry is diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 242b0498..ea99c371 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Added `decodeResultVersionedJson:` to + `TaskDefinition.versionedJson(...)` so argful manual task definitions can + derive version-aware result decoding and result-encoder metadata. - Refreshed runnable workflow/task examples to remove stale `.call(...)` transport usage and prefer the narrowed direct `start(...)` / `buildCall(...)` surfaces. diff --git a/packages/stem/README.md b/packages/stem/README.md index 85bfd29a..36dfc2f1 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -289,6 +289,10 @@ explicit schema version, and `TaskDefinition.noArgsCodec(...)` when you need a custom payload codec. These paths keep waiting helpers typed and advertise the right result encoder in task metadata. +For argful manual tasks, `TaskDefinition.versionedJson(...)` also accepts +`decodeResultVersionedJson:` when the stored result needs the same explicit +schema-version decode path. + When a DTO payload needs an explicit persisted schema version, prefer `PayloadCodec.versionedJson(...)`. It stores `__stemPayloadVersion` beside the JSON payload and passes the persisted version into the decoder so you can keep diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index ca6a3e4a..32fdcbf9 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2756,15 +2756,30 @@ class TaskDefinition { TaskOptions defaultOptions = const TaskOptions(), TaskMetadata metadata = const TaskMetadata(), TResult Function(Map payload)? decodeResultJson, + TResult Function(Map payload, int version)? + decodeResultVersionedJson, + int? defaultDecodeVersion, String? argsTypeName, String? resultTypeName, }) { - final resultCodec = decodeResultJson == null - ? null - : PayloadCodec.json( - decode: decodeResultJson, + assert( + decodeResultJson == null || decodeResultVersionedJson == null, + 'Specify either decodeResultJson or decodeResultVersionedJson, not both.', + ); + final resultCodec = + decodeResultVersionedJson != null + ? PayloadCodec.versionedJson( + version: version, + decode: decodeResultVersionedJson, + defaultDecodeVersion: defaultDecodeVersion, typeName: resultTypeName ?? '$TResult', - ); + ) + : (decodeResultJson == null + ? null + : PayloadCodec.json( + decode: decodeResultJson, + typeName: resultTypeName ?? '$TResult', + )); return TaskDefinition( name: name, encodeArgs: (args) => _encodeVersionedJsonArgs( diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index 21a4a1b9..7aa787b8 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -321,6 +321,31 @@ void main() { }, ); + test( + 'versioned json task definitions can derive versioned result metadata', + () async { + final broker = _RecordingBroker(); + final backend = _RecordingBackend(); + final stem = Stem(broker: broker, backend: backend); + final definition = + TaskDefinition<_CodecTaskArgs, _CodecReceipt>.versionedJson( + name: 'sample.versioned_json.result', + version: 2, + decodeResultVersionedJson: _CodecReceipt.fromVersionedJson, + ); + + final id = await stem.enqueueCall( + definition.buildCall(const _CodecTaskArgs('encoded')), + ); + + expect( + backend.records.single.meta[stemResultEncoderMetaKey], + endsWith('.result.codec'), + ); + expect(backend.records.single.id, id); + }, + ); + test( 'enqueueCall publishes no-arg definitions without fake empty maps', () async { @@ -480,7 +505,8 @@ void main() { ); test( - 'no-arg task definitions can derive versioned json-backed result metadata', + 'no-arg task definitions can derive versioned json-backed result' + ' metadata', () async { final broker = _RecordingBroker(); final backend = _RecordingBackend(); @@ -678,6 +704,32 @@ void main() { expect(result?.rawPayload, isA>()); }); + test('supports versioned argful task definitions', () async { + final backend = InMemoryResultBackend(); + final stem = Stem(broker: _RecordingBroker(), backend: backend); + final definition = + TaskDefinition<_CodecTaskArgs, _CodecReceipt>.versionedJson( + name: 'args.versioned.wait', + version: 2, + decodeResultVersionedJson: _CodecReceipt.fromVersionedJson, + ); + + await backend.set( + 'task-args-versioned-wait', + TaskState.succeeded, + payload: {'id': 'done', PayloadCodec.versionKey: 2}, + meta: {stemResultEncoderMetaKey: _codecReceiptEncoder.id}, + ); + + final result = await definition.waitFor( + stem, + 'task-args-versioned-wait', + ); + + expect(result?.value?.id, 'done-v2'); + expect(result?.rawPayload, isA>()); + }); + test('enqueueAndWait supports no-arg task definitions', () async { final broker = _RecordingBroker(); final backend = _RecordingBackend(); From 590946e3557e9762823782e2ae4c828d24c9204c Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 16:01:13 -0500 Subject: [PATCH 274/302] Add versioned result support to manual workflow refs --- .site/docs/workflows/starting-and-waiting.md | 3 ++ packages/stem/CHANGELOG.md | 4 ++ packages/stem/README.md | 3 ++ packages/stem/lib/src/workflow/core/flow.dart | 5 ++ .../workflow/core/workflow_definition.dart | 14 +++++- .../lib/src/workflow/core/workflow_ref.dart | 25 ++++++++-- .../src/workflow/core/workflow_script.dart | 5 ++ .../workflow/workflow_runtime_ref_test.dart | 49 +++++++++++++++++-- 8 files changed, 98 insertions(+), 10 deletions(-) diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index b49ae5fe..a790cf44 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -77,6 +77,9 @@ If a manual flow or script returns a DTO, prefer `Flow.json(...)` or case. Use `Flow.versionedJson(...)` / `WorkflowScript.versionedJson(...)` when the stored result should carry an explicit schema version. Use `Flow.codec(...)` or `WorkflowScript.codec(...)` when the result needs a custom payload codec. +For manual typed refs, `refVersionedJson(...)` / `WorkflowRef.versionedJson(...)` +also accept `decodeResultVersionedJson:` when the stored result should carry an +explicit schema version. When the persisted workflow result or suspension payload carries an explicit `__stemPayloadVersion`, use `workflowResult.payloadVersionedJson(...)`, `runState.resultVersionedJson(...)`, or diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index ea99c371..5b9ba2ba 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.1 +- Added `decodeResultVersionedJson:` to + `WorkflowRef.versionedJson(...)` / `refVersionedJson(...)` so manual typed + workflow refs can derive version-aware result decoding alongside versioned + params. - Added `decodeResultVersionedJson:` to `TaskDefinition.versionedJson(...)` so argful manual task definitions can derive version-aware result decoding and result-encoder metadata. diff --git a/packages/stem/README.md b/packages/stem/README.md index 36dfc2f1..a39162fa 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -619,6 +619,9 @@ If a manual flow or script only needs DTO result decoding, prefer result needs an explicit schema version. If the final result needs a custom codec, prefer `Flow.codec(...)` or `WorkflowScript.codec(...)` instead of passing `resultCodec:` to the base constructor. +For manual typed refs, `refVersionedJson(...)` / `WorkflowRef.versionedJson(...)` +also accept `decodeResultVersionedJson:` when the stored result should use the +same explicit schema-version decode path. For workflows without start parameters, start directly from the flow or script itself: diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index 28db3dc0..4d5e24ad 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -138,12 +138,17 @@ class Flow { WorkflowRef refVersionedJson({ required int version, T Function(Map payload)? decodeResultJson, + T Function(Map payload, int version)? + decodeResultVersionedJson, + int? defaultDecodeVersion, String? paramsTypeName, String? resultTypeName, }) { return definition.refVersionedJson( version: version, decodeResultJson: decodeResultJson, + decodeResultVersionedJson: decodeResultVersionedJson, + defaultDecodeVersion: defaultDecodeVersion, paramsTypeName: paramsTypeName, resultTypeName: resultTypeName, ); diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index 0a90ce01..4bc4ffcc 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -500,7 +500,9 @@ class WorkflowDefinition { return WorkflowRef.json( name: name, decodeResultJson: decodeResultJson, - decodeResult: (payload) => decodeResult(payload) as T, + decodeResult: decodeResultJson == null + ? (payload) => decodeResult(payload) as T + : null, paramsTypeName: paramsTypeName, resultTypeName: resultTypeName, ); @@ -511,6 +513,9 @@ class WorkflowDefinition { WorkflowRef refVersionedJson({ required int version, T Function(Map payload)? decodeResultJson, + T Function(Map payload, int version)? + decodeResultVersionedJson, + int? defaultDecodeVersion, String? paramsTypeName, String? resultTypeName, }) { @@ -518,7 +523,12 @@ class WorkflowDefinition { name: name, version: version, decodeResultJson: decodeResultJson, - decodeResult: (payload) => decodeResult(payload) as T, + decodeResultVersionedJson: decodeResultVersionedJson, + defaultDecodeVersion: defaultDecodeVersion, + decodeResult: + decodeResultJson == null && decodeResultVersionedJson == null + ? (payload) => decodeResult(payload) as T + : null, paramsTypeName: paramsTypeName, resultTypeName: resultTypeName, ); diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 52cd98d6..5214877a 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -58,16 +58,31 @@ class WorkflowRef { required String name, required int version, TResult Function(Map payload)? decodeResultJson, + TResult Function(Map payload, int version)? + decodeResultVersionedJson, + int? defaultDecodeVersion, TResult Function(Object? payload)? decodeResult, String? paramsTypeName, String? resultTypeName, }) { - final resultCodec = decodeResultJson == null - ? null - : PayloadCodec.json( - decode: decodeResultJson, + assert( + decodeResultJson == null || decodeResultVersionedJson == null, + 'Specify either decodeResultJson or decodeResultVersionedJson, not both.', + ); + final resultCodec = + decodeResultVersionedJson != null + ? PayloadCodec.versionedJson( + version: version, + decode: decodeResultVersionedJson, + defaultDecodeVersion: defaultDecodeVersion, typeName: resultTypeName ?? '$TResult', - ); + ) + : (decodeResultJson == null + ? null + : PayloadCodec.json( + decode: decodeResultJson, + typeName: resultTypeName ?? '$TResult', + )); return WorkflowRef( name: name, encodeParams: (params) => _encodeVersionedJsonParams( diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index 458902a6..d04d9b52 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -148,12 +148,17 @@ class WorkflowScript { WorkflowRef refVersionedJson({ required int version, T Function(Map payload)? decodeResultJson, + T Function(Map payload, int version)? + decodeResultVersionedJson, + int? defaultDecodeVersion, String? paramsTypeName, String? resultTypeName, }) { return definition.refVersionedJson( version: version, decodeResultJson: decodeResultJson, + decodeResultVersionedJson: decodeResultVersionedJson, + defaultDecodeVersion: defaultDecodeVersion, paramsTypeName: paramsTypeName, resultTypeName: resultTypeName, ); diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index f242eb2f..d57b8bc8 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -220,12 +220,12 @@ void main() { test( 'manual workflows can derive json-backed refs with result decoding', () async { - final flow = Flow<_GreetingResult>( + final flow = Flow( name: 'runtime.ref.json.ref-result.flow', build: (builder) { builder.step( 'hello', - (ctx) async => const _GreetingResult(message: 'hello ref json'), + (ctx) async => const {'message': 'hello ref json'}, ); }, ); @@ -243,7 +243,10 @@ void main() { timeout: const Duration(seconds: 2), ); - expect(result?.value?.message, 'hello ref json'); + expect( + (result?.value as _GreetingResult?)?.message, + 'hello ref json', + ); } finally { await workflowApp.shutdown(); } @@ -456,6 +459,46 @@ void main() { }, ); + test( + 'manual workflows can derive versioned-json refs with result decoding', + () async { + final flow = Flow( + name: 'runtime.ref.versioned-json.ref-result.flow', + build: (builder) { + builder.step( + 'hello', + (ctx) async => const { + 'message': 'hello ref result', + PayloadCodec.versionKey: 2, + }, + ); + }, + ); + final workflowRef = flow.refVersionedJson<_GreetingParams>( + version: 2, + decodeResultVersionedJson: _GreetingResult.fromVersionedJson, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + await workflowApp.start(); + + final result = await workflowRef.startAndWait( + workflowApp.runtime, + params: const _GreetingParams(name: 'ignored'), + timeout: const Duration(seconds: 2), + ); + + expect( + (result?.value as _GreetingResult?)?.message, + 'hello ref result v2', + ); + } finally { + await workflowApp.shutdown(); + } + }, + ); + test('manual workflows expose direct no-args helpers', () async { final flow = Flow( name: 'runtime.ref.no-args.flow', From b2fdeffa8e427f30cadebb232fc06267d6a9cf29 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 16:03:46 -0500 Subject: [PATCH 275/302] Add versioned result support to json task definitions --- .site/docs/core-concepts/tasks.md | 3 ++ packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 3 ++ packages/stem/lib/src/core/contracts.dart | 25 +++++++-- .../stem/test/unit/core/stem_core_test.dart | 51 +++++++++++++++++++ 5 files changed, 80 insertions(+), 5 deletions(-) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 1bea7189..266c9f86 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -79,6 +79,9 @@ when you need a custom `PayloadCodec`. Task args still need to encode to a string-keyed map (typically `Map`) because they are published as JSON-shaped data. For low-level name-based enqueue APIs, use `enqueueVersionedJson(...)` for the same versioned DTO path. +If the args stay unversioned but the stored result carries an explicit schema +version, `TaskDefinition.json(...)` also accepts +`decodeResultVersionedJson:` plus `defaultDecodeVersion:`. For manual handlers, prefer the typed payload readers on the argument map instead of repeating raw casts: diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 5b9ba2ba..6164b975 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Added `decodeResultVersionedJson:` to `TaskDefinition.json(...)` so manual + task definitions can keep unversioned DTO args while decoding a + version-aware stored result. - Added `decodeResultVersionedJson:` to `WorkflowRef.versionedJson(...)` / `refVersionedJson(...)` so manual typed workflow refs can derive version-aware result decoding alongside versioned diff --git a/packages/stem/README.md b/packages/stem/README.md index a39162fa..4cfa8dc1 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -226,6 +226,9 @@ explicit `__stemPayloadVersion`. Drop down to `TaskDefinition.codec(...)` only when you need a custom `PayloadCodec`. Task args still need to encode to a string-keyed map (typically `Map`) because they are published as JSON-shaped data. +If the args stay unversioned but the stored result carries an explicit schema +version, `TaskDefinition.json(...)` also accepts +`decodeResultVersionedJson:` plus `defaultDecodeVersion:`. For manual handlers, use the context arg helpers or the typed payload readers on the raw map instead of repeating casts. For workflows, use the context diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 32fdcbf9..4aa70ed9 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2728,15 +2728,30 @@ class TaskDefinition { TaskOptions defaultOptions = const TaskOptions(), TaskMetadata metadata = const TaskMetadata(), TResult Function(Map payload)? decodeResultJson, + TResult Function(Map payload, int version)? + decodeResultVersionedJson, + int? defaultDecodeVersion, String? argsTypeName, String? resultTypeName, }) { - final resultCodec = decodeResultJson == null - ? null - : PayloadCodec.json( - decode: decodeResultJson, + assert( + decodeResultJson == null || decodeResultVersionedJson == null, + 'Specify either decodeResultJson or decodeResultVersionedJson, not both.', + ); + final resultCodec = + decodeResultVersionedJson != null + ? PayloadCodec.versionedJson( + version: defaultDecodeVersion ?? 1, + decode: decodeResultVersionedJson, + defaultDecodeVersion: defaultDecodeVersion, typeName: resultTypeName ?? '$TResult', - ); + ) + : (decodeResultJson == null + ? null + : PayloadCodec.json( + decode: decodeResultJson, + typeName: resultTypeName ?? '$TResult', + )); return TaskDefinition( name: name, encodeArgs: (args) => _encodeJsonArgs(args, argsTypeName ?? '$TArgs'), diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index 7aa787b8..35ee0fdc 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -346,6 +346,30 @@ void main() { }, ); + test( + 'json task definitions can derive versioned result metadata', + () async { + final broker = _RecordingBroker(); + final backend = _RecordingBackend(); + final stem = Stem(broker: broker, backend: backend); + final definition = TaskDefinition<_CodecTaskArgs, _CodecReceipt>.json( + name: 'sample.json.result.versioned', + decodeResultVersionedJson: _CodecReceipt.fromVersionedJson, + defaultDecodeVersion: 2, + ); + + final id = await stem.enqueueCall( + definition.buildCall(const _CodecTaskArgs('encoded')), + ); + + expect( + backend.records.single.meta[stemResultEncoderMetaKey], + endsWith('.result.codec'), + ); + expect(backend.records.single.id, id); + }, + ); + test( 'enqueueCall publishes no-arg definitions without fake empty maps', () async { @@ -730,6 +754,33 @@ void main() { expect(result?.rawPayload, isA>()); }); + test( + 'supports json argful task definitions with versioned results', + () async { + final backend = InMemoryResultBackend(); + final stem = Stem(broker: _RecordingBroker(), backend: backend); + final definition = TaskDefinition<_CodecTaskArgs, _CodecReceipt>.json( + name: 'args.json.versioned.wait', + decodeResultVersionedJson: _CodecReceipt.fromVersionedJson, + defaultDecodeVersion: 2, + ); + + await backend.set( + 'task-args-json-versioned-wait', + TaskState.succeeded, + payload: {'id': 'done', PayloadCodec.versionKey: 2}, + meta: {stemResultEncoderMetaKey: _codecReceiptEncoder.id}, + ); + + final result = await definition.waitFor( + stem, + 'task-args-json-versioned-wait', + ); + + expect(result?.value?.id, 'done-v2'); + expect(result?.rawPayload, isA>()); + }); + test('enqueueAndWait supports no-arg task definitions', () async { final broker = _RecordingBroker(); final backend = _RecordingBackend(); From dc27a96b6fed4ee805c46eeb1ffebd857aebb17c Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 16:05:18 -0500 Subject: [PATCH 276/302] Add versioned result support to json workflow refs --- .site/docs/workflows/starting-and-waiting.md | 4 ++ packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 3 ++ packages/stem/lib/src/workflow/core/flow.dart | 5 +++ .../workflow/core/workflow_definition.dart | 8 +++- .../lib/src/workflow/core/workflow_ref.dart | 25 ++++++++--- .../src/workflow/core/workflow_script.dart | 5 +++ .../workflow/workflow_runtime_ref_test.dart | 41 +++++++++++++++++++ 8 files changed, 88 insertions(+), 6 deletions(-) diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index a790cf44..623824c0 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -72,6 +72,10 @@ Inside manual flow steps and script checkpoints, prefer `ctx.previousValue()` / `ctx.requiredPreviousValue()` over repeating raw `previousResult as ...` casts. +If the params stay unversioned but the stored result carries an explicit schema +version, `refJson(...)` / `WorkflowRef.json(...)` also accept +`decodeResultVersionedJson:` plus `defaultDecodeVersion:`. + If a manual flow or script returns a DTO, prefer `Flow.json(...)` or `WorkflowScript.json(...)` in the common `toJson()` / `Type.fromJson(...)` case. Use `Flow.versionedJson(...)` / `WorkflowScript.versionedJson(...)` when diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 6164b975..7495479c 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Added `decodeResultVersionedJson:` to + `WorkflowRef.json(...)` / `refJson(...)` so manual typed workflow refs can + keep unversioned params while decoding a version-aware stored result. - Added `decodeResultVersionedJson:` to `TaskDefinition.json(...)` so manual task definitions can keep unversioned DTO args while decoding a version-aware stored result. diff --git a/packages/stem/README.md b/packages/stem/README.md index 4cfa8dc1..de174468 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -615,6 +615,9 @@ to evolve and the persisted params should store `__stemPayloadVersion`. Drop down to `refCodec(...)` when you need a custom `PayloadCodec`. Workflow params still need to encode to a string-keyed map (typically `Map`) because they are persisted as JSON-shaped data. +If the params stay unversioned but the stored result carries an explicit schema +version, `refJson(...)` / `WorkflowRef.json(...)` also accept +`decodeResultVersionedJson:` plus `defaultDecodeVersion:`. If a manual flow or script only needs DTO result decoding, prefer `Flow.json(...)` or `WorkflowScript.json(...)`. Use diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index 4d5e24ad..a31ec36a 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -123,11 +123,16 @@ class Flow { /// `toJson()`. WorkflowRef refJson({ T Function(Map payload)? decodeResultJson, + T Function(Map payload, int version)? + decodeResultVersionedJson, + int? defaultDecodeVersion, String? paramsTypeName, String? resultTypeName, }) { return definition.refJson( decodeResultJson: decodeResultJson, + decodeResultVersionedJson: decodeResultVersionedJson, + defaultDecodeVersion: defaultDecodeVersion, paramsTypeName: paramsTypeName, resultTypeName: resultTypeName, ); diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index 4bc4ffcc..d788b9a4 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -494,13 +494,19 @@ class WorkflowDefinition { /// `toJson()`. WorkflowRef refJson({ T Function(Map payload)? decodeResultJson, + T Function(Map payload, int version)? + decodeResultVersionedJson, + int? defaultDecodeVersion, String? paramsTypeName, String? resultTypeName, }) { return WorkflowRef.json( name: name, decodeResultJson: decodeResultJson, - decodeResult: decodeResultJson == null + decodeResultVersionedJson: decodeResultVersionedJson, + defaultDecodeVersion: defaultDecodeVersion, + decodeResult: + decodeResultJson == null && decodeResultVersionedJson == null ? (payload) => decodeResult(payload) as T : null, paramsTypeName: paramsTypeName, diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 5214877a..147356ce 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -34,16 +34,31 @@ class WorkflowRef { factory WorkflowRef.json({ required String name, TResult Function(Map payload)? decodeResultJson, + TResult Function(Map payload, int version)? + decodeResultVersionedJson, + int? defaultDecodeVersion, TResult Function(Object? payload)? decodeResult, String? paramsTypeName, String? resultTypeName, }) { - final resultCodec = decodeResultJson == null - ? null - : PayloadCodec.json( - decode: decodeResultJson, + assert( + decodeResultJson == null || decodeResultVersionedJson == null, + 'Specify either decodeResultJson or decodeResultVersionedJson, not both.', + ); + final resultCodec = + decodeResultVersionedJson != null + ? PayloadCodec.versionedJson( + version: defaultDecodeVersion ?? 1, + decode: decodeResultVersionedJson, + defaultDecodeVersion: defaultDecodeVersion, typeName: resultTypeName ?? '$TResult', - ); + ) + : (decodeResultJson == null + ? null + : PayloadCodec.json( + decode: decodeResultJson, + typeName: resultTypeName ?? '$TResult', + )); return WorkflowRef( name: name, encodeParams: (params) => diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index d04d9b52..5bcadeff 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -133,11 +133,16 @@ class WorkflowScript { /// `toJson()`. WorkflowRef refJson({ T Function(Map payload)? decodeResultJson, + T Function(Map payload, int version)? + decodeResultVersionedJson, + int? defaultDecodeVersion, String? paramsTypeName, String? resultTypeName, }) { return definition.refJson( decodeResultJson: decodeResultJson, + decodeResultVersionedJson: decodeResultVersionedJson, + defaultDecodeVersion: defaultDecodeVersion, paramsTypeName: paramsTypeName, resultTypeName: resultTypeName, ); diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index d57b8bc8..544f46e4 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -253,6 +253,47 @@ void main() { }, ); + test( + 'manual workflows can derive json-backed refs with versioned result' + ' decoding', + () async { + final flow = Flow( + name: 'runtime.ref.json.versioned-result.flow', + build: (builder) { + builder.step( + 'hello', + (ctx) async => const { + 'message': 'hello ref json versioned', + PayloadCodec.versionKey: 2, + }, + ); + }, + ); + final workflowRef = flow.refJson<_GreetingParams>( + decodeResultVersionedJson: _GreetingResult.fromVersionedJson, + defaultDecodeVersion: 2, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + await workflowApp.start(); + + final result = await workflowRef.startAndWait( + workflowApp.runtime, + params: const _GreetingParams(name: 'ignored'), + timeout: const Duration(seconds: 2), + ); + + expect( + (result?.value as _GreetingResult?)?.message, + 'hello ref json versioned v2', + ); + } finally { + await workflowApp.shutdown(); + } + }, + ); + test('codec-backed refs preserve workflow result decoding', () async { final flow = Flow<_GreetingResult>.codec( name: 'runtime.ref.codec.result.flow', From 60e1763ecaaad3d40bf1c1f26b03552d0958b1ba Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 16:07:20 -0500 Subject: [PATCH 277/302] Refresh stem_builder transport examples --- packages/stem_builder/README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index a459d4d0..6f99afa2 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -115,8 +115,8 @@ Child workflows should be started from durable boundaries: - `ref.startAndWait(context, params: value)` inside script checkpoints - pass `ttl:`, `parentRunId:`, or `cancellationPolicy:` directly to `ref.start(...)` / `ref.startAndWait(...)` for the normal override cases -- keep `context.prepareStart(...)` for the rarer incremental-call - cases where you actually want to build the start request step by step +- build an explicit transport request with `ref.buildStart(...)` only for the + rarer low-level cases where you need to pass a `WorkflowStartCall` around Avoid starting child workflows directly from the raw `WorkflowScriptContext` body unless you are explicitly handling replay @@ -281,9 +281,10 @@ await workflowApp.executeRun(runId); Annotated tasks also get generated definitions: ```dart -final taskId = await StemTaskDefinitions.builderExampleTask - .call(const {'kind': 'welcome'}) - .enqueue(workflowApp); +final taskId = await StemTaskDefinitions.builderExampleTask.enqueue( + workflowApp, + const {'kind': 'welcome'}, +); ``` ## Examples From 54006d0647e8848fcd7407edbf99950485b60eed Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 16:09:04 -0500 Subject: [PATCH 278/302] Add versioned custom map payload codec --- .../workflows/context-and-serialization.md | 2 + packages/stem/CHANGELOG.md | 2 + packages/stem/README.md | 2 + packages/stem/lib/src/core/payload_codec.dart | 29 ++++++++ .../test/unit/core/payload_codec_test.dart | 67 +++++++++++++++++++ 5 files changed, 102 insertions(+) diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index 996a3305..810abd82 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -153,6 +153,8 @@ If the DTO payload shape is expected to evolve, use `PayloadCodec.versionedJson(...)`. That persists a reserved `__stemPayloadVersion` field beside the JSON payload and gives the decoder the stored version so it can read older shapes explicitly. +Use `PayloadCodec.versionedMap(...)` instead when the payload still needs a +custom map encoder or a nonstandard version-aware decode function. For manual flows and scripts, prefer the typed workflow param helpers before dropping to raw map casts: diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 7495479c..8f6c922f 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -129,6 +129,8 @@ require dropping down to raw payload-map helpers. - Added `PayloadCodec.versionedJson(...)` so DTO payload codecs can persist a schema version beside the JSON payload and decode older shapes explicitly. +- Added `PayloadCodec.versionedMap(...)` for versioned DTO payloads that still + need a custom map encoder or a nonstandard version-aware decode shape. - Added versioned low-level DTO shortcuts: `TaskEnqueuer.enqueueVersionedJson(...)`, `WorkflowRuntime.startWorkflowVersionedJson(...)`, diff --git a/packages/stem/README.md b/packages/stem/README.md index de174468..600c820e 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -300,6 +300,8 @@ When a DTO payload needs an explicit persisted schema version, prefer `PayloadCodec.versionedJson(...)`. It stores `__stemPayloadVersion` beside the JSON payload and passes the persisted version into the decoder so you can keep older payloads readable while newer producers emit the latest shape. +Use `PayloadCodec.versionedMap(...)` instead when the payload still needs a +custom map encoder or a nonstandard version-aware decode shape. The same pattern now carries through the low-level readback helpers: `status.payloadVersionedJson(...)`, `result.payloadVersionedJson(...)`, `workflowResult.payloadVersionedJson(...)`, and diff --git a/packages/stem/lib/src/core/payload_codec.dart b/packages/stem/lib/src/core/payload_codec.dart index 569d8b86..11b65ee4 100644 --- a/packages/stem/lib/src/core/payload_codec.dart +++ b/packages/stem/lib/src/core/payload_codec.dart @@ -40,6 +40,35 @@ class PayloadCodec { _defaultDecodeVersion = null, _typeName = typeName; + /// Creates a payload codec for map-backed DTO payloads that also persist a + /// schema [version]. + /// + /// Use this when a payload shape is expected to evolve over time and the + /// decoder needs the stored schema version, but the payload still uses a + /// custom map encoder or a nonstandard decode shape: + /// + /// ```dart + /// const approvalCodec = PayloadCodec.versionedMap( + /// encode: (value) => value.toLegacyMap(), + /// version: 2, + /// defaultDecodeVersion: 1, + /// decode: Approval.fromVersionedMap, + /// ); + /// ``` + const PayloadCodec.versionedMap({ + required Object? Function(T value) encode, + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) : _encode = encode, + _decode = null, + _decodeMap = null, + _decodeVersionedMap = decode, + _jsonVersion = version, + _defaultDecodeVersion = defaultDecodeVersion, + _typeName = typeName; + /// Creates a payload codec for DTOs that expose `toJson()` and a matching /// typed decoder like `Type.fromJson(...)`. /// diff --git a/packages/stem/test/unit/core/payload_codec_test.dart b/packages/stem/test/unit/core/payload_codec_test.dart index 758ebbc1..f84747b7 100644 --- a/packages/stem/test/unit/core/payload_codec_test.dart +++ b/packages/stem/test/unit/core/payload_codec_test.dart @@ -228,6 +228,68 @@ void main() { ); }); }); + + group('PayloadCodec.versionedMap', () { + test('encodes custom map payloads with a persisted schema version', () { + const codec = PayloadCodec<_VersionedCodecPayload>.versionedMap( + encode: _encodeVersionedCodecPayloadMap, + version: 4, + decode: _VersionedCodecPayload.fromVersionedJson, + typeName: '_VersionedCodecPayload', + ); + + final payload = codec.encode( + const _VersionedCodecPayload(id: 'payload-map-v0', count: 12), + ); + + expect(payload, { + PayloadCodec.versionKey: 4, + 'id': 'payload-map-v0', + 'count': 12, + 'legacy': true, + }); + }); + + test('passes the stored schema version to the custom decoder', () { + const codec = PayloadCodec<_VersionedCodecPayload>.versionedMap( + encode: _encodeVersionedCodecPayloadMap, + version: 2, + decode: _VersionedCodecPayload.fromVersionedJson, + typeName: '_VersionedCodecPayload', + ); + + final decoded = codec.decode({ + PayloadCodec.versionKey: 7, + 'id': 'payload-map-v1', + 'count': 5, + 'legacy': true, + }); + + expect(decoded.id, 'payload-map-v1'); + expect(decoded.count, 5); + expect(decoded.decodedVersion, 7); + }); + + test('falls back to the configured default decode version', () { + const codec = PayloadCodec<_VersionedCodecPayload>.versionedMap( + encode: _encodeVersionedCodecPayloadMap, + version: 3, + defaultDecodeVersion: 1, + decode: _VersionedCodecPayload.fromVersionedJson, + typeName: '_VersionedCodecPayload', + ); + + final decoded = codec.decode({ + 'id': 'payload-map-v2', + 'count': 14, + 'legacy': true, + }); + + expect(decoded.id, 'payload-map-v2'); + expect(decoded.count, 14); + expect(decoded.decodedVersion, 1); + }); + }); } class _CodecPayload { @@ -270,6 +332,11 @@ class _DynamicCodecPayload { Object? _encodeCodecPayload(_CodecPayload value) => value.toJson(); +Object? _encodeVersionedCodecPayloadMap(_VersionedCodecPayload value) => { + ...value.toJson(), + 'legacy': true, +}; + class _NoJsonPayload { const _NoJsonPayload({required this.id}); From fbfde9b29e912323a16c4e69eeaf4fdf4e4eef53 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 16:19:58 -0500 Subject: [PATCH 279/302] Add codec-backed low-level task enqueue --- .site/docs/core-concepts/producer.md | 5 +- packages/stem/CHANGELOG.md | 9 +- packages/stem/README.md | 24 +++-- packages/stem/lib/src/bootstrap/stem_app.dart | 25 ++++++ .../stem/lib/src/bootstrap/stem_client.dart | 24 +++++ .../stem/lib/src/bootstrap/workflow_app.dart | 23 +++++ packages/stem/lib/src/core/contracts.dart | 89 ++++++++++++++----- packages/stem/lib/src/core/stem.dart | 51 +++++++++++ .../stem/lib/src/core/task_invocation.dart | 73 +++++++++++++++ .../lib/src/workflow/core/flow_context.dart | 51 +++++++++++ .../workflow/runtime/workflow_runtime.dart | 73 +++++++++++++++ .../unit/core/task_context_enqueue_test.dart | 83 +++++++++++++++++ .../unit/core/task_enqueue_builder_test.dart | 14 +++ .../test/unit/core/task_invocation_test.dart | 40 +++++++++ .../test/unit/workflow/flow_context_test.dart | 40 +++++++++ .../unit/workflow/workflow_resume_test.dart | 67 ++++++++++++++ 16 files changed, 655 insertions(+), 36 deletions(-) diff --git a/.site/docs/core-concepts/producer.md b/.site/docs/core-concepts/producer.md index 887fc2b1..f6e4b9a1 100644 --- a/.site/docs/core-concepts/producer.md +++ b/.site/docs/core-concepts/producer.md @@ -58,8 +58,9 @@ transport path. Raw task-name strings still work, but they are the lower-level interop path. Reach for them when the task name is truly dynamic or you are crossing a boundary that does not have the generated/manual `TaskDefinition`. When those -calls already have DTO args, prefer `enqueuer.enqueueJson(...)` over -hand-building an `args` map. +calls already have typed DTO args, prefer +`enqueuer.enqueueValue(name, dto, codec: ...)` over hand-building an `args` +map. If you later inspect the raw `Envelope`, prefer `envelope.argsJson(...)`, `envelope.argsVersionedJson(...)`, `envelope.metaJson(...)`, or `envelope.metaVersionedJson(...)` over manual map casts. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 8f6c922f..b069692b 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -131,6 +131,9 @@ schema version beside the JSON payload and decode older shapes explicitly. - Added `PayloadCodec.versionedMap(...)` for versioned DTO payloads that still need a custom map encoder or a nonstandard version-aware decode shape. +- Added `TaskEnqueuer.enqueueValue(...)` across producers and task/workflow + execution contexts so dynamic task names can still enqueue typed DTO payloads + through an explicit `PayloadCodec` without hand-built arg maps. - Added versioned low-level DTO shortcuts: `TaskEnqueuer.enqueueVersionedJson(...)`, `WorkflowRuntime.startWorkflowVersionedJson(...)`, @@ -157,10 +160,10 @@ `RunState.resultVersionedJson(...)`, and `RunState.suspensionPayloadVersionedJson(...)`. - Added low-level DTO shortcuts for name-based dispatch: - `TaskEnqueuer.enqueueJson(...)`, `WorkflowRuntime.startWorkflowJson(...)`, + `WorkflowRuntime.startWorkflowJson(...)`, `StemWorkflowApp.startWorkflowJson(...)`, `WorkflowRuntime.emitJson(...)`, - and `StemWorkflowApp.emitJson(...)`, so dynamic task, workflow, and - workflow-event names can still use `toJson()` inputs without hand-built + and `StemWorkflowApp.emitJson(...)`, so dynamic workflow names and + workflow-event topics can still use `toJson()` inputs without hand-built maps. - Added `QueueEventsProducer.emitJson(...)` so queue-scoped custom events can publish DTO payloads without hand-built maps. diff --git a/packages/stem/README.md b/packages/stem/README.md index 600c820e..bcf44814 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -147,7 +147,13 @@ Future main() async { final worker = await client.createWorker(); unawaited(worker.start()); - await client.enqueueJson('demo.hello', const HelloArgs(name: 'Stem')); + await client.enqueueValue( + 'demo.hello', + const HelloArgs(name: 'Stem'), + codec: const PayloadCodec.json( + decode: HelloArgs.fromJson, + ), + ); await Future.delayed(const Duration(seconds: 1)); await worker.shutdown(); await client.close(); @@ -535,9 +541,9 @@ The runtime shape is the same in every case: - bootstrap a `StemWorkflowApp` - pass `flows:`, `scripts:`, and `tasks:` directly - start runs with direct workflow helpers or generated workflow refs -- use `startWorkflow(...)` / `startWorkflowJson(...)` / `emitJson(...)` / - `waitForCompletion(...)` when names come from config, CLI input, or other - dynamic sources +- use `enqueueValue(...)`, `startWorkflow(...)` / `startWorkflowJson(...)`, + `emitJson(...)`, and `waitForCompletion(...)` when names come from config, + CLI input, or other dynamic sources You do not need to build task registries manually for normal workflow usage. @@ -1349,11 +1355,11 @@ final runId = await workflowApp.startWorkflow( ``` When those low-level name-based paths already have DTO inputs, prefer -`client.enqueueJson(...)` / `client.enqueueVersionedJson(...)` and -`workflowApp.startWorkflowJson(...)` / `workflowApp.startWorkflowVersionedJson(...)` -over hand-built map payloads. Use the versioned forms when the DTO schema is -expected to evolve and you want the payload to persist an explicit -`__stemPayloadVersion`. +`client.enqueueValue(...)` plus `workflowApp.startWorkflowJson(...)` or +`workflowApp.startWorkflowVersionedJson(...)` over hand-built map payloads. +Use `PayloadCodec.versionedJson(...)` with `enqueueValue(...)`, or the +workflow-specific versioned helpers, when the DTO schema is expected to evolve +and you want the payload to persist an explicit `__stemPayloadVersion`. Adapter packages expose typed factories (e.g. `redisBrokerFactory`, `postgresResultBackendFactory`, `sqliteWorkflowStoreFactory`) so you can replace diff --git a/packages/stem/lib/src/bootstrap/stem_app.dart b/packages/stem/lib/src/bootstrap/stem_app.dart index 2e788365..ded9fb38 100644 --- a/packages/stem/lib/src/bootstrap/stem_app.dart +++ b/packages/stem/lib/src/bootstrap/stem_app.dart @@ -8,6 +8,7 @@ import 'package:stem/src/bootstrap/stem_stack.dart'; import 'package:stem/src/canvas/canvas.dart'; import 'package:stem/src/control/revoke_store.dart'; import 'package:stem/src/core/contracts.dart'; +import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/core/stem.dart'; import 'package:stem/src/core/task_payload_encoder.dart'; import 'package:stem/src/core/task_result.dart'; @@ -116,6 +117,30 @@ class StemApp implements StemTaskApp { ); } + @override + Future enqueueValue( + String name, + T value, { + PayloadCodec? codec, + Map headers = const {}, + TaskOptions options = const TaskOptions(), + DateTime? notBefore, + Map meta = const {}, + TaskEnqueueOptions? enqueueOptions, + }) async { + await _ensureStarted(); + return stem.enqueueValue( + name, + value, + codec: codec, + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ); + } + @override Future enqueueCall( TaskCall call, { diff --git a/packages/stem/lib/src/bootstrap/stem_client.dart b/packages/stem/lib/src/bootstrap/stem_client.dart index cceac542..c3abe1db 100644 --- a/packages/stem/lib/src/bootstrap/stem_client.dart +++ b/packages/stem/lib/src/bootstrap/stem_client.dart @@ -5,6 +5,7 @@ import 'package:stem/src/bootstrap/stem_stack.dart'; import 'package:stem/src/bootstrap/workflow_app.dart'; import 'package:stem/src/canvas/canvas.dart'; import 'package:stem/src/core/contracts.dart'; +import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/core/stem.dart'; import 'package:stem/src/core/task_payload_encoder.dart'; import 'package:stem/src/core/task_result.dart'; @@ -176,6 +177,29 @@ abstract class StemClient implements TaskResultCaller { ); } + @override + Future enqueueValue( + String name, + T value, { + PayloadCodec? codec, + Map headers = const {}, + TaskOptions options = const TaskOptions(), + DateTime? notBefore, + Map meta = const {}, + TaskEnqueueOptions? enqueueOptions, + }) { + return stem.enqueueValue( + name, + value, + codec: codec, + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ); + } + @override Future getTaskStatus(String taskId) { return stem.getTaskStatus(taskId); diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index 489b7618..a0fd73f0 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -107,6 +107,29 @@ class StemWorkflowApp ); } + @override + Future enqueueValue( + String name, + T value, { + PayloadCodec? codec, + Map headers = const {}, + TaskOptions options = const TaskOptions(), + DateTime? notBefore, + Map meta = const {}, + TaskEnqueueOptions? enqueueOptions, + }) { + return app.enqueueValue( + name, + value, + codec: codec, + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ); + } + @override Future enqueueCall( TaskCall call, { diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 4aa70ed9..b593841b 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -1910,6 +1910,21 @@ abstract class TaskEnqueuer { TaskEnqueueOptions? enqueueOptions, }); + /// Enqueue a dynamic-name task using a typed value plus optional [codec]. + /// + /// When [codec] is omitted, [value] must already be a string-keyed durable + /// map payload. + Future enqueueValue( + String name, + T value, { + PayloadCodec? codec, + Map headers, + TaskOptions options, + DateTime? notBefore, + Map meta, + TaskEnqueueOptions? enqueueOptions, + }); + /// Enqueue a typed task call. Future enqueueCall( TaskCall call, { @@ -1917,6 +1932,35 @@ abstract class TaskEnqueuer { }); } +Map _encodeEnqueuedValue( + String taskName, + T value, { + PayloadCodec? codec, +}) { + final payload = codec == null ? value : codec.encode(value); + if (payload is Map) { + return Map.from(payload); + } + if (payload is Map) { + final normalized = {}; + for (final entry in payload.entries) { + final key = entry.key; + if (key is! String) { + throw StateError( + 'Task payload for $taskName must use string keys, got ' + '${key.runtimeType}.', + ); + } + normalized[key] = entry.value; + } + return normalized; + } + throw StateError( + 'Task payload for $taskName must encode to Map, got ' + '${payload.runtimeType}.', + ); +} + /// Provides ambient metadata for task enqueue operations. /// /// Use [run] to scope workflow or tracing metadata so `Stem.enqueue` can @@ -2389,6 +2433,28 @@ class TaskContext implements TaskExecutionContext { ); } + @override + Future enqueueValue( + String name, + T value, { + PayloadCodec? codec, + Map headers = const {}, + TaskOptions options = const TaskOptions(), + DateTime? notBefore, + Map meta = const {}, + TaskEnqueueOptions? enqueueOptions, + }) { + return enqueue( + name, + args: _encodeEnqueuedValue(name, value, codec: codec), + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ); + } + /// Enqueue a typed call with default context propagation. /// /// This merges headers/meta from the task call and applies lineage metadata @@ -2920,28 +2986,7 @@ class TaskDefinition { PayloadCodec codec, T args, ) { - final payload = codec.encode(args); - if (payload is Map) { - return Map.from(payload); - } - if (payload is Map) { - final normalized = {}; - for (final entry in payload.entries) { - final key = entry.key; - if (key is! String) { - throw StateError( - 'TaskDefinition.codec($taskName) requires payload keys ' - 'to be strings, got ${key.runtimeType}.', - ); - } - normalized[key] = entry.value; - } - return normalized; - } - throw StateError( - 'TaskDefinition.codec($taskName) must encode args to ' - 'Map, got ${payload.runtimeType}.', - ); + return _encodeEnqueuedValue(taskName, args, codec: codec); } static Map _encodeJsonArgs(T args, String typeName) { diff --git a/packages/stem/lib/src/core/stem.dart b/packages/stem/lib/src/core/stem.dart index 07ac8012..91fcd010 100644 --- a/packages/stem/lib/src/core/stem.dart +++ b/packages/stem/lib/src/core/stem.dart @@ -244,6 +244,28 @@ class Stem implements TaskResultCaller { ); } + @override + Future enqueueValue( + String name, + T value, { + PayloadCodec? codec, + Map headers = const {}, + TaskOptions options = const TaskOptions(), + DateTime? notBefore, + Map meta = const {}, + TaskEnqueueOptions? enqueueOptions, + }) { + return enqueue( + name, + args: _encodeStemTaskValue(name, value, codec: codec), + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ); + } + Future _enqueueResolved({ required String name, required Map args, @@ -1083,6 +1105,35 @@ class Stem implements TaskResultCaller { } } +Map _encodeStemTaskValue( + String name, + T value, { + PayloadCodec? codec, +}) { + final payload = codec == null ? value : codec.encode(value); + if (payload is Map) { + return Map.from(payload); + } + if (payload is Map) { + final normalized = {}; + for (final entry in payload.entries) { + final key = entry.key; + if (key is! String) { + throw StateError( + 'Task payload for $name must use string keys, got ' + '${key.runtimeType}.', + ); + } + normalized[key] = entry.value; + } + return normalized; + } + throw StateError( + 'Task payload for $name must encode to Map, got ' + '${payload.runtimeType}.', + ); +} + Future _enqueueBuiltTaskCall( TaskEnqueuer enqueuer, TaskCall call, { diff --git a/packages/stem/lib/src/core/task_invocation.dart b/packages/stem/lib/src/core/task_invocation.dart index 47a4926a..834eb4e0 100644 --- a/packages/stem/lib/src/core/task_invocation.dart +++ b/packages/stem/lib/src/core/task_invocation.dart @@ -722,6 +722,28 @@ class TaskInvocationContext implements TaskExecutionContext { ); } + @override + Future enqueueValue( + String name, + T value, { + PayloadCodec? codec, + Map headers = const {}, + TaskOptions options = const TaskOptions(), + DateTime? notBefore, + Map meta = const {}, + TaskEnqueueOptions? enqueueOptions, + }) { + return enqueue( + name, + args: _encodeInvocationEnqueuedValue(name, value, codec: codec), + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ); + } + /// Enqueue a typed task call from within a task invocation. /// /// This merges headers/meta from the task call and applies lineage metadata @@ -955,6 +977,57 @@ class _RemoteTaskEnqueuer implements TaskEnqueuer { enqueueOptions: enqueueOptions ?? call.enqueueOptions, ); } + + @override + Future enqueueValue( + String name, + T value, { + PayloadCodec? codec, + Map headers = const {}, + TaskOptions options = const TaskOptions(), + DateTime? notBefore, + Map meta = const {}, + TaskEnqueueOptions? enqueueOptions, + }) { + return enqueue( + name, + args: _encodeInvocationEnqueuedValue(name, value, codec: codec), + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ); + } +} + +Map _encodeInvocationEnqueuedValue( + String name, + T value, { + PayloadCodec? codec, +}) { + final payload = codec == null ? value : codec.encode(value); + if (payload is Map) { + return Map.from(payload); + } + if (payload is Map) { + final normalized = {}; + for (final entry in payload.entries) { + final key = entry.key; + if (key is! String) { + throw StateError( + 'Task payload for $name must use string keys, got ' + '${key.runtimeType}.', + ); + } + normalized[key] = entry.value; + } + return normalized; + } + throw StateError( + 'Task payload for $name must encode to Map, got ' + '${payload.runtimeType}.', + ); } class _RemoteWorkflowCaller implements WorkflowCaller { diff --git a/packages/stem/lib/src/workflow/core/flow_context.dart b/packages/stem/lib/src/workflow/core/flow_context.dart index b39266ca..fb1b3426 100644 --- a/packages/stem/lib/src/workflow/core/flow_context.dart +++ b/packages/stem/lib/src/workflow/core/flow_context.dart @@ -75,6 +75,28 @@ class FlowContext implements WorkflowExecutionContext { FlowStepControl? _control; Object? _resumeData; + @override + Future enqueueValue( + String name, + T value, { + PayloadCodec? codec, + Map headers = const {}, + TaskOptions options = const TaskOptions(), + DateTime? notBefore, + Map meta = const {}, + TaskEnqueueOptions? enqueueOptions, + }) { + return enqueue( + name, + args: _encodeFlowContextValue(name, value, codec: codec), + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ); + } + /// Suspends the workflow until the delay elapses. /// /// After the delay, the worker replays the **same step** from the top. To @@ -344,3 +366,32 @@ class FlowContext implements WorkflowExecutionContext { ); } } + +Map _encodeFlowContextValue( + String name, + T value, { + PayloadCodec? codec, +}) { + final payload = codec == null ? value : codec.encode(value); + if (payload is Map) { + return Map.from(payload); + } + if (payload is Map) { + final normalized = {}; + for (final entry in payload.entries) { + final key = entry.key; + if (key is! String) { + throw StateError( + 'Task payload for $name must use string keys, got ' + '${key.runtimeType}.', + ); + } + normalized[key] = entry.value; + } + return normalized; + } + throw StateError( + 'Task payload for $name must encode to Map, got ' + '${payload.runtimeType}.', + ); +} diff --git a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart index f60cbf47..65ad2d06 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart @@ -1919,6 +1919,28 @@ class _WorkflowScriptStepContextImpl implements WorkflowScriptStepContext { _ScriptControl? _control; Object? _resumeData; + @override + Future enqueueValue( + String name, + T value, { + PayloadCodec? codec, + Map headers = const {}, + TaskOptions options = const TaskOptions(), + DateTime? notBefore, + Map meta = const {}, + TaskEnqueueOptions? enqueueOptions, + }) { + return enqueue( + name, + args: _encodeWorkflowStepValue(name, value, codec: codec), + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ); + } + /// Consumes any control signal emitted by the step. _ScriptControl? takeControl() { final value = _control; @@ -2181,6 +2203,57 @@ class _WorkflowStepEnqueuer implements TaskEnqueuer { enqueueOptions: enqueueOptions, ); } + + @override + Future enqueueValue( + String name, + T value, { + PayloadCodec? codec, + Map headers = const {}, + TaskOptions options = const TaskOptions(), + DateTime? notBefore, + Map meta = const {}, + TaskEnqueueOptions? enqueueOptions, + }) { + return enqueue( + name, + args: _encodeWorkflowStepValue(name, value, codec: codec), + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ); + } +} + +Map _encodeWorkflowStepValue( + String name, + T value, { + PayloadCodec? codec, +}) { + final payload = codec == null ? value : codec.encode(value); + if (payload is Map) { + return Map.from(payload); + } + if (payload is Map) { + final normalized = {}; + for (final entry in payload.entries) { + final key = entry.key; + if (key is! String) { + throw StateError( + 'Task payload for $name must use string keys, got ' + '${key.runtimeType}.', + ); + } + normalized[key] = entry.value; + } + return normalized; + } + throw StateError( + 'Task payload for $name must encode to Map, got ' + '${payload.runtimeType}.', + ); } class _ChildWorkflowCaller implements WorkflowCaller { diff --git a/packages/stem/test/unit/core/task_context_enqueue_test.dart b/packages/stem/test/unit/core/task_context_enqueue_test.dart index 24c1ad99..013e6ff2 100644 --- a/packages/stem/test/unit/core/task_context_enqueue_test.dart +++ b/packages/stem/test/unit/core/task_context_enqueue_test.dart @@ -136,6 +136,35 @@ void main() { expect(enqueuer.records.single.args, equals({'value': 42})); }); + test('enqueueValue encodes typed payloads through the supplied codec', () async { + final enqueuer = _RecordingEnqueuer(); + final context = TaskContext( + id: 'parent-3b', + attempt: 1, + headers: const {'x-trace-id': 'trace-2'}, + meta: const {'tenant': 'acme'}, + heartbeat: () {}, + extendLease: (_) async {}, + progress: (_, {data}) async {}, + enqueuer: enqueuer, + ); + + await context.enqueueValue( + 'tasks.child', + const _InvitePayload(email: 'ops@example.com'), + codec: const PayloadCodec<_InvitePayload>.json( + decode: _InvitePayload.fromJson, + typeName: '_InvitePayload', + ), + ); + + final record = enqueuer.last!; + expect(record.args, equals({'email': 'ops@example.com'})); + expect(record.meta[_parentTaskIdKey], equals('parent-3b')); + expect(record.meta[_parentAttemptKey], equals(1)); + expect(record.headers['x-trace-id'], equals('trace-2')); + }); + test('merges headers/meta overrides with defaults', () async { final enqueuer = _RecordingEnqueuer(); final context = TaskContext( @@ -470,6 +499,60 @@ class _RecordingEnqueuer implements TaskEnqueuer { enqueueOptions: enqueueOptions, ); } + + @override + Future enqueueValue( + String name, + T value, { + PayloadCodec? codec, + Map headers = const {}, + TaskOptions options = const TaskOptions(), + DateTime? notBefore, + Map meta = const {}, + TaskEnqueueOptions? enqueueOptions, + }) { + return enqueue( + name, + args: _encodeTestTaskArgs(name, value, codec: codec), + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ); + } +} + +Map _encodeTestTaskArgs( + String name, + T value, { + PayloadCodec? codec, +}) { + final payload = codec == null ? value : codec.encode(value); + if (payload is Map) { + return Map.from(payload); + } + if (payload is Map) { + return payload.map( + (key, value) => MapEntry(key.toString(), value), + ); + } + throw StateError( + 'Task payload for $name must encode to Map, got ' + '${payload.runtimeType}.', + ); +} + +class _InvitePayload { + const _InvitePayload({required this.email}); + + factory _InvitePayload.fromJson(Map json) { + return _InvitePayload(email: json['email']! as String); + } + + final String email; + + Map toJson() => {'email': email}; } class _RecordingWorkflowCaller implements WorkflowCaller { diff --git a/packages/stem/test/unit/core/task_enqueue_builder_test.dart b/packages/stem/test/unit/core/task_enqueue_builder_test.dart index 6ff0dcc5..28ea5f06 100644 --- a/packages/stem/test/unit/core/task_enqueue_builder_test.dart +++ b/packages/stem/test/unit/core/task_enqueue_builder_test.dart @@ -232,6 +232,20 @@ class _RecordingTaskEnqueuer implements TaskEnqueuer { lastCall = call; return 'task-1'; } + + @override + Future enqueueValue( + String name, + T value, { + PayloadCodec? codec, + Map headers = const {}, + TaskOptions options = const TaskOptions(), + DateTime? notBefore, + Map meta = const {}, + TaskEnqueueOptions? enqueueOptions, + }) { + throw UnimplementedError('enqueueValue is not used in this test'); + } } class _RecordingTaskResultCaller extends _RecordingTaskEnqueuer diff --git a/packages/stem/test/unit/core/task_invocation_test.dart b/packages/stem/test/unit/core/task_invocation_test.dart index b9c912b0..735ba22a 100644 --- a/packages/stem/test/unit/core/task_invocation_test.dart +++ b/packages/stem/test/unit/core/task_invocation_test.dart @@ -47,6 +47,46 @@ class _CapturingEnqueuer implements TaskEnqueuer { lastOptions = enqueueOptions; return _taskId; } + + @override + Future enqueueValue( + String name, + T value, { + PayloadCodec? codec, + Map headers = const {}, + TaskOptions options = const TaskOptions(), + DateTime? notBefore, + Map meta = const {}, + TaskEnqueueOptions? enqueueOptions, + }) { + return enqueue( + name, + args: _encodeInvocationTaskArgs(name, value, codec: codec), + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ); + } +} + +Map _encodeInvocationTaskArgs( + String name, + T value, { + PayloadCodec? codec, +}) { + final payload = codec == null ? value : codec.encode(value); + if (payload is Map) { + return Map.from(payload); + } + if (payload is Map) { + return payload.map((key, value) => MapEntry(key.toString(), value)); + } + throw StateError( + 'Task payload for $name must encode to Map, got ' + '${payload.runtimeType}.', + ); } class _CapturingWorkflowCaller implements WorkflowCaller { diff --git a/packages/stem/test/unit/workflow/flow_context_test.dart b/packages/stem/test/unit/workflow/flow_context_test.dart index 621c10f3..af2379a8 100644 --- a/packages/stem/test/unit/workflow/flow_context_test.dart +++ b/packages/stem/test/unit/workflow/flow_context_test.dart @@ -250,4 +250,44 @@ class _RecordingEnqueuer implements TaskEnqueuer { enqueueOptions: enqueueOptions ?? call.enqueueOptions, ); } + + @override + Future enqueueValue( + String name, + T value, { + PayloadCodec? codec, + Map headers = const {}, + Map meta = const {}, + TaskOptions options = const TaskOptions(), + DateTime? notBefore, + TaskEnqueueOptions? enqueueOptions, + }) { + return enqueue( + name, + args: _encodeFlowTaskArgs(name, value, codec: codec), + headers: headers, + meta: meta, + options: options, + notBefore: notBefore, + enqueueOptions: enqueueOptions, + ); + } +} + +Map _encodeFlowTaskArgs( + String name, + T value, { + PayloadCodec? codec, +}) { + final payload = codec == null ? value : codec.encode(value); + if (payload is Map) { + return Map.from(payload); + } + if (payload is Map) { + return payload.map((key, value) => MapEntry(key.toString(), value)); + } + throw StateError( + 'Task payload for $name must encode to Map, got ' + '${payload.runtimeType}.', + ); } diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart index 0e3c7a02..bab01090 100644 --- a/packages/stem/test/unit/workflow/workflow_resume_test.dart +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -1263,6 +1263,33 @@ class _FakeWorkflowScriptStepContext implements WorkflowScriptStepContext { return delegate.enqueueCall(call, enqueueOptions: enqueueOptions); } + @override + Future enqueueValue( + String name, + T value, { + PayloadCodec? codec, + Map headers = const {}, + Map meta = const {}, + TaskOptions options = const TaskOptions(), + DateTime? notBefore, + TaskEnqueueOptions? enqueueOptions, + }) async { + final delegate = _enqueuer; + if (delegate == null) { + throw StateError('WorkflowScriptStepContext has no enqueuer configured'); + } + return delegate.enqueueValue( + name, + value, + codec: codec, + headers: headers, + meta: meta, + options: options, + notBefore: notBefore, + enqueueOptions: enqueueOptions, + ); + } + @override Future startWorkflowRef( WorkflowRef definition, @@ -1388,4 +1415,44 @@ class _RecordingTaskEnqueuer implements TaskEnqueuer { enqueueOptions: enqueueOptions ?? call.enqueueOptions, ); } + + @override + Future enqueueValue( + String name, + T value, { + PayloadCodec? codec, + Map headers = const {}, + Map meta = const {}, + TaskOptions options = const TaskOptions(), + DateTime? notBefore, + TaskEnqueueOptions? enqueueOptions, + }) { + return enqueue( + name, + args: _encodeWorkflowTaskArgs(name, value, codec: codec), + headers: headers, + meta: meta, + options: options, + notBefore: notBefore, + enqueueOptions: enqueueOptions, + ); + } +} + +Map _encodeWorkflowTaskArgs( + String name, + T value, { + PayloadCodec? codec, +}) { + final payload = codec == null ? value : codec.encode(value); + if (payload is Map) { + return Map.from(payload); + } + if (payload is Map) { + return payload.map((key, value) => MapEntry(key.toString(), value)); + } + throw StateError( + 'Task payload for $name must encode to Map, got ' + '${payload.runtimeType}.', + ); } From f27c9c4a78ef1420822c37f4a6cf94ee33fc86cc Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 16:22:36 -0500 Subject: [PATCH 280/302] Add codec-backed low-level workflow start --- .site/docs/workflows/getting-started.md | 13 ++--- .site/docs/workflows/starting-and-waiting.md | 4 +- packages/stem/CHANGELOG.md | 4 ++ packages/stem/README.md | 17 ++++--- .../stem/lib/src/bootstrap/workflow_app.dart | 34 +++++++++++++ .../workflow/runtime/workflow_runtime.dart | 50 +++++++++++++++++++ .../stem/test/bootstrap/stem_app_test.dart | 46 +++++++++++++++++ 7 files changed, 153 insertions(+), 15 deletions(-) diff --git a/.site/docs/workflows/getting-started.md b/.site/docs/workflows/getting-started.md index 66dee242..27c602fe 100644 --- a/.site/docs/workflows/getting-started.md +++ b/.site/docs/workflows/getting-started.md @@ -29,12 +29,13 @@ the worker subscription automatically. The managed worker subscribes to the workflow orchestration queue, so you do not need to manually register the internal `stem.workflow.run` task. -If you prefer a minimal example, `startWorkflow(...)` and -`startWorkflowJson(...)` also lazy-start the runtime and managed worker on -first use. Explicit `start()` is still the better choice when you want -deterministic application lifecycle control. Use those name-based APIs when -workflow names come from config or external input. For workflows you define in -code, prefer direct workflow helpers or generated workflow refs. +If you prefer a minimal example, `startWorkflow(...)`, +`startWorkflowValue(...)`, and `startWorkflowJson(...)` also lazy-start the +runtime and managed worker on first use. Explicit `start()` is still the +better choice when you want deterministic application lifecycle control. Use +those name-based APIs when workflow names come from config or external input. +For workflows you define in code, prefer direct workflow helpers or generated +workflow refs. ## 3. Start a run and wait for the result diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index 623824c0..f5c009d9 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -184,8 +184,8 @@ adjust an explicit start request before dispatch. ## Parent runs and TTL -`WorkflowRuntime.startWorkflow(...)`, `startWorkflowJson(...)`, and -`startWorkflowVersionedJson(...)` also support: +`WorkflowRuntime.startWorkflow(...)`, `startWorkflowValue(...)`, +`startWorkflowJson(...)`, and `startWorkflowVersionedJson(...)` also support: - `parentRunId` when one workflow needs to track provenance from another run - `ttl` when you want run metadata to expire after a bounded retention period diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index b069692b..d66a124f 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -134,6 +134,10 @@ - Added `TaskEnqueuer.enqueueValue(...)` across producers and task/workflow execution contexts so dynamic task names can still enqueue typed DTO payloads through an explicit `PayloadCodec` without hand-built arg maps. +- Added `WorkflowRuntime.startWorkflowValue(...)` and + `StemWorkflowApp.startWorkflowValue(...)` so dynamic workflow names can start + typed DTO-backed runs through an explicit `PayloadCodec` without + hand-built param maps. - Added versioned low-level DTO shortcuts: `TaskEnqueuer.enqueueVersionedJson(...)`, `WorkflowRuntime.startWorkflowVersionedJson(...)`, diff --git a/packages/stem/README.md b/packages/stem/README.md index bcf44814..f6cb5776 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -541,9 +541,10 @@ The runtime shape is the same in every case: - bootstrap a `StemWorkflowApp` - pass `flows:`, `scripts:`, and `tasks:` directly - start runs with direct workflow helpers or generated workflow refs -- use `enqueueValue(...)`, `startWorkflow(...)` / `startWorkflowJson(...)`, - `emitJson(...)`, and `waitForCompletion(...)` when names come from config, - CLI input, or other dynamic sources +- use `enqueueValue(...)`, `startWorkflow(...)` / + `startWorkflowValue(...)` / `startWorkflowJson(...)`, `emitJson(...)`, and + `waitForCompletion(...)` when names come from config, CLI input, or other + dynamic sources You do not need to build task registries manually for normal workflow usage. @@ -1355,11 +1356,13 @@ final runId = await workflowApp.startWorkflow( ``` When those low-level name-based paths already have DTO inputs, prefer -`client.enqueueValue(...)` plus `workflowApp.startWorkflowJson(...)` or +`client.enqueueValue(...)` plus `workflowApp.startWorkflowValue(...)`, +`workflowApp.startWorkflowJson(...)`, or `workflowApp.startWorkflowVersionedJson(...)` over hand-built map payloads. -Use `PayloadCodec.versionedJson(...)` with `enqueueValue(...)`, or the -workflow-specific versioned helpers, when the DTO schema is expected to evolve -and you want the payload to persist an explicit `__stemPayloadVersion`. +Use `PayloadCodec.versionedJson(...)` with `enqueueValue(...)` or +`startWorkflowValue(...)`, or the workflow-specific versioned helpers, when +the DTO schema is expected to evolve and you want the payload to persist an +explicit `__stemPayloadVersion`. Adapter packages expose typed factories (e.g. `redisBrokerFactory`, `postgresResultBackendFactory`, `sqliteWorkflowStoreFactory`) so you can replace diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index a0fd73f0..18f2a391 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -240,6 +240,40 @@ class StemWorkflowApp ); } + /// Starts a workflow from a typed value plus optional [codec]. + /// + /// When [codec] is omitted, [value] must already be a string-keyed durable + /// map payload. + Future startWorkflowValue( + String name, + T value, { + PayloadCodec? codec, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + if (!_started) { + return start().then( + (_) => runtime.startWorkflowValue( + name, + value, + codec: codec, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ), + ); + } + return runtime.startWorkflowValue( + name, + value, + codec: codec, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + /// Starts a workflow from a DTO and stores a schema [version] beside the /// JSON payload. Future startWorkflowVersionedJson( diff --git a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart index 65ad2d06..5e83e6af 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart @@ -237,6 +237,27 @@ class WorkflowRuntime implements WorkflowCaller, WorkflowEventEmitter { ); } + /// Persists a new workflow run from a typed value plus optional [codec]. + /// + /// When [codec] is omitted, [value] must already be a string-keyed durable + /// map payload. + Future startWorkflowValue( + String name, + T value, { + PayloadCodec? codec, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return startWorkflow( + name, + params: _encodeWorkflowStartValue(name, value, codec: codec), + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + /// Persists a new workflow run from a DTO and stores a schema [version] /// beside the JSON payload. Future startWorkflowVersionedJson( @@ -1507,6 +1528,35 @@ class WorkflowRuntime implements WorkflowCaller, WorkflowEventEmitter { } } +Map _encodeWorkflowStartValue( + String name, + T value, { + PayloadCodec? codec, +}) { + final payload = codec == null ? value : codec.encode(value); + if (payload is Map) { + return Map.from(payload); + } + if (payload is Map) { + final normalized = {}; + for (final entry in payload.entries) { + final key = entry.key; + if (key is! String) { + throw StateError( + 'Workflow start payload for $name must use string keys, got ' + '${key.runtimeType}.', + ); + } + normalized[key] = entry.value; + } + return normalized; + } + throw StateError( + 'Workflow start payload for $name must encode to Map, got ' + '${payload.runtimeType}.', + ); +} + /// Task handler that dispatches workflow run execution for a run id. class _WorkflowRunTaskHandler implements TaskHandler { _WorkflowRunTaskHandler({required this.runtime}); diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index 6e709d33..74c1ddd6 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -546,6 +546,47 @@ void main() { } }); + test( + 'startWorkflowValue encodes typed params through the supplied codec', + () async { + final flow = Flow( + name: 'workflow.codec.start', + build: (builder) { + builder.step( + 'payload', + (ctx) async => + '${ctx.requiredParam('foo')}:' + '${ctx.requiredParam('kind')}', + ); + }, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + final runId = await workflowApp.startWorkflowValue( + 'workflow.codec.start', + const _DemoPayload('bar'), + codec: const PayloadCodec<_DemoPayload>.map( + encode: _encodeDemoPayloadMap, + decode: _DemoPayload.fromJson, + typeName: '_DemoPayload', + ), + ); + final runState = await workflowApp.getRun(runId); + final run = await workflowApp.waitForCompletion( + runId, + timeout: const Duration(seconds: 2), + ); + + expect(runId, isNotEmpty); + expect(runState?.params, containsPair('foo', 'bar')); + expect(runState?.params, containsPair('kind', 'custom')); + expect(run?.requiredValue(), 'bar:custom'); + } finally { + await workflowApp.shutdown(); + } + }); + test( 'startWorkflowVersionedJson encodes DTO params with a persisted ' 'schema version', @@ -1533,6 +1574,11 @@ class _DemoPayload { Map toJson() => {'foo': foo}; } +Object? _encodeDemoPayloadMap(_DemoPayload value) => { + 'foo': value.foo, + 'kind': 'custom', +}; + const _demoPayloadCodec = PayloadCodec<_DemoPayload>( encode: _encodeDemoPayload, decode: _decodeDemoPayload, From e3c3b779e2361e0ecdf6c952e685df81ec3fd6e2 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 16:24:21 -0500 Subject: [PATCH 281/302] Add codec-backed queue event publish --- .site/docs/core-concepts/queue-events.md | 4 ++ packages/stem/CHANGELOG.md | 3 ++ packages/stem/lib/src/core/queue_events.dart | 51 ++++++++++++++++++ .../test/unit/core/queue_events_test.dart | 52 +++++++++++++++++++ 4 files changed, 110 insertions(+) diff --git a/.site/docs/core-concepts/queue-events.md b/.site/docs/core-concepts/queue-events.md index 057dd291..86d2d3b7 100644 --- a/.site/docs/core-concepts/queue-events.md +++ b/.site/docs/core-concepts/queue-events.md @@ -14,6 +14,7 @@ Use this when you need lightweight event streams for domain notifications ## API Surface - `QueueEventsProducer.emit(queue, eventName, payload, headers, meta)` +- `QueueEventsProducer.emitValue(queue, eventName, value, codec, headers, meta)` - `QueueEventsProducer.emitJson(queue, eventName, dto, headers, meta)` - `QueueEventsProducer.emitVersionedJson(queue, eventName, dto, version, headers, meta)` - `QueueEvents.start()` / `QueueEvents.close()` @@ -48,6 +49,9 @@ Multiple listeners on the same queue receive each emitted event. - Events are queue-scoped: listeners receive only events for their configured queue. +- `emitValue(...)` is the codec-backed path when the payload should be + authored as a typed object but still use a custom map encoder or explicit + `PayloadCodec`. - `emitJson(...)` is the DTO convenience path when the payload already exposes `toJson()`. - `emitVersionedJson(...)` is the same convenience path when the payload diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index d66a124f..3ed225c6 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -138,6 +138,9 @@ `StemWorkflowApp.startWorkflowValue(...)` so dynamic workflow names can start typed DTO-backed runs through an explicit `PayloadCodec` without hand-built param maps. +- Added `QueueEventsProducer.emitValue(...)` so queue-scoped custom events can + publish typed payloads through an explicit `PayloadCodec` without + hand-built maps. - Added versioned low-level DTO shortcuts: `TaskEnqueuer.enqueueVersionedJson(...)`, `WorkflowRuntime.startWorkflowVersionedJson(...)`, diff --git a/packages/stem/lib/src/core/queue_events.dart b/packages/stem/lib/src/core/queue_events.dart index 1d98be4b..1da56dd7 100644 --- a/packages/stem/lib/src/core/queue_events.dart +++ b/packages/stem/lib/src/core/queue_events.dart @@ -227,6 +227,27 @@ class QueueEventsProducer { ); } + /// Emits [eventName] using a typed value plus optional [codec]. + /// + /// When [codec] is omitted, [value] must already be a string-keyed durable + /// map payload. + Future emitValue( + String queue, + String eventName, + T value, { + PayloadCodec? codec, + Map headers = const {}, + Map meta = const {}, + }) { + return emit( + queue, + eventName, + payload: _encodeQueueEventValue(queue, eventName, value, codec: codec), + headers: headers, + meta: meta, + ); + } + /// Emits [eventName] using a DTO payload and stores a schema [version] /// beside the JSON payload. Future emitVersionedJson( @@ -254,6 +275,36 @@ class QueueEventsProducer { } } +Map _encodeQueueEventValue( + String queue, + String eventName, + T value, { + PayloadCodec? codec, +}) { + final payload = codec == null ? value : codec.encode(value); + if (payload is Map) { + return Map.from(payload); + } + if (payload is Map) { + final normalized = {}; + for (final entry in payload.entries) { + final key = entry.key; + if (key is! String) { + throw StateError( + 'Queue event payload for $queue/$eventName must use string keys, ' + 'got ${key.runtimeType}.', + ); + } + normalized[key] = entry.value; + } + return normalized; + } + throw StateError( + 'Queue event payload for $queue/$eventName must encode to ' + 'Map, got ${payload.runtimeType}.', + ); +} + /// Listens for queue-scoped custom events emitted by [QueueEventsProducer]. class QueueEvents { /// Creates a queue event listener for [queue]. diff --git a/packages/stem/test/unit/core/queue_events_test.dart b/packages/stem/test/unit/core/queue_events_test.dart index cfefec35..9fd8c7ba 100644 --- a/packages/stem/test/unit/core/queue_events_test.dart +++ b/packages/stem/test/unit/core/queue_events_test.dart @@ -169,6 +169,53 @@ void main() { ); }); + test( + 'emitValue publishes typed payloads through the supplied codec', + () async { + final listener = QueueEvents( + broker: broker, + queue: 'orders', + consumerName: 'orders-listener-codec', + ); + await listener.start(); + addTearDown(listener.close); + + final received = listener + .on('order.codec') + .first + .timeout(const Duration(seconds: 5)); + + final eventId = await producer.emitValue( + 'orders', + 'order.codec', + const _QueueEventPayload(orderId: 'o-2b', status: 'codec'), + codec: const PayloadCodec<_QueueEventPayload>.map( + encode: _encodeQueueEventPayloadMap, + decode: _QueueEventPayload.fromJson, + typeName: '_QueueEventPayload', + ), + ); + + final event = await received; + expect(event.id, eventId); + expect(event.requiredPayloadValue('orderId'), 'o-2b'); + expect(event.requiredPayloadValue('status'), 'codec'); + expect(event.requiredPayloadValue('kind'), 'custom'); + expect( + event.payloadAs<_QueueEventPayload>( + codec: const PayloadCodec<_QueueEventPayload>.map( + encode: _encodeQueueEventPayloadMap, + decode: _QueueEventPayload.fromJson, + typeName: '_QueueEventPayload', + ), + ), + isA<_QueueEventPayload>() + .having((value) => value.orderId, 'orderId', 'o-2b') + .having((value) => value.status, 'status', 'codec'), + ); + }, + ); + test( 'emitVersionedJson publishes DTO payloads with a persisted schema ' 'version', @@ -264,6 +311,11 @@ class _QueueEventPayload { }; } +Object? _encodeQueueEventPayloadMap(_QueueEventPayload value) => { + ...value.toJson(), + 'kind': 'custom', +}; + class _QueueEventMeta { const _QueueEventMeta({required this.tenant}); From b76ed2715fdb5545c971416979f9fdde9114a345 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 16:29:27 -0500 Subject: [PATCH 282/302] Add versioned map workflow helpers --- .site/docs/workflows/starting-and-waiting.md | 2 + .../docs/workflows/suspensions-and-events.md | 5 +- packages/stem/CHANGELOG.md | 5 ++ packages/stem/README.md | 5 +- packages/stem/lib/src/workflow/core/flow.dart | 23 ++++++++ .../workflow/core/workflow_definition.dart | 28 +++++++++ .../src/workflow/core/workflow_event_ref.dart | 22 +++++++ .../lib/src/workflow/core/workflow_ref.dart | 50 ++++++++++++++++ .../src/workflow/core/workflow_script.dart | 23 ++++++++ .../workflow/workflow_runtime_ref_test.dart | 50 ++++++++++++++++ .../test/workflow/workflow_runtime_test.dart | 58 +++++++++++++++++++ 11 files changed, 268 insertions(+), 3 deletions(-) diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index f5c009d9..e8c03ff3 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -66,6 +66,8 @@ carry an explicit `__stemPayloadVersion`. Use `refCodec(...)` when you need a custom `PayloadCodec`. Workflow params still need to encode to a string-keyed map (typically `Map`) because they are stored as JSON-shaped data. +If the params need a custom map encoder and still need an explicit stored +schema version, use `refVersionedMap(...)` / `WorkflowRef.versionedMap(...)`. Inside manual flow steps and script checkpoints, prefer `ctx.param()` / `ctx.requiredParam()` for workflow start params and diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index 492f00a4..943e5fd4 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -70,8 +70,9 @@ DTO/codec convenience layers, not a new transport shape. When the topic and codec travel together in your codebase, prefer `WorkflowEventRef.json(...)` for normal DTO payloads, `WorkflowEventRef.versionedJson(...)` when the payload schema should carry -an explicit `__stemPayloadVersion`, and keep `event.emit(emitter, dto)` as the -happy path. +an explicit `__stemPayloadVersion`, `WorkflowEventRef.versionedMap(...)` +when the payload needs a custom map encoder plus stored schema version, and +keep `event.emit(emitter, dto)` as the happy path. Pair that with `await event.wait(ctx)`. If you are writing a flow and deliberately want the lower-level `FlowStepControl` path, use `event.awaitOn(step)` instead of dropping back to a raw topic string. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 3ed225c6..8e8433a2 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,11 @@ ## 0.1.1 +- Added `WorkflowEventRef.versionedMap(...)` and + `WorkflowRef.versionedMap(...)` plus the matching + `refVersionedMap(...)` helpers on `Flow`, `WorkflowScript`, and + `WorkflowDefinition` for custom map payloads that still persist + `__stemPayloadVersion`. - Added `decodeResultVersionedJson:` to `WorkflowRef.json(...)` / `refJson(...)` so manual typed workflow refs can keep unversioned params while decoding a version-aware stored result. diff --git a/packages/stem/README.md b/packages/stem/README.md index f6cb5776..fd3eddbd 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -624,6 +624,8 @@ to evolve and the persisted params should store `__stemPayloadVersion`. Drop down to `refCodec(...)` when you need a custom `PayloadCodec`. Workflow params still need to encode to a string-keyed map (typically `Map`) because they are persisted as JSON-shaped data. +If the params need a custom map encoder and still need an explicit stored +schema version, use `refVersionedMap(...)` / `WorkflowRef.versionedMap(...)`. If the params stay unversioned but the stored result carries an explicit schema version, `refJson(...)` / `WorkflowRef.json(...)` also accept `decodeResultVersionedJson:` plus `defaultDecodeVersion:`. @@ -1318,7 +1320,8 @@ backend metadata under `stem.unique.duplicates`. `runtime.emitJson(...)` / `runtime.emitVersionedJson(...)` / `runtime.emitValue(...)` when you are intentionally using the low-level runtime) with a `PayloadCodec`, or use `WorkflowEventRef.json(...)` / - `WorkflowEventRef.versionedJson(...)` as the shortest typed event forms + `WorkflowEventRef.versionedJson(...)` / + `WorkflowEventRef.versionedMap(...)` as the shortest typed event forms and call `event.emit(emitter, dto)` as the happy path. Pair that with `await event.wait(ctx)`. Event payloads still serialize onto a string-keyed JSON-like map. diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index a31ec36a..2f8fc0ce 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -159,6 +159,29 @@ class Flow { ); } + /// Builds a typed [WorkflowRef] for custom map params that persist a schema + /// [version] beside the payload. + WorkflowRef refVersionedMap({ + required Object? Function(TParams params) encodeParams, + required int version, + T Function(Map payload)? decodeResultJson, + T Function(Map payload, int version)? + decodeResultVersionedJson, + int? defaultDecodeVersion, + String? paramsTypeName, + String? resultTypeName, + }) { + return definition.refVersionedMap( + encodeParams: encodeParams, + version: version, + decodeResultJson: decodeResultJson, + decodeResultVersionedJson: decodeResultVersionedJson, + defaultDecodeVersion: defaultDecodeVersion, + paramsTypeName: paramsTypeName, + resultTypeName: resultTypeName, + ); + } + /// Builds a typed [NoArgsWorkflowRef] for flows without start params. NoArgsWorkflowRef ref0() { return definition.ref0(); diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index d788b9a4..bf03c0f2 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -540,6 +540,34 @@ class WorkflowDefinition { ); } + /// Builds a typed [WorkflowRef] for custom map params that persist a schema + /// [version] beside the payload. + WorkflowRef refVersionedMap({ + required Object? Function(TParams params) encodeParams, + required int version, + T Function(Map payload)? decodeResultJson, + T Function(Map payload, int version)? + decodeResultVersionedJson, + int? defaultDecodeVersion, + String? paramsTypeName, + String? resultTypeName, + }) { + return WorkflowRef.versionedMap( + name: name, + encodeParams: encodeParams, + version: version, + decodeResultJson: decodeResultJson, + decodeResultVersionedJson: decodeResultVersionedJson, + defaultDecodeVersion: defaultDecodeVersion, + decodeResult: + decodeResultJson == null && decodeResultVersionedJson == null + ? (payload) => decodeResult(payload) as T + : null, + paramsTypeName: paramsTypeName, + resultTypeName: resultTypeName, + ); + } + /// Builds a typed [NoArgsWorkflowRef] from this definition. NoArgsWorkflowRef ref0() { return NoArgsWorkflowRef( diff --git a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart index 72d76acd..f3ab65aa 100644 --- a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart @@ -74,6 +74,28 @@ class WorkflowEventRef { ); } + /// Creates a typed workflow event reference for custom map payloads that + /// persist a schema [version] beside the payload. + factory WorkflowEventRef.versionedMap({ + required String topic, + required Object? Function(T value) encode, + required int version, + required T Function(Map payload, int version) decode, + int? defaultDecodeVersion, + String? typeName, + }) { + return WorkflowEventRef.codec( + topic: topic, + codec: PayloadCodec.versionedMap( + encode: encode, + version: version, + decode: decode, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ), + ); + } + /// Durable topic name used to suspend and resume workflow runs. final String topic; diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index 147356ce..e3cd13f7 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -109,6 +109,56 @@ class WorkflowRef { ); } + /// Creates a typed workflow reference for custom map params that persist a + /// schema [version] beside the payload. + factory WorkflowRef.versionedMap({ + required String name, + required Object? Function(TParams params) encodeParams, + required int version, + TResult Function(Map payload)? decodeResultJson, + TResult Function(Map payload, int version)? + decodeResultVersionedJson, + int? defaultDecodeVersion, + TResult Function(Object? payload)? decodeResult, + String? paramsTypeName, + String? resultTypeName, + }) { + assert( + decodeResultJson == null || decodeResultVersionedJson == null, + 'Specify either decodeResultJson or decodeResultVersionedJson, not both.', + ); + final paramsCodec = PayloadCodec.versionedMap( + encode: encodeParams, + version: version, + decode: (payload, _) => throw UnsupportedError( + 'WorkflowRef.versionedMap($name) only uses the params codec for ' + 'encoding. Decoding is not supported at the ref layer.', + ), + defaultDecodeVersion: defaultDecodeVersion, + typeName: paramsTypeName ?? '$TParams', + ); + final resultCodec = + decodeResultVersionedJson != null + ? PayloadCodec.versionedJson( + version: version, + decode: decodeResultVersionedJson, + defaultDecodeVersion: defaultDecodeVersion, + typeName: resultTypeName ?? '$TResult', + ) + : (decodeResultJson == null + ? null + : PayloadCodec.json( + decode: decodeResultJson, + typeName: resultTypeName ?? '$TResult', + )); + return WorkflowRef.codec( + name: name, + paramsCodec: paramsCodec, + resultCodec: resultCodec, + decodeResult: decodeResult, + ); + } + /// Registered workflow name. final String name; diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index 5bcadeff..7eb61ace 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -169,6 +169,29 @@ class WorkflowScript { ); } + /// Builds a typed [WorkflowRef] for custom map params that persist a schema + /// [version] beside the payload. + WorkflowRef refVersionedMap({ + required Object? Function(TParams params) encodeParams, + required int version, + T Function(Map payload)? decodeResultJson, + T Function(Map payload, int version)? + decodeResultVersionedJson, + int? defaultDecodeVersion, + String? paramsTypeName, + String? resultTypeName, + }) { + return definition.refVersionedMap( + encodeParams: encodeParams, + version: version, + decodeResultJson: decodeResultJson, + decodeResultVersionedJson: decodeResultVersionedJson, + defaultDecodeVersion: defaultDecodeVersion, + paramsTypeName: paramsTypeName, + resultTypeName: resultTypeName, + ); + } + /// Builds a typed [NoArgsWorkflowRef] for scripts without start params. NoArgsWorkflowRef ref0() { return definition.ref0(); diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 544f46e4..5fc99f4c 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -44,6 +44,23 @@ const _greetingResultCodec = PayloadCodec<_GreetingResult>.json( typeName: '_GreetingResult', ); +class _LegacyGreetingParams { + const _LegacyGreetingParams({required this.name}); + + final String name; + + Map toLegacyMap() => {'display_name': name}; + + static _LegacyGreetingParams fromVersionedMap( + Map json, + int version, + ) { + return _LegacyGreetingParams( + name: '${json['display_name']! as String} v$version', + ); + } +} + final _userUpdatedEvent = WorkflowEventRef<_GreetingParams>.json( topic: 'runtime.ref.event', decode: _GreetingParams.fromJson, @@ -217,6 +234,39 @@ void main() { } }); + test('manual workflows can derive versioned-map refs', () async { + final flow = Flow( + name: 'runtime.ref.versioned-map.flow', + build: (builder) { + builder.step('hello', (ctx) async { + final params = ctx.paramsVersionedJson<_LegacyGreetingParams>( + decode: _LegacyGreetingParams.fromVersionedMap, + ); + return 'hello ${params.name}'; + }); + }, + ); + final workflowRef = flow.refVersionedMap<_LegacyGreetingParams>( + version: 3, + encodeParams: (params) => params.toLegacyMap(), + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + await workflowApp.start(); + + final result = await workflowRef.startAndWait( + workflowApp.runtime, + params: const _LegacyGreetingParams(name: 'map'), + timeout: const Duration(seconds: 2), + ); + + expect(result?.value, 'hello map v3'); + } finally { + await workflowApp.shutdown(); + } + }); + test( 'manual workflows can derive json-backed refs with result decoding', () async { diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index 0f16fd12..606901a1 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -862,6 +862,54 @@ void main() { expect(completed?.result, 'user-versioned-ref-2'); }); + test('emitEvent resumes flows with versioned-map workflow event refs', () async { + final event = WorkflowEventRef<_UserUpdatedEvent>.versionedMap( + topic: 'user.updated.versioned.map.ref', + encode: (value) => value.toLegacyMap(), + version: 3, + decode: _UserUpdatedEvent.fromVersionedMap, + typeName: '_UserUpdatedEvent', + ); + _UserUpdatedEvent? observedPayload; + + runtime.registerWorkflow( + Flow( + name: 'event.versioned.map.ref.workflow', + build: (flow) { + flow.step( + 'wait', + (context) async { + final resume = event.waitValue(context); + if (resume == null) { + return null; + } + observedPayload = resume; + return resume.id; + }, + ); + }, + ).definition, + ); + + final runId = await runtime.startWorkflow('event.versioned.map.ref.workflow'); + await runtime.executeRun(runId); + + final suspended = await store.get(runId); + expect(suspended?.status, WorkflowStatus.suspended); + expect(suspended?.waitTopic, event.topic); + + await event.emit( + runtime, + const _UserUpdatedEvent(id: 'user-versioned-map-ref'), + ); + await runtime.executeRun(runId); + + final completed = await store.get(runId); + expect(completed?.status, WorkflowStatus.completed); + expect(observedPayload?.id, 'user-versioned-map-ref-v3'); + expect(completed?.result, 'user-versioned-map-ref-v3'); + }); + test('emit persists payload before worker resumes execution', () async { runtime.registerWorkflow( Flow( @@ -1694,6 +1742,8 @@ class _UserUpdatedEvent { Map toJson() => {'id': id}; + Map toLegacyMap() => {'user_id': id}; + static _UserUpdatedEvent fromJson(Map json) { return _UserUpdatedEvent(id: json['id'] as String); } @@ -1705,4 +1755,12 @@ class _UserUpdatedEvent { expect(version, 2); return _UserUpdatedEvent(id: json['id'] as String); } + + static _UserUpdatedEvent fromVersionedMap( + Map json, + int version, + ) { + expect(version, 3); + return _UserUpdatedEvent(id: '${json['user_id'] as String}-v$version'); + } } From 392ae62380839cdb794c0f87c5e3771ad18008f9 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 16:31:04 -0500 Subject: [PATCH 283/302] Add versioned map task helpers --- .site/docs/core-concepts/tasks.md | 2 + packages/stem/CHANGELOG.md | 3 ++ packages/stem/README.md | 2 + packages/stem/lib/src/core/contracts.dart | 54 +++++++++++++++++++ .../stem/test/unit/core/stem_core_test.dart | 54 +++++++++++++++++++ 5 files changed, 115 insertions(+) diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 266c9f86..b97a0332 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -79,6 +79,8 @@ when you need a custom `PayloadCodec`. Task args still need to encode to a string-keyed map (typically `Map`) because they are published as JSON-shaped data. For low-level name-based enqueue APIs, use `enqueueVersionedJson(...)` for the same versioned DTO path. +If the args need a custom map encoder and still need an explicit stored schema +version, use `TaskDefinition.versionedMap(...)`. If the args stay unversioned but the stored result carries an explicit schema version, `TaskDefinition.json(...)` also accepts `decodeResultVersionedJson:` plus `defaultDecodeVersion:`. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 8e8433a2..7729ac87 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Added `TaskDefinition.versionedMap(...)` for custom map task args that + should still persist `__stemPayloadVersion`, including the same + version-aware stored-result decoding options as `versionedJson(...)`. - Added `WorkflowEventRef.versionedMap(...)` and `WorkflowRef.versionedMap(...)` plus the matching `refVersionedMap(...)` helpers on `Flow`, `WorkflowScript`, and diff --git a/packages/stem/README.md b/packages/stem/README.md index fd3eddbd..4483e9e7 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -232,6 +232,8 @@ explicit `__stemPayloadVersion`. Drop down to `TaskDefinition.codec(...)` only when you need a custom `PayloadCodec`. Task args still need to encode to a string-keyed map (typically `Map`) because they are published as JSON-shaped data. +If the args need a custom map encoder and still need an explicit stored schema +version, use `TaskDefinition.versionedMap(...)`. If the args stay unversioned but the stored result carries an explicit schema version, `TaskDefinition.json(...)` also accepts `decodeResultVersionedJson:` plus `defaultDecodeVersion:`. diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index b593841b..3199d230 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2875,6 +2875,60 @@ class TaskDefinition { ); } + /// Creates a typed task definition for custom map args that persist a schema + /// [version] beside the payload. + factory TaskDefinition.versionedMap({ + required String name, + required Object? Function(TArgs args) encodeArgs, + required int version, + TaskMetaBuilder? encodeMeta, + TaskOptions defaultOptions = const TaskOptions(), + TaskMetadata metadata = const TaskMetadata(), + TResult Function(Map payload)? decodeResultJson, + TResult Function(Map payload, int version)? + decodeResultVersionedJson, + int? defaultDecodeVersion, + String? argsTypeName, + String? resultTypeName, + }) { + assert( + decodeResultJson == null || decodeResultVersionedJson == null, + 'Specify either decodeResultJson or decodeResultVersionedJson, not both.', + ); + final argsCodec = PayloadCodec.versionedMap( + encode: encodeArgs, + version: version, + decode: (payload, _) => throw UnsupportedError( + 'TaskDefinition.versionedMap($name) only uses the args codec for ' + 'encoding. Decoding is not supported at the definition layer.', + ), + defaultDecodeVersion: defaultDecodeVersion, + typeName: argsTypeName ?? '$TArgs', + ); + final resultCodec = + decodeResultVersionedJson != null + ? PayloadCodec.versionedJson( + version: version, + decode: decodeResultVersionedJson, + defaultDecodeVersion: defaultDecodeVersion, + typeName: resultTypeName ?? '$TResult', + ) + : (decodeResultJson == null + ? null + : PayloadCodec.json( + decode: decodeResultJson, + typeName: resultTypeName ?? '$TResult', + )); + return TaskDefinition.codec( + name: name, + argsCodec: argsCodec, + encodeMeta: encodeMeta, + defaultOptions: defaultOptions, + metadata: metadata, + resultCodec: resultCodec, + ); + } + /// Creates a typed task definition for handlers with no producer args. static NoArgsTaskDefinition noArgsCodec({ required String name, diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index 35ee0fdc..964d6a13 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -212,6 +212,35 @@ void main() { expect(backend.records.single.state, TaskState.queued); }); + test('enqueueCall publishes versioned-map task definitions', () async { + final broker = _RecordingBroker(); + final backend = _RecordingBackend(); + final stem = Stem(broker: broker, backend: backend); + final definition = TaskDefinition<_CodecTaskArgs, Object?>.versionedMap( + name: 'sample.versioned.map.args', + version: 3, + encodeArgs: (args) => {'legacy_value': args.value}, + defaultOptions: const TaskOptions(queue: 'typed'), + ); + + final id = await stem.enqueueCall( + definition.buildCall(const _CodecTaskArgs('encoded')), + ); + + expect(id, isNotEmpty); + expect( + broker.published.single.envelope.name, + 'sample.versioned.map.args', + ); + expect(broker.published.single.envelope.queue, 'typed'); + expect(broker.published.single.envelope.args, { + PayloadCodec.versionKey: 3, + 'legacy_value': 'encoded', + }); + expect(backend.records.single.id, id); + expect(backend.records.single.state, TaskState.queued); + }); + test('enqueueJson publishes DTO args without a manual map', () async { final broker = _RecordingBroker(); final backend = _RecordingBackend(); @@ -346,6 +375,31 @@ void main() { }, ); + test( + 'versioned map task definitions can derive versioned result metadata', + () async { + final broker = _RecordingBroker(); + final backend = _RecordingBackend(); + final stem = Stem(broker: broker, backend: backend); + final definition = TaskDefinition<_CodecTaskArgs, _CodecReceipt>.versionedMap( + name: 'sample.versioned_map.result', + version: 2, + encodeArgs: (args) => {'legacy_value': args.value}, + decodeResultVersionedJson: _CodecReceipt.fromVersionedJson, + ); + + final id = await stem.enqueueCall( + definition.buildCall(const _CodecTaskArgs('encoded')), + ); + + expect( + backend.records.single.meta[stemResultEncoderMetaKey], + endsWith('.result.codec'), + ); + expect(backend.records.single.id, id); + }, + ); + test( 'json task definitions can derive versioned result metadata', () async { From 80f50df90536c684ff40bf27e3f947cde43b11e7 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 16:34:21 -0500 Subject: [PATCH 284/302] Add versioned map workflow result helpers --- .site/docs/workflows/starting-and-waiting.md | 2 + packages/stem/CHANGELOG.md | 4 ++ packages/stem/README.md | 4 +- packages/stem/lib/src/core/payload_codec.dart | 2 +- packages/stem/lib/src/workflow/core/flow.dart | 30 +++++++++ .../workflow/core/workflow_definition.dart | 62 +++++++++++++++++++ .../src/workflow/core/workflow_script.dart | 32 ++++++++++ .../workflow/workflow_runtime_ref_test.dart | 59 +++++++++++++++++- .../test/workflow/workflow_runtime_test.dart | 4 +- 9 files changed, 191 insertions(+), 8 deletions(-) diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index e8c03ff3..706b3cfb 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -83,6 +83,8 @@ If a manual flow or script returns a DTO, prefer `Flow.json(...)` or case. Use `Flow.versionedJson(...)` / `WorkflowScript.versionedJson(...)` when the stored result should carry an explicit schema version. Use `Flow.codec(...)` or `WorkflowScript.codec(...)` when the result needs a custom payload codec. +If the result still needs a custom map encoder plus an explicit stored schema +version, use `Flow.versionedMap(...)` / `WorkflowScript.versionedMap(...)`. For manual typed refs, `refVersionedJson(...)` / `WorkflowRef.versionedJson(...)` also accept `decodeResultVersionedJson:` when the stored result should carry an explicit schema version. diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 7729ac87..03821e70 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.1 +- Added `Flow.versionedMap(...)`, `WorkflowScript.versionedMap(...)`, + `WorkflowDefinition.flowVersionedMap(...)`, and + `WorkflowDefinition.scriptVersionedMap(...)` for custom map workflow results + that still persist `__stemPayloadVersion`. - Added `TaskDefinition.versionedMap(...)` for custom map task args that should still persist `__stemPayloadVersion`, including the same version-aware stored-result decoding options as `versionedJson(...)`. diff --git a/packages/stem/README.md b/packages/stem/README.md index 4483e9e7..d0318d95 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -637,7 +637,9 @@ If a manual flow or script only needs DTO result decoding, prefer `Flow.versionedJson(...)` / `WorkflowScript.versionedJson(...)` when the stored result needs an explicit schema version. If the final result needs a custom codec, prefer `Flow.codec(...)` or `WorkflowScript.codec(...)` instead of -passing `resultCodec:` to the base constructor. +passing `resultCodec:` to the base constructor. If the result still needs a +custom map encoder plus an explicit stored schema version, use +`Flow.versionedMap(...)` / `WorkflowScript.versionedMap(...)`. For manual typed refs, `refVersionedJson(...)` / `WorkflowRef.versionedJson(...)` also accept `decodeResultVersionedJson:` when the stored result should use the same explicit schema-version decode path. diff --git a/packages/stem/lib/src/core/payload_codec.dart b/packages/stem/lib/src/core/payload_codec.dart index 11b65ee4..3f978ab6 100644 --- a/packages/stem/lib/src/core/payload_codec.dart +++ b/packages/stem/lib/src/core/payload_codec.dart @@ -49,7 +49,7 @@ class PayloadCodec { /// /// ```dart /// const approvalCodec = PayloadCodec.versionedMap( - /// encode: (value) => value.toLegacyMap(), + /// encode: (value) => {'legacy_status': value.status}, /// version: 2, /// defaultDecodeVersion: 1, /// decode: Approval.fromVersionedMap, diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index 2f8fc0ce..428076b3 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -101,6 +101,36 @@ class Flow { ); } + /// Creates a flow definition whose final result is a versioned custom map + /// payload. + factory Flow.versionedMap({ + required String name, + required void Function(FlowBuilder builder) build, + required Object? Function(T value) encodeResult, + required int version, + required T Function(Map payload, int version) decodeResult, + String? workflowVersion, + String? description, + Map? metadata, + int? defaultDecodeVersion, + String? resultTypeName, + }) { + return Flow( + name: name, + build: build, + version: workflowVersion, + description: description, + metadata: metadata, + resultCodec: PayloadCodec.versionedMap( + encode: encodeResult, + version: version, + decode: decodeResult, + defaultDecodeVersion: defaultDecodeVersion, + typeName: resultTypeName ?? '$T', + ), + ); + } + /// The constructed workflow definition. final WorkflowDefinition definition; diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index bf03c0f2..fd7c7e43 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -273,6 +273,36 @@ class WorkflowDefinition { ); } + /// Creates a flow-based workflow definition whose final result is a + /// versioned custom map payload. + factory WorkflowDefinition.flowVersionedMap({ + required String name, + required void Function(FlowBuilder builder) build, + required Object? Function(T value) encodeResult, + required int version, + required T Function(Map payload, int version) decodeResult, + String? workflowVersion, + String? description, + Map? metadata, + int? defaultDecodeVersion, + String? resultTypeName, + }) { + return WorkflowDefinition.flow( + name: name, + build: build, + version: workflowVersion, + description: description, + metadata: metadata, + resultCodec: PayloadCodec.versionedMap( + encode: encodeResult, + version: version, + decode: decodeResult, + defaultDecodeVersion: defaultDecodeVersion, + typeName: resultTypeName ?? '$T', + ), + ); + } + /// Creates a script-based workflow definition. factory WorkflowDefinition.script({ required String name, @@ -367,6 +397,38 @@ class WorkflowDefinition { ); } + /// Creates a script-based workflow definition whose final result is a + /// versioned custom map payload. + factory WorkflowDefinition.scriptVersionedMap({ + required String name, + required WorkflowScriptBody run, + required Object? Function(T value) encodeResult, + required int version, + required T Function(Map payload, int version) decodeResult, + Iterable checkpoints = const [], + String? workflowVersion, + String? description, + Map? metadata, + int? defaultDecodeVersion, + String? resultTypeName, + }) { + return WorkflowDefinition.script( + name: name, + run: run, + checkpoints: checkpoints, + version: workflowVersion, + description: description, + metadata: metadata, + resultCodec: PayloadCodec.versionedMap( + encode: encodeResult, + version: version, + decode: decodeResult, + defaultDecodeVersion: defaultDecodeVersion, + typeName: resultTypeName ?? '$T', + ), + ); + } + /// Creates a script-based workflow definition whose final result is a /// versioned DTO-backed JSON payload. factory WorkflowDefinition.scriptVersionedJson({ diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index 7eb61ace..87e9ce97 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -111,6 +111,38 @@ class WorkflowScript { ); } + /// Creates a script definition whose final result is a versioned custom map + /// payload. + factory WorkflowScript.versionedMap({ + required String name, + required WorkflowScriptBody run, + required Object? Function(T value) encodeResult, + required int version, + required T Function(Map payload, int version) decodeResult, + Iterable checkpoints = const [], + String? workflowVersion, + String? description, + Map? metadata, + int? defaultDecodeVersion, + String? resultTypeName, + }) { + return WorkflowScript( + name: name, + run: run, + checkpoints: checkpoints, + version: workflowVersion, + description: description, + metadata: metadata, + resultCodec: PayloadCodec.versionedMap( + encode: encodeResult, + version: version, + decode: decodeResult, + defaultDecodeVersion: defaultDecodeVersion, + typeName: resultTypeName ?? '$T', + ), + ); + } + /// The constructed workflow definition. final WorkflowDefinition definition; diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 5fc99f4c..96a30e49 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -29,6 +29,15 @@ class _GreetingResult { ); } + factory _GreetingResult.fromVersionedMap( + Map json, + int version, + ) { + return _GreetingResult( + message: '${json['legacy_message']! as String} v$version', + ); + } + final String message; Map toJson() => {'message': message}; @@ -49,8 +58,6 @@ class _LegacyGreetingParams { final String name; - Map toLegacyMap() => {'display_name': name}; - static _LegacyGreetingParams fromVersionedMap( Map json, int version, @@ -248,7 +255,7 @@ void main() { ); final workflowRef = flow.refVersionedMap<_LegacyGreetingParams>( version: 3, - encodeParams: (params) => params.toLegacyMap(), + encodeParams: (params) => {'display_name': params.name}, ); final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); @@ -550,6 +557,52 @@ void main() { }, ); + test( + 'raw workflow definitions expose direct versioned map result helpers', + () async { + final flow = WorkflowDefinition<_GreetingResult>.flowVersionedMap( + name: 'runtime.ref.definition.versioned.map.result.flow', + version: 3, + encodeResult: (value) => {'legacy_message': value.message}, + decodeResult: _GreetingResult.fromVersionedMap, + build: (builder) { + builder.step( + 'hello', + (ctx) async => const _GreetingResult(message: 'hello flow'), + ); + }, + ); + final script = WorkflowDefinition<_GreetingResult>.scriptVersionedMap( + name: 'runtime.ref.definition.versioned.map.result.script', + version: 3, + encodeResult: (value) => {'legacy_message': value.message}, + decodeResult: _GreetingResult.fromVersionedMap, + run: (context) async => + const _GreetingResult(message: 'hello script'), + ); + + final workflowApp = await StemWorkflowApp.inMemory(); + try { + workflowApp.registerWorkflows([flow, script]); + await workflowApp.start(); + + final flowResult = await flow.ref0().startAndWait( + workflowApp.runtime, + timeout: const Duration(seconds: 2), + ); + final scriptResult = await script.ref0().startAndWait( + workflowApp.runtime, + timeout: const Duration(seconds: 2), + ); + + expect(flowResult?.value?.message, 'hello flow v3'); + expect(scriptResult?.value?.message, 'hello script v3'); + } finally { + await workflowApp.shutdown(); + } + }, + ); + test( 'manual workflows can derive versioned-json refs with result decoding', () async { diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index 606901a1..a0c85358 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -865,7 +865,7 @@ void main() { test('emitEvent resumes flows with versioned-map workflow event refs', () async { final event = WorkflowEventRef<_UserUpdatedEvent>.versionedMap( topic: 'user.updated.versioned.map.ref', - encode: (value) => value.toLegacyMap(), + encode: (value) => {'user_id': value.id}, version: 3, decode: _UserUpdatedEvent.fromVersionedMap, typeName: '_UserUpdatedEvent', @@ -1742,8 +1742,6 @@ class _UserUpdatedEvent { Map toJson() => {'id': id}; - Map toLegacyMap() => {'user_id': id}; - static _UserUpdatedEvent fromJson(Map json) { return _UserUpdatedEvent(id: json['id'] as String); } From ec72d1fdc9cd5943394b5571aeb8bf754e35e3c0 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 19:37:31 -0500 Subject: [PATCH 285/302] Add payload version decoder registry --- .../workflows/context-and-serialization.md | 8 ++ packages/stem/README.md | 8 ++ packages/stem/lib/src/core/payload_codec.dart | 84 +++++++++++++++++++ .../test/unit/core/payload_codec_test.dart | 84 +++++++++++++++++++ 4 files changed, 184 insertions(+) diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index 810abd82..ac4b4f81 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -153,8 +153,16 @@ If the DTO payload shape is expected to evolve, use `PayloadCodec.versionedJson(...)`. That persists a reserved `__stemPayloadVersion` field beside the JSON payload and gives the decoder the stored version so it can read older shapes explicitly. + +When a DTO evolves through multiple persisted shapes, prefer +`PayloadVersionRegistry` with `PayloadCodec.versionedJsonRegistry(...)` +so version-specific decoders live in one reusable registry instead of being +repeated inline at every call site. + Use `PayloadCodec.versionedMap(...)` instead when the payload still needs a custom map encoder or a nonstandard version-aware decode function. +`PayloadCodec.versionedMapRegistry(...)` gives the same reusable-registry +shape for that case. For manual flows and scripts, prefer the typed workflow param helpers before dropping to raw map casts: diff --git a/packages/stem/README.md b/packages/stem/README.md index d0318d95..05b18aba 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -308,8 +308,16 @@ When a DTO payload needs an explicit persisted schema version, prefer `PayloadCodec.versionedJson(...)`. It stores `__stemPayloadVersion` beside the JSON payload and passes the persisted version into the decoder so you can keep older payloads readable while newer producers emit the latest shape. + +If the payload evolves through multiple stored versions, prefer +`PayloadVersionRegistry` with `PayloadCodec.versionedJsonRegistry(...)` so +version-specific decoders live in one reusable registry instead of being +repeated inline at every call site. + Use `PayloadCodec.versionedMap(...)` instead when the payload still needs a custom map encoder or a nonstandard version-aware decode shape. +`PayloadCodec.versionedMapRegistry(...)` provides the same registry-backed +pattern for that custom-map case. The same pattern now carries through the low-level readback helpers: `status.payloadVersionedJson(...)`, `result.payloadVersionedJson(...)`, `workflowResult.payloadVersionedJson(...)`, and diff --git a/packages/stem/lib/src/core/payload_codec.dart b/packages/stem/lib/src/core/payload_codec.dart index 3f978ab6..2a3b91b9 100644 --- a/packages/stem/lib/src/core/payload_codec.dart +++ b/packages/stem/lib/src/core/payload_codec.dart @@ -1,5 +1,46 @@ +import 'dart:collection'; + import 'package:stem/src/core/task_payload_encoder.dart'; +/// Registry of version-specific payload decoders for a single durable DTO type. +/// +/// Use this when a payload schema evolves over time and you want one reusable +/// place to define how each stored version should be decoded. +class PayloadVersionRegistry { + /// Creates a version registry from explicit [decoders]. + const PayloadVersionRegistry({ + required Map payload)> decoders, + this.defaultVersion, + }) : _decoders = decoders; + + final Map payload)> _decoders; + + /// Fallback version to use when a stored payload does not persist one. + final int? defaultVersion; + + /// Registered decoder versions. + Map payload)> get decoders => + UnmodifiableMapView(_decoders); + + /// Decodes [payload] using the decoder registered for [version]. + T decode( + Map payload, + int version, { + String typeName = 'payload', + }) { + final decoder = _decoders[version]; + if (decoder == null) { + final known = _decoders.keys.toList()..sort(); + throw StateError( + '$typeName has no decoder registered for payload version $version. ' + 'Known versions: ${known.join(', ')}.', + ); + } + return decoder(payload); + } +} + + /// Encodes and decodes a strongly-typed payload value. /// /// This author-facing codec layer is used by generated workflow/task helpers to @@ -115,6 +156,49 @@ class PayloadCodec { _defaultDecodeVersion = defaultDecodeVersion, _typeName = typeName; + /// Creates a JSON DTO codec backed by a reusable version registry. + /// + /// This keeps payload version evolution in one place instead of repeating the + /// same `switch(version)` logic across task, workflow, and event surfaces. + factory PayloadCodec.versionedJsonRegistry({ + required int version, + required PayloadVersionRegistry registry, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedJson( + version: version, + defaultDecodeVersion: defaultDecodeVersion ?? registry.defaultVersion, + decode: (payload, storedVersion) => registry.decode( + payload, + storedVersion, + typeName: typeName ?? '$T', + ), + typeName: typeName, + ); + } + + /// Creates a custom map-backed codec backed by a reusable version registry. + factory PayloadCodec.versionedMapRegistry({ + required Object? Function(T value) encode, + required int version, + required PayloadVersionRegistry registry, + int? defaultDecodeVersion, + String? typeName, + }) { + return PayloadCodec.versionedMap( + encode: encode, + version: version, + defaultDecodeVersion: defaultDecodeVersion ?? registry.defaultVersion, + decode: (payload, storedVersion) => registry.decode( + payload, + storedVersion, + typeName: typeName ?? '$T', + ), + typeName: typeName, + ); + } + /// Reserved key used to persist payload schema versions for versioned codecs. static const String versionKey = '__stemPayloadVersion'; diff --git a/packages/stem/test/unit/core/payload_codec_test.dart b/packages/stem/test/unit/core/payload_codec_test.dart index f84747b7..b26fcacb 100644 --- a/packages/stem/test/unit/core/payload_codec_test.dart +++ b/packages/stem/test/unit/core/payload_codec_test.dart @@ -229,6 +229,74 @@ void main() { }); }); + group('PayloadVersionRegistry', () { + const registry = PayloadVersionRegistry<_VersionedCodecPayload>( + decoders: )>{ + 1: _VersionedCodecPayload.fromV1Json, + 2: _VersionedCodecPayload.fromV2Json, + }, + defaultVersion: 1, + ); + + test('decodes versioned JSON payloads through a reusable registry', () { + final codec = PayloadCodec<_VersionedCodecPayload>.versionedJsonRegistry( + version: 2, + registry: registry, + typeName: '_VersionedCodecPayload', + ); + + final decoded = codec.decode({ + PayloadCodec.versionKey: 2, + 'id': 'payload-registry-v2', + 'count': 21, + }); + + expect(decoded.id, 'payload-registry-v2'); + expect(decoded.count, 21); + expect(decoded.decodedVersion, 2); + }); + + test('uses the registry default version when the payload has none', () { + final codec = PayloadCodec<_VersionedCodecPayload>.versionedJsonRegistry( + version: 2, + registry: registry, + typeName: '_VersionedCodecPayload', + ); + + final decoded = codec.decode({ + 'legacy_id': 'payload-registry-v1', + 'amount': 18, + }); + + expect(decoded.id, 'payload-registry-v1'); + expect(decoded.count, 18); + expect(decoded.decodedVersion, 1); + }); + + test('rejects unknown payload versions with a clear error', () { + final codec = PayloadCodec<_VersionedCodecPayload>.versionedJsonRegistry( + version: 2, + registry: registry, + typeName: '_VersionedCodecPayload', + ); + + expect( + () => codec.decode({ + PayloadCodec.versionKey: 9, + 'id': 'payload-registry-unknown', + 'count': 22, + }), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('has no decoder registered for payload version 9'), + ), + ), + ); + }); + }); + group('PayloadCodec.versionedMap', () { test('encodes custom map payloads with a persisted schema version', () { const codec = PayloadCodec<_VersionedCodecPayload>.versionedMap( @@ -365,6 +433,22 @@ class _VersionedCodecPayload { ); } + factory _VersionedCodecPayload.fromV1Json(Map json) { + return _VersionedCodecPayload( + id: json['legacy_id']! as String, + count: json['amount']! as int, + decodedVersion: 1, + ); + } + + factory _VersionedCodecPayload.fromV2Json(Map json) { + return _VersionedCodecPayload( + id: json['id']! as String, + count: json['count']! as int, + decodedVersion: 2, + ); + } + final String id; final int count; final int? decodedVersion; From ea914ef73801ed344acdb71e7681fe3c0f701034 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Fri, 20 Mar 2026 20:44:15 -0500 Subject: [PATCH 286/302] Add registry-backed versioned factories --- .../workflows/context-and-serialization.md | 12 ++ packages/stem/CHANGELOG.md | 3 + packages/stem/README.md | 12 ++ packages/stem/lib/src/core/contracts.dart | 105 ++++++++++++++++++ packages/stem/lib/src/workflow/core/flow.dart | 96 ++++++++++++++++ .../workflow/core/workflow_definition.dart | 40 +++++++ .../src/workflow/core/workflow_event_ref.dart | 42 +++++++ .../lib/src/workflow/core/workflow_ref.dart | 65 +++++++++++ .../src/workflow/core/workflow_script.dart | 100 +++++++++++++++++ .../stem/test/unit/core/stem_core_test.dart | 37 ++++++ .../workflow/workflow_runtime_ref_test.dart | 54 +++++++++ .../test/workflow/workflow_runtime_test.dart | 59 ++++++++++ 12 files changed, 625 insertions(+) diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index ac4b4f81..e812d6c6 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -164,6 +164,18 @@ custom map encoder or a nonstandard version-aware decode function. `PayloadCodec.versionedMapRegistry(...)` gives the same reusable-registry shape for that case. +The same registry-backed model is available on the higher-level authoring +factories too: +- `TaskDefinition.versionedJsonRegistry(...)` +- `TaskDefinition.versionedMapRegistry(...)` +- `WorkflowRef.versionedJsonRegistry(...)` +- `WorkflowRef.versionedMapRegistry(...)` +- `WorkflowEventRef.versionedJsonRegistry(...)` +- `WorkflowEventRef.versionedMapRegistry(...)` +- `Flow.versionedJsonRegistry(...)` / `Flow.versionedMapRegistry(...)` +- `WorkflowScript.versionedJsonRegistry(...)` / + `WorkflowScript.versionedMapRegistry(...)` + For manual flows and scripts, prefer the typed workflow param helpers before dropping to raw map casts: diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 03821e70..50f0f9f8 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.1 +- Added `PayloadVersionRegistry` plus registry-backed versioned factories + for manual task definitions, workflow refs, workflow events, flows, and + scripts. - Added `Flow.versionedMap(...)`, `WorkflowScript.versionedMap(...)`, `WorkflowDefinition.flowVersionedMap(...)`, and `WorkflowDefinition.scriptVersionedMap(...)` for custom map workflow results diff --git a/packages/stem/README.md b/packages/stem/README.md index 05b18aba..6363ddeb 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -318,6 +318,18 @@ Use `PayloadCodec.versionedMap(...)` instead when the payload still needs a custom map encoder or a nonstandard version-aware decode shape. `PayloadCodec.versionedMapRegistry(...)` provides the same registry-backed pattern for that custom-map case. + +The same registry-backed model is available on the author-facing factories: +- `TaskDefinition.versionedJsonRegistry(...)` +- `TaskDefinition.versionedMapRegistry(...)` +- `WorkflowRef.versionedJsonRegistry(...)` +- `WorkflowRef.versionedMapRegistry(...)` +- `WorkflowEventRef.versionedJsonRegistry(...)` +- `WorkflowEventRef.versionedMapRegistry(...)` +- `Flow.versionedJsonRegistry(...)` / `Flow.versionedMapRegistry(...)` +- `WorkflowScript.versionedJsonRegistry(...)` / + `WorkflowScript.versionedMapRegistry(...)` + The same pattern now carries through the low-level readback helpers: `status.payloadVersionedJson(...)`, `result.payloadVersionedJson(...)`, `workflowResult.payloadVersionedJson(...)`, and diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 3199d230..0ea0ef94 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -2875,6 +2875,47 @@ class TaskDefinition { ); } + /// Creates a typed task definition for DTO args that already expose + /// `toJson()` and decode versioned results through a reusable registry. + factory TaskDefinition.versionedJsonRegistry({ + required String name, + required int version, + required PayloadVersionRegistry resultRegistry, + TaskMetaBuilder? encodeMeta, + TaskOptions defaultOptions = const TaskOptions(), + TaskMetadata metadata = const TaskMetadata(), + int? defaultDecodeVersion, + String? argsTypeName, + String? resultTypeName, + }) { + return TaskDefinition( + name: name, + encodeArgs: (args) => _encodeVersionedJsonArgs( + args, + version: version, + typeName: argsTypeName ?? '$TArgs', + ), + encodeMeta: encodeMeta, + defaultOptions: defaultOptions, + metadata: _metadataWithResultCodec( + name, + metadata, + PayloadCodec.versionedJsonRegistry( + version: version, + registry: resultRegistry, + defaultDecodeVersion: defaultDecodeVersion, + typeName: resultTypeName ?? '$TResult', + ), + ), + decodeResult: PayloadCodec.versionedJsonRegistry( + version: version, + registry: resultRegistry, + defaultDecodeVersion: defaultDecodeVersion, + typeName: resultTypeName ?? '$TResult', + ).decode, + ); + } + /// Creates a typed task definition for custom map args that persist a schema /// [version] beside the payload. factory TaskDefinition.versionedMap({ @@ -2929,6 +2970,46 @@ class TaskDefinition { ); } + /// Creates a typed task definition for custom map args that persist a schema + /// [version] and decode versioned results through a reusable registry. + factory TaskDefinition.versionedMapRegistry({ + required String name, + required Object? Function(TArgs args) encodeArgs, + required int version, + required PayloadVersionRegistry resultRegistry, + TaskMetaBuilder? encodeMeta, + TaskOptions defaultOptions = const TaskOptions(), + TaskMetadata metadata = const TaskMetadata(), + int? defaultDecodeVersion, + String? argsTypeName, + String? resultTypeName, + }) { + final argsCodec = PayloadCodec.versionedMap( + encode: encodeArgs, + version: version, + decode: (payload, _) => throw UnsupportedError( + 'TaskDefinition.versionedMapRegistry($name) only uses the args codec ' + 'for encoding. Decoding is not supported at the definition layer.', + ), + defaultDecodeVersion: defaultDecodeVersion, + typeName: argsTypeName ?? '$TArgs', + ); + final resultCodec = PayloadCodec.versionedJsonRegistry( + version: version, + registry: resultRegistry, + defaultDecodeVersion: defaultDecodeVersion, + typeName: resultTypeName ?? '$TResult', + ); + return TaskDefinition.codec( + name: name, + argsCodec: argsCodec, + encodeMeta: encodeMeta, + defaultOptions: defaultOptions, + metadata: metadata, + resultCodec: resultCodec, + ); + } + /// Creates a typed task definition for handlers with no producer args. static NoArgsTaskDefinition noArgsCodec({ required String name, @@ -2986,6 +3067,30 @@ class TaskDefinition { ); } + /// Creates a typed task definition for handlers with no producer args whose + /// result uses a reusable version registry. + static NoArgsTaskDefinition noArgsVersionedJsonRegistry({ + required String name, + required int version, + required PayloadVersionRegistry resultRegistry, + TaskOptions defaultOptions = const TaskOptions(), + TaskMetadata metadata = const TaskMetadata(), + int? defaultDecodeVersion, + String? resultTypeName, + }) { + return noArgs( + name: name, + defaultOptions: defaultOptions, + metadata: metadata, + resultCodec: PayloadCodec.versionedJsonRegistry( + version: version, + registry: resultRegistry, + defaultDecodeVersion: defaultDecodeVersion, + typeName: resultTypeName ?? '$TResult', + ), + ); + } + /// Creates a typed task definition for handlers with no producer args. static NoArgsTaskDefinition noArgs({ required String name, diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index 428076b3..000d213d 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -101,6 +101,34 @@ class Flow { ); } + /// Creates a flow definition whose final result uses a reusable version + /// registry. + factory Flow.versionedJsonRegistry({ + required String name, + required void Function(FlowBuilder builder) build, + required int version, + required PayloadVersionRegistry resultRegistry, + String? workflowVersion, + String? description, + Map? metadata, + int? defaultDecodeVersion, + String? resultTypeName, + }) { + return Flow( + name: name, + build: build, + version: workflowVersion, + description: description, + metadata: metadata, + resultCodec: PayloadCodec.versionedJsonRegistry( + version: version, + registry: resultRegistry, + defaultDecodeVersion: defaultDecodeVersion, + typeName: resultTypeName ?? '$T', + ), + ); + } + /// Creates a flow definition whose final result is a versioned custom map /// payload. factory Flow.versionedMap({ @@ -131,6 +159,36 @@ class Flow { ); } + /// Creates a flow definition whose final result is a versioned custom map + /// payload decoded through a reusable registry. + factory Flow.versionedMapRegistry({ + required String name, + required void Function(FlowBuilder builder) build, + required Object? Function(T value) encodeResult, + required int version, + required PayloadVersionRegistry resultRegistry, + String? workflowVersion, + String? description, + Map? metadata, + int? defaultDecodeVersion, + String? resultTypeName, + }) { + return Flow( + name: name, + build: build, + version: workflowVersion, + description: description, + metadata: metadata, + resultCodec: PayloadCodec.versionedMapRegistry( + encode: encodeResult, + version: version, + registry: resultRegistry, + defaultDecodeVersion: defaultDecodeVersion, + typeName: resultTypeName ?? '$T', + ), + ); + } + /// The constructed workflow definition. final WorkflowDefinition definition; @@ -189,6 +247,24 @@ class Flow { ); } + /// Builds a typed [WorkflowRef] for DTO params that already expose + /// `toJson()` and decode versioned results through a reusable registry. + WorkflowRef refVersionedJsonRegistry({ + required int version, + required PayloadVersionRegistry resultRegistry, + int? defaultDecodeVersion, + String? paramsTypeName, + String? resultTypeName, + }) { + return definition.refVersionedJsonRegistry( + version: version, + resultRegistry: resultRegistry, + defaultDecodeVersion: defaultDecodeVersion, + paramsTypeName: paramsTypeName, + resultTypeName: resultTypeName, + ); + } + /// Builds a typed [WorkflowRef] for custom map params that persist a schema /// [version] beside the payload. WorkflowRef refVersionedMap({ @@ -212,6 +288,26 @@ class Flow { ); } + /// Builds a typed [WorkflowRef] for custom map params that persist a schema + /// [version] and decode versioned results through a reusable registry. + WorkflowRef refVersionedMapRegistry({ + required Object? Function(TParams params) encodeParams, + required int version, + required PayloadVersionRegistry resultRegistry, + int? defaultDecodeVersion, + String? paramsTypeName, + String? resultTypeName, + }) { + return definition.refVersionedMapRegistry( + encodeParams: encodeParams, + version: version, + resultRegistry: resultRegistry, + defaultDecodeVersion: defaultDecodeVersion, + paramsTypeName: paramsTypeName, + resultTypeName: resultTypeName, + ); + } + /// Builds a typed [NoArgsWorkflowRef] for flows without start params. NoArgsWorkflowRef ref0() { return definition.ref0(); diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index fd7c7e43..91260583 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -602,6 +602,25 @@ class WorkflowDefinition { ); } + /// Builds a typed [WorkflowRef] for DTO params that already expose + /// `toJson()` and decode versioned results through a reusable registry. + WorkflowRef refVersionedJsonRegistry({ + required int version, + required PayloadVersionRegistry resultRegistry, + int? defaultDecodeVersion, + String? paramsTypeName, + String? resultTypeName, + }) { + return WorkflowRef.versionedJsonRegistry( + name: name, + version: version, + resultRegistry: resultRegistry, + defaultDecodeVersion: defaultDecodeVersion, + paramsTypeName: paramsTypeName, + resultTypeName: resultTypeName, + ); + } + /// Builds a typed [WorkflowRef] for custom map params that persist a schema /// [version] beside the payload. WorkflowRef refVersionedMap({ @@ -630,6 +649,27 @@ class WorkflowDefinition { ); } + /// Builds a typed [WorkflowRef] for custom map params that persist a schema + /// [version] and decode versioned results through a reusable registry. + WorkflowRef refVersionedMapRegistry({ + required Object? Function(TParams params) encodeParams, + required int version, + required PayloadVersionRegistry resultRegistry, + int? defaultDecodeVersion, + String? paramsTypeName, + String? resultTypeName, + }) { + return WorkflowRef.versionedMapRegistry( + name: name, + encodeParams: encodeParams, + version: version, + resultRegistry: resultRegistry, + defaultDecodeVersion: defaultDecodeVersion, + paramsTypeName: paramsTypeName, + resultTypeName: resultTypeName, + ); + } + /// Builds a typed [NoArgsWorkflowRef] from this definition. NoArgsWorkflowRef ref0() { return NoArgsWorkflowRef( diff --git a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart index f3ab65aa..00a41f06 100644 --- a/packages/stem/lib/src/workflow/core/workflow_event_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_event_ref.dart @@ -74,6 +74,26 @@ class WorkflowEventRef { ); } + /// Creates a typed workflow event reference backed by a reusable version + /// registry. + factory WorkflowEventRef.versionedJsonRegistry({ + required String topic, + required int version, + required PayloadVersionRegistry registry, + int? defaultDecodeVersion, + String? typeName, + }) { + return WorkflowEventRef.codec( + topic: topic, + codec: PayloadCodec.versionedJsonRegistry( + version: version, + registry: registry, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ), + ); + } + /// Creates a typed workflow event reference for custom map payloads that /// persist a schema [version] beside the payload. factory WorkflowEventRef.versionedMap({ @@ -96,6 +116,28 @@ class WorkflowEventRef { ); } + /// Creates a typed workflow event reference for custom map payloads backed + /// by a reusable version registry. + factory WorkflowEventRef.versionedMapRegistry({ + required String topic, + required Object? Function(T value) encode, + required int version, + required PayloadVersionRegistry registry, + int? defaultDecodeVersion, + String? typeName, + }) { + return WorkflowEventRef.codec( + topic: topic, + codec: PayloadCodec.versionedMapRegistry( + encode: encode, + version: version, + registry: registry, + defaultDecodeVersion: defaultDecodeVersion, + typeName: typeName, + ), + ); + } + /// Durable topic name used to suspend and resume workflow runs. final String topic; diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart index e3cd13f7..8097d022 100644 --- a/packages/stem/lib/src/workflow/core/workflow_ref.dart +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -109,6 +109,34 @@ class WorkflowRef { ); } + /// Creates a typed workflow reference for DTO params that already expose + /// `toJson()` and decode versioned results through a reusable registry. + factory WorkflowRef.versionedJsonRegistry({ + required String name, + required int version, + required PayloadVersionRegistry resultRegistry, + TResult Function(Object? payload)? decodeResult, + int? defaultDecodeVersion, + String? paramsTypeName, + String? resultTypeName, + }) { + final resultCodec = PayloadCodec.versionedJsonRegistry( + version: version, + registry: resultRegistry, + defaultDecodeVersion: defaultDecodeVersion, + typeName: resultTypeName ?? '$TResult', + ); + return WorkflowRef( + name: name, + encodeParams: (params) => _encodeVersionedJsonParams( + params, + version: version, + typeName: paramsTypeName ?? '$TParams', + ), + decodeResult: decodeResult ?? resultCodec.decode, + ); + } + /// Creates a typed workflow reference for custom map params that persist a /// schema [version] beside the payload. factory WorkflowRef.versionedMap({ @@ -159,6 +187,43 @@ class WorkflowRef { ); } + /// Creates a typed workflow reference for custom map params that persist a + /// schema [version] and decode versioned results through a reusable + /// registry. + factory WorkflowRef.versionedMapRegistry({ + required String name, + required Object? Function(TParams params) encodeParams, + required int version, + required PayloadVersionRegistry resultRegistry, + TResult Function(Object? payload)? decodeResult, + int? defaultDecodeVersion, + String? paramsTypeName, + String? resultTypeName, + }) { + final paramsCodec = PayloadCodec.versionedMap( + encode: encodeParams, + version: version, + decode: (payload, _) => throw UnsupportedError( + 'WorkflowRef.versionedMapRegistry($name) only uses the params codec ' + 'for encoding. Decoding is not supported at the ref layer.', + ), + defaultDecodeVersion: defaultDecodeVersion, + typeName: paramsTypeName ?? '$TParams', + ); + final resultCodec = PayloadCodec.versionedJsonRegistry( + version: version, + registry: resultRegistry, + defaultDecodeVersion: defaultDecodeVersion, + typeName: resultTypeName ?? '$TResult', + ); + return WorkflowRef.codec( + name: name, + paramsCodec: paramsCodec, + resultCodec: resultCodec, + decodeResult: decodeResult, + ); + } + /// Registered workflow name. final String name; diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index 87e9ce97..2361b593 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -111,6 +111,36 @@ class WorkflowScript { ); } + /// Creates a script definition whose final result uses a reusable version + /// registry. + factory WorkflowScript.versionedJsonRegistry({ + required String name, + required WorkflowScriptBody run, + required int version, + required PayloadVersionRegistry resultRegistry, + Iterable checkpoints = const [], + String? workflowVersion, + String? description, + Map? metadata, + int? defaultDecodeVersion, + String? resultTypeName, + }) { + return WorkflowScript( + name: name, + run: run, + checkpoints: checkpoints, + version: workflowVersion, + description: description, + metadata: metadata, + resultCodec: PayloadCodec.versionedJsonRegistry( + version: version, + registry: resultRegistry, + defaultDecodeVersion: defaultDecodeVersion, + typeName: resultTypeName ?? '$T', + ), + ); + } + /// Creates a script definition whose final result is a versioned custom map /// payload. factory WorkflowScript.versionedMap({ @@ -143,6 +173,38 @@ class WorkflowScript { ); } + /// Creates a script definition whose final result is a versioned custom map + /// payload decoded through a reusable registry. + factory WorkflowScript.versionedMapRegistry({ + required String name, + required WorkflowScriptBody run, + required Object? Function(T value) encodeResult, + required int version, + required PayloadVersionRegistry resultRegistry, + Iterable checkpoints = const [], + String? workflowVersion, + String? description, + Map? metadata, + int? defaultDecodeVersion, + String? resultTypeName, + }) { + return WorkflowScript( + name: name, + run: run, + checkpoints: checkpoints, + version: workflowVersion, + description: description, + metadata: metadata, + resultCodec: PayloadCodec.versionedMapRegistry( + encode: encodeResult, + version: version, + registry: resultRegistry, + defaultDecodeVersion: defaultDecodeVersion, + typeName: resultTypeName ?? '$T', + ), + ); + } + /// The constructed workflow definition. final WorkflowDefinition definition; @@ -201,6 +263,24 @@ class WorkflowScript { ); } + /// Builds a typed [WorkflowRef] for DTO params that already expose + /// `toJson()` and decode versioned results through a reusable registry. + WorkflowRef refVersionedJsonRegistry({ + required int version, + required PayloadVersionRegistry resultRegistry, + int? defaultDecodeVersion, + String? paramsTypeName, + String? resultTypeName, + }) { + return definition.refVersionedJsonRegistry( + version: version, + resultRegistry: resultRegistry, + defaultDecodeVersion: defaultDecodeVersion, + paramsTypeName: paramsTypeName, + resultTypeName: resultTypeName, + ); + } + /// Builds a typed [WorkflowRef] for custom map params that persist a schema /// [version] beside the payload. WorkflowRef refVersionedMap({ @@ -224,6 +304,26 @@ class WorkflowScript { ); } + /// Builds a typed [WorkflowRef] for custom map params that persist a schema + /// [version] and decode versioned results through a reusable registry. + WorkflowRef refVersionedMapRegistry({ + required Object? Function(TParams params) encodeParams, + required int version, + required PayloadVersionRegistry resultRegistry, + int? defaultDecodeVersion, + String? paramsTypeName, + String? resultTypeName, + }) { + return definition.refVersionedMapRegistry( + encodeParams: encodeParams, + version: version, + resultRegistry: resultRegistry, + defaultDecodeVersion: defaultDecodeVersion, + paramsTypeName: paramsTypeName, + resultTypeName: resultTypeName, + ); + } + /// Builds a typed [NoArgsWorkflowRef] for scripts without start params. NoArgsWorkflowRef ref0() { return definition.ref0(); diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index 964d6a13..bb6f119c 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -375,6 +375,31 @@ void main() { }, ); + test( + 'versioned json registry task definitions can derive versioned result metadata', + () async { + final broker = _RecordingBroker(); + final backend = _RecordingBackend(); + final stem = Stem(broker: broker, backend: backend); + final definition = + TaskDefinition<_CodecTaskArgs, _CodecReceipt>.versionedJsonRegistry( + name: 'sample.versioned_json.registry.result', + version: 2, + resultRegistry: _codecReceiptRegistry, + ); + + final id = await stem.enqueueCall( + definition.buildCall(const _CodecTaskArgs('encoded')), + ); + + expect( + backend.records.single.meta[stemResultEncoderMetaKey], + endsWith('.result.codec'), + ); + expect(backend.records.single.id, id); + }, + ); + test( 'versioned map task definitions can derive versioned result metadata', () async { @@ -990,6 +1015,10 @@ class _CodecReceipt { return _CodecReceipt('${json['id']! as String}-v$version'); } + factory _CodecReceipt.fromV2Json(Map json) { + return _CodecReceipt('${json['id']! as String}-v2'); + } + final String id; Map toJson() => {'id': id}; @@ -1036,6 +1065,14 @@ const _codecReceiptCodec = PayloadCodec<_CodecReceipt>.json( typeName: '_CodecReceipt', ); +const _codecReceiptRegistry = PayloadVersionRegistry<_CodecReceipt>( + decoders: )>{ + 1: _CodecReceipt.fromJson, + 2: _CodecReceipt.fromV2Json, + }, + defaultVersion: 1, +); + const _codecReceiptEncoder = CodecTaskPayloadEncoder<_CodecReceipt>( idValue: 'test.codec.receipt', codec: _codecReceiptCodec, diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 96a30e49..8552bb2e 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -29,6 +29,12 @@ class _GreetingResult { ); } + factory _GreetingResult.fromV2Json(Map json) { + return _GreetingResult( + message: '${json['message']! as String} v2', + ); + } + factory _GreetingResult.fromVersionedMap( Map json, int version, @@ -53,6 +59,14 @@ const _greetingResultCodec = PayloadCodec<_GreetingResult>.json( typeName: '_GreetingResult', ); +const _greetingResultRegistry = PayloadVersionRegistry<_GreetingResult>( + decoders: )>{ + 1: _GreetingResult.fromJson, + 2: _GreetingResult.fromV2Json, + }, + defaultVersion: 1, +); + class _LegacyGreetingParams { const _LegacyGreetingParams({required this.name}); @@ -643,6 +657,46 @@ void main() { }, ); + test( + 'manual workflows can derive registry-backed versioned-json refs', + () async { + final flow = Flow( + name: 'runtime.ref.registry.ref-result.flow', + build: (builder) { + builder.step( + 'hello', + (ctx) async => const { + 'message': 'hello ref registry', + PayloadCodec.versionKey: 2, + }, + ); + }, + ); + final workflowRef = flow.refVersionedJsonRegistry<_GreetingParams>( + version: 2, + resultRegistry: _greetingResultRegistry, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + await workflowApp.start(); + + final result = await workflowRef.startAndWait( + workflowApp.runtime, + params: const _GreetingParams(name: 'ignored'), + timeout: const Duration(seconds: 2), + ); + + expect( + (result?.value as _GreetingResult?)?.message, + 'hello ref registry v2', + ); + } finally { + await workflowApp.shutdown(); + } + }, + ); + test('manual workflows expose direct no-args helpers', () async { final flow = Flow( name: 'runtime.ref.no-args.flow', diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index a0c85358..df120dcd 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -862,6 +862,53 @@ void main() { expect(completed?.result, 'user-versioned-ref-2'); }); + test('emitEvent resumes flows with registry-backed workflow event refs', () async { + final event = WorkflowEventRef<_UserUpdatedEvent>.versionedJsonRegistry( + topic: 'user.updated.registry.ref', + version: 2, + registry: _userUpdatedEventRegistry, + typeName: '_UserUpdatedEvent', + ); + _UserUpdatedEvent? observedPayload; + + runtime.registerWorkflow( + Flow( + name: 'event.registry.ref.workflow', + build: (flow) { + flow.step( + 'wait', + (context) async { + final resume = event.waitValue(context); + if (resume == null) { + return null; + } + observedPayload = resume; + return resume.id; + }, + ); + }, + ).definition, + ); + + final runId = await runtime.startWorkflow('event.registry.ref.workflow'); + await runtime.executeRun(runId); + + final suspended = await store.get(runId); + expect(suspended?.status, WorkflowStatus.suspended); + expect(suspended?.waitTopic, event.topic); + + await event.emit( + runtime, + const _UserUpdatedEvent(id: 'user-registry-ref-2'), + ); + await runtime.executeRun(runId); + + final completed = await store.get(runId); + expect(completed?.status, WorkflowStatus.completed); + expect(observedPayload?.id, 'user-registry-ref-2'); + expect(completed?.result, 'user-registry-ref-2'); + }); + test('emitEvent resumes flows with versioned-map workflow event refs', () async { final event = WorkflowEventRef<_UserUpdatedEvent>.versionedMap( topic: 'user.updated.versioned.map.ref', @@ -1754,6 +1801,10 @@ class _UserUpdatedEvent { return _UserUpdatedEvent(id: json['id'] as String); } + static _UserUpdatedEvent fromV2Json(Map json) { + return _UserUpdatedEvent(id: json['id'] as String); + } + static _UserUpdatedEvent fromVersionedMap( Map json, int version, @@ -1762,3 +1813,11 @@ class _UserUpdatedEvent { return _UserUpdatedEvent(id: '${json['user_id'] as String}-v$version'); } } + +const _userUpdatedEventRegistry = PayloadVersionRegistry<_UserUpdatedEvent>( + decoders: )>{ + 1: _UserUpdatedEvent.fromJson, + 2: _UserUpdatedEvent.fromV2Json, + }, + defaultVersion: 1, +); From 2099c936195f3e0cb3bbdadb432b77644dd87b8b Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Sat, 21 Mar 2026 03:15:46 -0500 Subject: [PATCH 287/302] Add stack reuse to StemClient.fromUrl --- .../stem/lib/src/bootstrap/stem_client.dart | 20 +++++---- .../stem/test/bootstrap/stem_client_test.dart | 41 +++++++++++++++++++ 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/packages/stem/lib/src/bootstrap/stem_client.dart b/packages/stem/lib/src/bootstrap/stem_client.dart index c3abe1db..a2d6017b 100644 --- a/packages/stem/lib/src/bootstrap/stem_client.dart +++ b/packages/stem/lib/src/bootstrap/stem_client.dart @@ -92,6 +92,9 @@ abstract class StemClient implements TaskResultCaller { /// /// This resolves broker/backend factories via [StemStack.fromUrl] so callers /// can avoid manual factory wiring for common Redis/Postgres/SQLite setups. + /// + /// When [stack] is supplied, the client reuses that pre-resolved adapter + /// stack instead of resolving broker/backend factories from [url] again. static Future fromUrl( String url, { StemModule? module, @@ -111,20 +114,23 @@ abstract class StemClient implements TaskResultCaller { TaskPayloadEncoder resultEncoder = const JsonTaskPayloadEncoder(), TaskPayloadEncoder argsEncoder = const JsonTaskPayloadEncoder(), Iterable additionalEncoders = const [], + StemStack? stack, }) { - final stack = StemStack.fromUrl( - url, - adapters: adapters, - overrides: overrides, - ); + final resolvedStack = + stack ?? + StemStack.fromUrl( + url, + adapters: adapters, + overrides: overrides, + ); return create( module: module, modules: modules, tasks: tasks, taskRegistry: taskRegistry, workflowRegistry: workflowRegistry, - broker: stack.broker, - backend: stack.backend, + broker: resolvedStack.broker, + backend: resolvedStack.backend, routing: routing, retryStrategy: retryStrategy, uniqueTaskCoordinator: uniqueTaskCoordinator, diff --git a/packages/stem/test/bootstrap/stem_client_test.dart b/packages/stem/test/bootstrap/stem_client_test.dart index 7176e131..1f79e1f1 100644 --- a/packages/stem/test/bootstrap/stem_client_test.dart +++ b/packages/stem/test/bootstrap/stem_client_test.dart @@ -491,6 +491,47 @@ void main() { } }); + test('StemClient fromUrl reuses a pre-resolved stack', () async { + final handler = FunctionTaskHandler( + name: 'client.from-url.stack', + entrypoint: (context, args) async => 'ok', + ); + final definition = TaskDefinition.noArgs( + name: 'client.from-url.stack', + ); + final stack = StemStack.fromUrl( + 'test://localhost', + adapters: [ + TestStoreAdapter( + scheme: 'test', + adapterName: 'client-test-adapter', + broker: StemBrokerFactory(create: () async => InMemoryBroker()), + backend: StemBackendFactory( + create: () async => InMemoryResultBackend(), + ), + ), + ], + ); + final client = await StemClient.fromUrl( + 'memory://ignored', + stack: stack, + tasks: [handler], + ); + + final worker = await client.createWorker(); + await worker.start(); + try { + final result = await definition.enqueueAndWait( + client, + timeout: const Duration(seconds: 2), + ); + expect(result?.value, 'ok'); + } finally { + await worker.shutdown(); + await client.close(); + } + }); + test('StemClient createWorker infers queues from explicit tasks', () async { final client = await StemClient.inMemory(); final worker = await client.createWorker( From 3404a7ad55a6e4becf6e1c8fda7b3e75ace19ed6 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Sat, 21 Mar 2026 03:15:47 -0500 Subject: [PATCH 288/302] Bump stem to 0.2.0 --- packages/dashboard/pubspec.yaml | 2 +- packages/stem/CHANGELOG.md | 831 +----------------- packages/stem/pubspec.yaml | 2 +- .../stem/test/unit/core/stem_event_test.dart | 8 +- packages/stem_adapter_tests/pubspec.yaml | 2 +- packages/stem_builder/pubspec.yaml | 2 +- packages/stem_cli/pubspec.yaml | 2 +- packages/stem_memory/pubspec.yaml | 2 +- packages/stem_postgres/pubspec.yaml | 2 +- packages/stem_redis/pubspec.yaml | 2 +- packages/stem_sqlite/pubspec.yaml | 2 +- 11 files changed, 36 insertions(+), 821 deletions(-) diff --git a/packages/dashboard/pubspec.yaml b/packages/dashboard/pubspec.yaml index dc79877b..03421099 100644 --- a/packages/dashboard/pubspec.yaml +++ b/packages/dashboard/pubspec.yaml @@ -12,7 +12,7 @@ dependencies: ormed: ^0.2.0 routed: ^0.3.2 routed_hotwire: ^0.1.2 - stem: ^0.1.0 + stem: ^0.2.0 stem_cli: ^0.1.0 stem_postgres: ^0.1.0 stem_redis: ^0.1.0 diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 50f0f9f8..d102a20c 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -1,814 +1,29 @@ # Changelog -## 0.1.1 +## 0.2.0 -- Added `PayloadVersionRegistry` plus registry-backed versioned factories - for manual task definitions, workflow refs, workflow events, flows, and - scripts. -- Added `Flow.versionedMap(...)`, `WorkflowScript.versionedMap(...)`, - `WorkflowDefinition.flowVersionedMap(...)`, and - `WorkflowDefinition.scriptVersionedMap(...)` for custom map workflow results - that still persist `__stemPayloadVersion`. -- Added `TaskDefinition.versionedMap(...)` for custom map task args that - should still persist `__stemPayloadVersion`, including the same - version-aware stored-result decoding options as `versionedJson(...)`. -- Added `WorkflowEventRef.versionedMap(...)` and - `WorkflowRef.versionedMap(...)` plus the matching - `refVersionedMap(...)` helpers on `Flow`, `WorkflowScript`, and - `WorkflowDefinition` for custom map payloads that still persist - `__stemPayloadVersion`. -- Added `decodeResultVersionedJson:` to - `WorkflowRef.json(...)` / `refJson(...)` so manual typed workflow refs can - keep unversioned params while decoding a version-aware stored result. -- Added `decodeResultVersionedJson:` to `TaskDefinition.json(...)` so manual - task definitions can keep unversioned DTO args while decoding a - version-aware stored result. -- Added `decodeResultVersionedJson:` to - `WorkflowRef.versionedJson(...)` / `refVersionedJson(...)` so manual typed - workflow refs can derive version-aware result decoding alongside versioned - params. -- Added `decodeResultVersionedJson:` to - `TaskDefinition.versionedJson(...)` so argful manual task definitions can - derive version-aware result decoding and result-encoder metadata. -- Refreshed runnable workflow/task examples to remove stale `.call(...)` - transport usage and prefer the narrowed direct `start(...)` / - `buildCall(...)` surfaces. -- Removed `WorkflowRef.call(...)` as a duplicate workflow-start convenience. - The direct `start(...)` / `startAndWait(...)` helpers remain the happy path, - and `buildStart(...)` remains the explicit prebuilt-call path. -- Removed `NoArgsWorkflowRef.prepareStart()` as a duplicate no-args builder - wrapper. Use direct `start(...)` / `startAndWait(...)` for the happy path, - or `ref0().asRef.buildStart(params: ())` when you need an explicit prebuilt - call. -- Removed `TaskDefinition.call(...)` as a duplicate task-enqueue convenience. - The direct `enqueue(...)` / `enqueueAndWait(...)` helpers remain the happy - path, and `buildCall(args, ...)` remains the explicit prebuilt-call path. -- Removed the `TaskCall.enqueue(...)` / `enqueueAndWait(...)` and - `WorkflowStartCall.start(...)` / `startAndWait(...)` dispatch wrappers. - Prebuilt transport objects now dispatch explicitly through - `TaskEnqueuer.enqueueCall(...)` and `WorkflowCaller.startWorkflowCall(...)`. -- Renamed read-side `...VersionedJson(...)` fallback args to - `defaultVersion:` so decode helpers no longer imply they are choosing the - persisted schema version on already-stored payloads. -- Refreshed runnable task examples/docs to prefer direct `enqueue(...)` and - `enqueueAndWait(...)` with named overrides over `prepareEnqueue(...)` when - no incremental builder assembly is needed. -- Refreshed workflow examples/docs to prefer direct `start(...)` named - overrides over `prepareStart(...)` when no incremental builder assembly is - needed. -- Clarified docs so direct `start(...)` / `startAndWait(...)` and - `enqueue(...)` / `enqueueAndWait(...)` are the default happy path, with - `buildStart(...)` and `buildCall(...)` positioned as the explicit advanced - transport path. -- Removed the caller/context `prepareStart(...)` and `prepareEnqueue(...)` - wrapper entrypoints so the explicit transport path now hangs directly off - `WorkflowRef` and `TaskDefinition`. -- Clarified docs so `TaskCall` and `WorkflowStartCall` are described as the - explicit low-level transport path, not peer happy-path APIs beside direct - `enqueue(...)` / `start(...)` helpers. -- Added direct `buildCall(...)` / `buildStart(...)` helpers on task/workflow - definitions so the explicit transport path no longer requires - `prepare...().build()` when all overrides are already known. -- Removed `TaskCall.copyWith(...)` and `WorkflowStartCall.copyWith(...)`. - Explicit transport objects are now built with their final overrides up - front rather than mutated after construction. -- Added versioned manual-result convenience constructors: - `TaskDefinition.noArgsVersionedJson(...)`, - `WorkflowDefinition.flowVersionedJson(...)`, - `WorkflowDefinition.scriptVersionedJson(...)`, - `Flow.versionedJson(...)`, and `WorkflowScript.versionedJson(...)`. -- Removed no-args `buildCall()` / `buildStart()` transport wrappers in favor - of the explicit typed surfaces: - `definition.asDefinition.buildCall(())` and - `ref0().asRef.buildStart(params: ())`. -- Removed `WorkflowRef.prepareStart(...)` and `WorkflowStartBuilder`; the - explicit workflow transport path now uses `buildStart(...)` plus - `copyWith(...)` when advanced overrides are needed. -- Removed `TaskDefinition.prepareEnqueue(...)` and `TaskEnqueueBuilder`; the - explicit task transport path now uses `buildCall(...)` plus `copyWith(...)` - when advanced overrides are needed. -- Added `QueueCustomEvent.metaJson(...)`, `metaVersionedJson(...)`, and - `metaAs(codec: ...)` so queue-event metadata can decode DTO payloads - without raw map casts. -- Added `TaskStatus.metaJson(...)`, `metaVersionedJson(...)`, and - `metaAs(codec: ...)` so low-level task status metadata can decode DTO - payloads without raw map casts. -- Added `WorkflowWatcher.dataJson(...)`, `dataVersionedJson(...)`, - `dataAs(codec: ...)`, and the matching `WorkflowWatcherResolution` - `resumeData...` helpers so watcher inspection can decode full stored watcher - metadata DTOs without raw map casts. -- Added `RunState.paramsJson(...)`, `paramsVersionedJson(...)`, and - `paramsAs(codec: ...)` so low-level workflow run snapshots can decode - stored workflow input DTOs without raw map casts. -- Added `TaskError.metaJson(...)`, `metaVersionedJson(...)`, and - `metaAs(codec: ...)` so low-level task failure metadata can decode DTO - payloads without raw map casts. -- Added `WorkflowRunView.paramsJson(...)`, - `paramsVersionedJson(...)`, and `paramsAs(codec: ...)` so workflow detail - views can decode stored workflow input DTOs without raw map casts. -- Added `RunState.lastErrorJson(...)`, `runtimeJson(...)`, - `cancellationDataJson(...)`, and the matching `WorkflowRunView` helpers so - workflow inspection surfaces can decode error, runtime, and cancellation - DTOs without raw map casts. -- Added `WorkerHeartbeat.extrasJson(...)`, - `extrasVersionedJson(...)`, and `extrasAs(codec: ...)` so persisted worker - heartbeat metadata can decode DTO payloads without raw map casts. -- Added `DeadLetterEntry.metaJson(...)`, `metaVersionedJson(...)`, - `ScheduleEntry.argsJson(...)`, `kwargsJson(...)`, and `metaJson(...)` so - DLQ and scheduler tooling can decode persisted DTO payloads without raw map - casts. -- Added `ControlCommandMessage.payloadJson(...)`, - `payloadVersionedJson(...)`, and the matching `ControlReplyMessage` - payload/error helpers so low-level worker control tooling can decode DTO - payloads without raw map casts. -- Added `WorkflowStepEvent.metadataJson(...)`, - `metadataVersionedJson(...)`, `metadataPayloadJson(...)`, and the matching - `WorkflowRuntimeEvent` helpers so workflow introspection metadata can decode - DTOs without raw map casts. -- Added `WorkerEvent.dataJson(...)`, `dataVersionedJson(...)`, and - `dataAs(codec: ...)` so worker observability events can decode structured - event data without raw map casts. -- Added `ControlCommandCompletedPayload.responseJson(...)`, - `responseVersionedJson(...)`, `responseAs(codec: ...)`, `errorJson(...)`, - `errorVersionedJson(...)`, and `errorAs(codec: ...)` so control command - result signals can decode structured response and error payloads without - raw map plumbing. -- Added `TaskExecutionContext.argsAs(...)`, `argsJson(...)`, - `argsVersionedJson(...)`, `WorkflowExecutionContext.paramsAs(...)`, - `paramsJson(...)`, `paramsVersionedJson(...)`, and the same full-payload - helpers on `WorkflowScriptContext`, so manual task/workflow code can decode - an entire DTO input without field-by-field map plumbing. -- Added `argVersionedJson(...)`, `argListVersionedJson(...)`, - `paramVersionedJson(...)`, and `paramListVersionedJson(...)` on the shared - task/workflow context helpers so nested versioned DTO fields no longer - require dropping down to raw payload-map helpers. -- Added `PayloadCodec.versionedJson(...)` so DTO payload codecs can persist a - schema version beside the JSON payload and decode older shapes explicitly. -- Added `PayloadCodec.versionedMap(...)` for versioned DTO payloads that still - need a custom map encoder or a nonstandard version-aware decode shape. -- Added `TaskEnqueuer.enqueueValue(...)` across producers and task/workflow - execution contexts so dynamic task names can still enqueue typed DTO payloads - through an explicit `PayloadCodec` without hand-built arg maps. -- Added `WorkflowRuntime.startWorkflowValue(...)` and - `StemWorkflowApp.startWorkflowValue(...)` so dynamic workflow names can start - typed DTO-backed runs through an explicit `PayloadCodec` without - hand-built param maps. -- Added `QueueEventsProducer.emitValue(...)` so queue-scoped custom events can - publish typed payloads through an explicit `PayloadCodec` without - hand-built maps. -- Added versioned low-level DTO shortcuts: - `TaskEnqueuer.enqueueVersionedJson(...)`, - `WorkflowRuntime.startWorkflowVersionedJson(...)`, - `StemWorkflowApp.startWorkflowVersionedJson(...)`, - `WorkflowRuntime.emitVersionedJson(...)`, - `StemWorkflowApp.emitVersionedJson(...)`, and - `QueueEventsProducer.emitVersionedJson(...)`. -- Added versioned workflow resume/result decode helpers: - `WorkflowExecutionContext.previousVersionedJson(...)`, - `WorkflowResumeContext.takeResumeVersionedJson(...)`, - `waitForEventValueVersionedJson(...)`, and - `waitForEventVersionedJson(...)`. -- Added `decodeVersionedJson:` to the low-level - `Stem.waitForTask`, `StemApp.waitForTask`, - `StemClient.waitForTask`, `StemWorkflowApp.waitForCompletion`, and - `WorkflowRuntime.waitForCompletion` APIs so schema-versioned DTO waits no - longer need a manual raw-payload closure. -- Added direct `versionedJson(...)` shortcuts for manual task definitions, - workflow refs, and workflow event refs so evolving DTO payloads do not need - a separate `PayloadCodec.versionedJson(...)` constant in the common case. -- Added versioned DTO decode helpers across low-level result snapshots: - `TaskStatus.payloadVersionedJson(...)`, `TaskResult.payloadVersionedJson(...)`, - `WorkflowResult.payloadVersionedJson(...)`, `GroupStatus.resultVersionedJson(...)`, - `RunState.resultVersionedJson(...)`, and - `RunState.suspensionPayloadVersionedJson(...)`. -- Added low-level DTO shortcuts for name-based dispatch: - `WorkflowRuntime.startWorkflowJson(...)`, - `StemWorkflowApp.startWorkflowJson(...)`, `WorkflowRuntime.emitJson(...)`, - and `StemWorkflowApp.emitJson(...)`, so dynamic workflow names and - workflow-event topics can still use `toJson()` inputs without hand-built - maps. -- Added `QueueEventsProducer.emitJson(...)` so queue-scoped custom events can - publish DTO payloads without hand-built maps. -- Added `QueueCustomEvent.payloadValue(...)`, - `payloadValueOr(...)`, and `requiredPayloadValue(...)` so queue-event - consumers can decode typed payload fields without raw `payload['key']` - casts. -- Added `QueueCustomEvent.payloadJson(...)`, - `payloadVersionedJson(...)`, and `payloadAs(codec: ...)` so queue-event - consumers can decode whole DTO payloads without rebuilding them field by - field. -- Added `decodeJson:` shortcuts to the low-level - `Stem.waitForTask` and `StemWorkflowApp.waitForCompletion` wait APIs, - and propagated the same task wait shortcut through `StemApp` and - `StemClient`, so DTO waits no longer need manual - `payload as Map` closures. -- Added typed task/workflow result readers: - `TaskStatus.payloadValue(...)`, `payloadValueOr(...)`, - `requiredPayloadValue(...)`, `TaskResult.valueOr(...)`, - `TaskResult.requiredValue()`, `WorkflowResult.valueOr(...)`, and - `WorkflowResult.requiredValue()` so low-level status reads and typed waits no - longer need manual nullable handling or raw payload casts. -- Added `TaskStatus.payloadJson(...)` and `payloadAs(codec: ...)` so existing - raw task-status reads can decode whole DTO payloads without another - cast/closure. -- Added `TaskResult.payloadJson(...)` and `payloadAs(codec: ...)` so raw typed - task-wait results can decode whole DTO payloads without another - cast/closure. -- Added `WorkflowResult.payloadJson(...)` and `payloadAs(codec: ...)` so raw - workflow completion results can decode whole DTO payloads without another - cast/closure. -- Added `RunState.resultJson(...)`, `resultAs(codec: ...)`, - `suspensionPayloadJson(...)`, and `suspensionPayloadAs(codec: ...)` so raw - workflow-store inspection paths can decode DTO payloads without manual - casts. -- Added `WorkflowWatcher.payloadJson(...)`, `payloadAs(codec: ...)`, - `WorkflowWatcherResolution.payloadJson(...)`, `payloadAs(codec: ...)`, and - `WorkflowStepEntry.valueJson(...)` / `valueAs(codec: ...)` so raw watcher - and checkpoint inspection paths can decode DTO payloads without manual - casts. -- Added `WorkflowRunView.resultJson(...)`, - `WorkflowRunView.resultVersionedJson(...)`, - `WorkflowRunView.suspensionPayloadJson(...)`, - `WorkflowRunView.suspensionPayloadVersionedJson(...)`, and - `WorkflowCheckpointView.valueJson(...)` / - `valueVersionedJson(...)` plus their `...As(codec: ...)` counterparts so - dashboard/CLI workflow detail views can decode DTO payloads without manual - casts. -- Added `WorkflowStepEvent.resultJson(...)`, - `resultVersionedJson(...)`, and `resultAs(codec: ...)` so workflow - introspection consumers can decode DTO checkpoint results without manual - casts. -- Added `TaskPostrunPayload.resultJson(...)`, - `resultVersionedJson(...)`, and `resultAs(codec: ...)` so task lifecycle - signal consumers can decode DTO task results without manual casts. -- Added `TaskSuccessPayload.resultJson(...)`, - `resultVersionedJson(...)`, and `resultAs(codec: ...)` so success-only - task signal consumers can decode DTO task results without manual casts. -- Added `WorkflowRunPayload.metadataValue(...)`, - `requiredMetadataValue(...)`, `metadataJson(...)`, and - `metadataAs(codec: ...)` so workflow lifecycle signal consumers can decode - structured metadata without raw map casts. -- Added `WorkflowRunPayload.metadataPayloadJson(...)`, - `metadataPayloadVersionedJson(...)`, and `metadataPayloadAs(codec: ...)` so - workflow lifecycle signal consumers can decode a whole metadata DTO without - field-by-field reads. -- Added `ProgressSignal.dataValue(...)`, `requiredDataValue(...)`, - `dataJson(...)`, and `dataAs(codec: ...)` so raw task-progress signal - consumers can decode structured progress metadata without raw map casts. -- Added `ProgressSignal.payloadJson(...)`, - `payloadVersionedJson(...)`, and `payloadAs(codec: ...)` so raw task - progress inspection can decode a whole DTO payload without field-by-field - map reads. -- Added `TaskExecutionContext` as the shared task-side execution surface for - `TaskContext` and `TaskInvocationContext`, and taught `stem_builder` to - accept it directly in annotated task definitions. -- Added `TaskExecutionContext.retry(...)` so typed task handlers can request - retries through the shared task context surface instead of depending on - concrete runtime classes. -- Added `TaskExecutionContext.spawn(...)` so the shared task context surface - now covers the common follow-up enqueue alias, including `notBefore` - forwarding. -- Updated task docs/snippets to prefer `TaskExecutionContext` for shared - enqueue/workflow/event examples, leaving `TaskContext` and - `TaskInvocationContext` only where the runtime distinction matters. -- Added `FlowStepControl.dataJson(...)`, `dataVersionedJson(...)`, and - `dataAs(codec: ...)` so lower-level suspension control objects can decode - DTO metadata without manual casts. -- Added `GroupStatus.resultValues()`, `resultJson(...)`, and - `resultAs(codec: ...)` so canvas/group status inspection can decode typed - child results without manually mapping raw `TaskStatus.payload` values. -- Added `arg()`, `argOr()`, and `requiredArg()` on `TaskContext` and - `TaskInvocationContext`, and taught both contexts to retain the current - task args so manual handlers and isolate entrypoints can read typed inputs - without threading raw `args[...]` lookups through their logic. -- Added `param()`, `paramOr()`, and `requiredParam()` on - `WorkflowExecutionContext` and `WorkflowScriptContext` so manual flows, - checkpoints, and script run methods can read typed workflow start params - without repeating `ctx.params[...]` lookups. -- Added `previousValue()` and `requiredPreviousValue()` on - `WorkflowExecutionContext` so manual flow steps and script checkpoints can - read prior persisted values without repeating raw `previousResult as ...` - casts. -- Added typed payload-map readers like `args.requiredValue(...)`, - `args.valueOr(...)`, and `ctx.params.requiredValue(...)` so manual tasks and - workflows can decode scalars and codec-backed DTOs without repeating raw map - casts. -- Added direct JSON entry readers like `args.requiredValueJson(...)`, - `ctx.requiredParamJson(...)`, and shared `valueJson(...)` / - `requiredValueJson(...)` helpers so nested DTO payload fields no longer need - a separate `PayloadCodec` constant. -- Added `Envelope.argsJson(...)`, `argsVersionedJson(...)`, `metaJson(...)`, - and `metaVersionedJson(...)` so low-level producer and signal code can - decode whole envelope DTO payloads without dropping to raw maps. -- Added typed DTO readers on isolate bridge payloads like - `TaskEnqueueRequest.argsVersionedJson(...)`, - `StartWorkflowRequest.paramsVersionedJson(...)`, - `WaitForWorkflowResponse.resultVersionedJson(...)`, and - `EmitWorkflowEventRequest.payloadVersionedJson(...)` so low-level - cross-isolate helpers no longer need manual map casts. -- Added `valueVersionedJson(...)`, `requiredValueVersionedJson(...)`, - `valueListVersionedJson(...)`, and - `requiredValueListVersionedJson(...)` to the shared payload-map helpers so - nested versioned DTO payloads no longer need custom per-surface decode - plumbing. -- Added `previousJson(...)`, `requiredPreviousJson(...)`, - `takeResumeJson(...)`, `waitForEventValueJson(...)`, and - `waitForEventJson(...)` so workflow steps and checkpoints can decode prior - DTO results and resume/event payloads without separate codec constants. -- Added `valueListJson(...)`, `requiredValueListJson(...)`, - `argListJson(...)`, and `paramListJson(...)` so nested DTO lists can be - decoded directly from durable payload maps without separate codec constants - or manual list mapping. -- Added `TaskContext.progressJson(...)` and - `TaskInvocationContext.progressJson(...)` so task progress updates can emit - DTO payloads without hand-built maps. -- Added `TaskExecutionContext.progressVersionedJson(...)` so task progress - updates can persist an explicit DTO schema version alongside the payload. -- Added `sleepJson(...)`, `sleepVersionedJson(...)`, - `awaitEventJson(...)`, `awaitEventVersionedJson(...)`, and - `FlowStepControl.awaitTopicJson(...)` so lower-level flow/script suspension - directives can carry DTO metadata without hand-built maps. -- Added `valueList()`, `valueListOr(...)`, and `requiredValueList(...)` to - the shared payload-map helpers so canvas chains/chords and other meta-driven - paths can decode typed list payloads without manual list casts. -- Added `WorkflowExecutionContext` as the shared typed execution context for - flow steps and script checkpoints, and taught `stem_builder` to accept that - shared context type directly in annotated workflow methods. -- Added `WorkflowRunPayload.metadataVersionedJson(...)` and - `ProgressSignal.dataVersionedJson(...)` so signal payloads can decode - versioned DTO metadata without manual casts. -- Simplified the manual JSON helper path so `TaskDefinition.json(...)` and - `WorkflowRef.json(...)` no longer require unused producer-side - `decodeArgs`/`decodeParams` callbacks just to publish DTO payloads. -- Added `WorkflowResumeContext` as the shared typed suspension/wait surface for - flow steps and script checkpoints. Typed workflow event waits now target that - shared interface instead of accepting an erased `Object`. -- Added `TaskDefinition.noArgsCodec(...)`, `Flow.codec(...)`, - `WorkflowScript.codec(...)`, `WorkflowDefinition.flowCodec(...)`, and - `.scriptCodec(...)` so the manual result path now has direct custom-codec - helpers alongside the newer JSON shortcuts. -- Added `WorkflowDefinition.flowJson(...)` and - `WorkflowDefinition.scriptJson(...)` so the raw definition path has the same - direct DTO-result helper as `Flow.json(...)` and `WorkflowScript.json(...)`. -- Added `TaskDefinition.noArgsJson(...)`, `Flow.json(...)`, and - `WorkflowScript.json(...)` as the shortest manual DTO result helpers for the - common `toJson()` / `Type.fromJson(...)` path. -- Relaxed manual `Flow(...)`, `WorkflowScript(...)`, and - `WorkflowDefinition.flow(...)` / `.script(...)` `decodeResultJson:` helpers - to accept `Map` DTO decoders, matching the newer - `PayloadCodec.json(...)` and `refJson(...)` surfaces. -- Renamed the caller-bound advanced child-workflow helpers from - `prepareWorkflowStart(...)` / `prepareNoArgsWorkflowStart(...)` to - `prepareStart(...)` so the caller side aligns with task-side - `prepareEnqueue(...)` and ref-side `prepareStart(...)`. -- Renamed the advanced workflow-ref builder entrypoints from `startBuilder(...)` - / `startBuilder()` to `prepareStart(...)` on `WorkflowRef` and - `NoArgsWorkflowRef` so the workflow side aligns with task-side - `prepareEnqueue(...)`. -- Relaxed JSON/codec DTO decoding helpers to accept `Map` - `fromJson(...)` signatures across manual task/workflow/event helpers and the - shared `PayloadCodec` surface. DTO payloads still persist as string-keyed - JSON-like maps. -- Removed the deprecated workflow-event compatibility helpers: - `emitWith(...)`, `emitEventBuilder(...)`, `waitForEventRef(...)`, - `waitForEventRefValue(...)`, `awaitEventRef(...)`, `waitValueWith(...)`, and - `waitWith(...)`. The direct `event.emit(...)`, `event.wait(...)`, - `event.waitValue(...)`, and `event.awaitOn(...)` surfaces are now the only - supported forms. -- Removed the deprecated workflow-start compatibility helpers: - `startWith(...)`, `startAndWaitWith(...)`, `startWorkflowBuilder(...)`, and - `startNoArgsWorkflowBuilder(...)`. The direct `start(...)`, - `startAndWait(...)`, and `prepareWorkflowStart(...)` forms are now the only - supported workflow-start surfaces. -- Removed the deprecated task-builder compatibility helpers: - `enqueueBuilder(...)` and `enqueueNoArgsBuilder(...)`. The direct - `prepareEnqueue(...)` form is now the only supported builder entrypoint. -- Removed the deprecated `withJsonCodec(...)` / `refWithJsonCodec(...)` - compatibility helpers. The direct `json(...)` / `refJson(...)` forms are now - the only supported JSON shortcut APIs. -- Removed the legacy `SimpleTaskRegistry` compatibility alias. Use - `InMemoryTaskRegistry` directly. -- Replaced the older manual DTO helper names with direct forms: - `TaskDefinition.json(...)`, `TaskDefinition.codec(...)`, - `WorkflowRef.json(...)`, `WorkflowRef.codec(...)`, `refJson(...)`, and - `refCodec(...)`. -- Added `prepareEnqueue(...)` as the clearer task-side name for advanced - enqueue builders, and deprecated the older `enqueueBuilder(...)` / - `enqueueNoArgsBuilder(...)` aliases. -- Added `prepareWorkflowStart(...)` / `prepareNoArgsWorkflowStart(...)` as the - clearer names for caller-bound workflow start builders, and deprecated the - older `startWorkflowBuilder(...)` / `startNoArgsWorkflowBuilder(...)` - aliases. -- Added `event.awaitOn(step)` for the low-level flow-control event wait path, - and deprecated `FlowContext.awaitEventRef(...)`. -- Deprecated script-step `awaitEventRef(...)` in favor of `await event.wait(ctx)`. -- Deprecated context-side `waitForEventRef(...)` and - `waitForEventRefValue(...)` in favor of `event.waitValue(ctx)` and - `event.wait(ctx)`. -- Deprecated `emitEventBuilder(...)` in favor of direct typed event calls via - `event.emit(...)`. -- Deprecated the older workflow-start `startWith(...)` and - `startAndWaitWith(...)` helpers in favor of direct `start(...)` and - `startAndWait(...)` aliases. -- Deprecated the older workflow-event `emitWith(...)`, `waitWith(...)`, and - `waitValueWith(...)` helpers in favor of the direct `emit(...)`, `wait(...)`, - and `waitValue(...)` aliases. -- Added direct workflow-event aliases `event.emit(...)`, `event.wait(...)`, - and `event.waitValue(...)`, while keeping the older `...With(...)` forms for - compatibility. -- Refreshed the cancellation-policy workflow example to use the fluent builder - `start(...)` alias instead of `startWith(...)`. -- Refreshed the remaining simple workflow examples to use direct `start(...)` - aliases instead of `startWith(...)` where no advanced overrides are needed. -- Refreshed child-workflow examples and docs to prefer the direct - `startAndWait(...)` alias over `startAndWaitWith(...)` in the common case. -- Refreshed the public workflow examples and docs snippets to prefer the direct - `start(...)` alias over the older `startWith(...)` helper in the happy path. -- Added `StemApp.registerTask(...)`, `registerTasks(...)`, `registerModule(...)`, - and `registerModules(...)` so plain task apps now have the same late - registration ergonomics as `StemWorkflowApp`. -- Added `decodeResultJson:` support to manual `refWithJsonCodec(...)` helpers - on `WorkflowDefinition`, `Flow`, and `WorkflowScript`, so DTO result - decoding can now live entirely on the typed workflow ref path. -- Added named workflow start aliases `start(...)` and `startAndWait(...)` on - workflow refs, no-args workflow refs, manual `Flow` / `WorkflowScript` - wrappers, and workflow start calls/builders. The existing - `startWith(...)` / `startAndWaitWith(...)` helpers still work. -- Added `StemModule.requiredTaskQueues()` and - `StemModule.requiredWorkflowQueues(...)` so bundle queue requirements can be - inspected directly before app/worker bootstrap. -- Added `StemModule.requiredTaskSubscription()` and - `StemModule.requiredWorkflowSubscription(...)` so low-level worker wiring can - reuse the exact subscription implied by a module without rebuilding it by - hand. -- Added `decodeResultJson:` shortcuts on manual `Flow`, `WorkflowScript`, and - `TaskDefinition.noArgs(...)` definitions so common DTO result decoding no - longer needs a separate `PayloadCodec.json(...)` constant. -- Added `TaskDefinition.withJsonCodec(...)`, `refWithJsonCodec(...)`, and - `WorkflowEventRef.json(...)` so manual DTO-backed tasks, workflows, and - typed workflow events no longer need a separate codec constant in the common - `toJson()` / `Type.fromJson(...)` case. -- Added `PayloadCodec.json(...)` as the shortest DTO helper for types that - already expose `toJson()` and `Type.fromJson(...)`, while keeping - `PayloadCodec.map(...)` for custom map encoders. -- Added `PayloadCodec.map(...)` so map-shaped workflow/task DTO codecs no - longer need handwritten `Object?` decode wrappers, and refreshed the public - typed payload docs/examples around the new helper. -- Added expression-style workflow suspension helpers with named arguments: - `await ctx.sleepFor(duration: ...)`, - `await ctx.waitForEvent(topic: ...)`, and - `await ctx.waitForEventRefValue(event: ...)` for both flow steps and script - checkpoints. -- Added direct typed workflow event wait helpers: - `await event.waitWith(ctx)` and `event.waitValueWith(ctx)` so typed workflow - events now stay on the ref surface for both emit and wait paths. -- Added `StemModule.combine(...)` plus `modules:` support across `StemApp`, - `StemWorkflowApp`, and `StemClient` bootstrap helpers so multi-module apps - no longer need to pre-merge bundles manually at every call site. -- Added `registerModules(...)` on `StemWorkflowApp` so late registration can - attach multiple generated or hand-written bundles with the same conflict - rules as `StemModule.merge(...)`. -- Added `StemModule.merge(...)` so generated and hand-written bundles can be - composed with fail-fast conflict checks instead of manual list stitching. -- Updated the public workflow event examples and docs to prefer the direct - typed ref helper `event.emitWith(emitter, value)` for simple event emission, - leaving bound event builders and prebuilt calls as lower-level variants. -- Updated the remaining README child-workflow snippet to use the direct - no-args helper `childWorkflow.startAndWaitWith(context)` instead of an - unnecessary `startBuilder()` hop. -- Updated the public workflow docs and annotated workflow example to prefer - direct child-workflow helpers like `ref.startAndWaitWith(context, value)` in - durable boundaries, keeping `startWorkflowBuilder(...)` for advanced - override cases. -- Clarified the workflow docs so direct workflow helpers and generated refs are - the default path, while `startWorkflow(...)` / `waitForCompletion(...)` are - explicitly documented as the low-level name-driven APIs. -- Added `WorkflowCaller.startWorkflowBuilder(...)` / - `startNoArgsWorkflowBuilder(...)` plus a caller-bound fluent builder so - workflow-capable contexts, apps, and runtimes can start child workflows with - the same builder-first ergonomics already used for typed task enqueue. -- Added `WorkflowEventEmitter.emitEventBuilder(...)` plus a caller-bound typed - event call so apps, runtimes, and task/workflow contexts can emit typed - workflow events without bouncing between emitter-first and ref-first styles. -- Added `TaskEnqueuer.enqueueBuilder(...)` / `enqueueNoArgsBuilder(...)` plus a - caller-bound fluent task builder so producers and contexts can enqueue typed - task calls directly from the enqueuer surface, with `enqueueAndWait()` - available whenever that enqueuer also supports typed result waits. -- Updated the public workflow event examples and docs to prefer - `emitEventBuilder(...).emit()` as the primary typed event emission path, - while keeping the older `emitWith(...)` variants documented as lower-level - alternatives. -- Updated the public no-input task examples to prefer - `TaskDefinition.noArgs(...)` plus typed `enqueue()` / `enqueueAndWait()` - helpers instead of reintroducing raw task-name strings in the happy path. -- Added `TaskDefinition.enqueueBuilder(...)` / - `NoArgsTaskDefinition.enqueueBuilder()` so typed tasks now expose the same - definition-first fluent builder pattern as typed workflow refs. -- Added direct no-args `startWith(...)`, `startAndWaitWith(...)`, - `startBuilder()`, and `waitFor(...)` helpers on manual `Flow` and - `WorkflowScript` definitions so simple workflows no longer need an extra - `ref0()` hop just to start or wait. -- Updated the public typed task examples to prefer `enqueueAndWait(...)` as the - happy path when callers only need the final typed result, while keeping - `waitFor(...)` documented for task-id-driven inspection flows. -- Updated the remaining no-args workflow examples and docs snippets to use the - new direct `Flow.startWith(...)` / `Flow.waitFor(...)` helpers instead of - creating a temporary `ref0()` only to start and wait. -- Updated the remaining no-input producer examples to prefer - `TaskDefinition.noArgs(...)` over raw empty-map publishes where the task - already has a stable typed definition. -- Updated the persistence and signals docs snippets to use the same no-input - task-definition helpers instead of raw empty-map enqueue calls. -- Updated the manual child-workflow docs to prefer - `childFlow.startBuilder().startAndWaitWith(context)` over creating a - temporary `ref0()` only to start a no-args child run. -- Made `TaskContext` and `TaskInvocationContext` implement - `WorkflowEventEmitter` when a workflow runtime is attached, so inline - handlers and isolate entrypoints can resume waiting workflows with - `emitValue(...)` or typed `WorkflowEventRef`. -- Made `TaskContext` and `TaskInvocationContext` implement - `WorkflowCaller` when a workflow runtime is attached, so inline handlers and - isolate entrypoints can start and wait for typed child workflows directly. -- Added `awaitEventRef(...)` on flow and script checkpoint resume helpers so - typed `WorkflowEventRef` values now cover both the common wait-for-value - path and the lower-level suspend-first path. -- Reframed the producer docs to treat `TaskDefinition` and shared - `TaskEnqueuer` surfaces as the happy path, keeping raw name-based enqueue as - the lower-level interop option. -- Tightened advanced builder dispatch so `TaskEnqueueBuilder` and - `WorkflowStartBuilder` now only build `TaskCall` / `WorkflowStartCall` - values; dispatch goes through direct `enqueue(...)` / `start(...)` helpers - or explicit `enqueueCall(...)` / `startWorkflowCall(...)`. -- Added direct typed workflow event helper APIs so event refs can emit - payloads without repeating raw topic strings or separate codecs. -- Added `WorkflowStartBuilder` plus `WorkflowRef.startBuilder(...)` / - `NoArgsWorkflowRef.startBuilder()` so typed workflow refs can fluently set - `parentRunId`, `ttl`, and `WorkflowCancellationPolicy` without dropping to - raw workflow-name APIs. -- Updated the public annotated workflow example and workflow docs to keep - context-aware script workflows on the direct annotated checkpoint path, - removing the redundant outer `script.step(...)` wrapper from the happy path. -- Made `StemApp` lazy-start its managed worker on first enqueue/wait and - `app.canvas` dispatch calls so in-memory and module-backed task apps no - longer need an explicit `start()` in the common case. -- Made `StemClient.createWorker(...)` infer queue subscriptions from bundled - or explicitly supplied task handlers when `workerConfig.subscription` is - omitted. -- Made raw `Stem.enqueue('task.name')` inherit handler-declared publish defaults - like queue routing, priority, visibility timeout, and retry policy when the - producer does not override them explicitly. -- Updated the public snippets and annotated workflow example to use the - high-level app surfaces directly, dropping unnecessary `start()` calls and - `app.stem` hops in the common in-memory and workflow happy paths. -- Updated the client-backed workflow examples to attach `stemModule` at - `StemClient` creation time and then call `createWorkflowApp()` without - repeating the bundle. -- Updated the `stack_autowire` example to use `StemApp` lazy-start and the - high-level `app.enqueue(...)` / `app.waitForTask(...)` surface instead of - manually starting the task app and dropping to `app.stem`. -- Updated the uniqueness snippet to show raw `app.enqueue(...)` inheriting - handler-declared queue and uniqueness defaults, while still demonstrating - explicit uniqueness-key overrides without manual worker startup. -- Simplified the `task_usage_patterns` example to use `StemApp.inMemory(...)` - instead of manually wiring `Broker`, `Worker`, and `Stem` just to demonstrate - typed task-definition enqueue and wait helpers. -- Simplified `example/stem_example.dart` and the getting-started docs that - embed it to use `StemApp.fromUrl(...)` plus typed wait helpers instead of - manually wiring broker/backend/worker instances. -- Simplified the Redis producer examples in `retry_task` and `signals_demo` to - use `StemClient.fromUrl(...)` instead of manually creating a broker and raw - `Stem` producer just to enqueue tasks. -- Simplified the TLS-aware `autoscaling_demo` and `ops_health_suite` producer - examples to use `StemClient.create(...)` with broker/backend factories - instead of hand-constructing `Stem` for publishing. -- Simplified the `worker_control_lab` and `progress_heartbeat` producer - examples to use `StemClient.create(...)` with their existing connection - helpers instead of manually wiring `Stem`. -- Added `notBefore` support to the shared `TaskEnqueuer` surface so - `StemClient`, `StemApp`, workflow contexts, and task contexts can publish - delayed tasks without dropping down to raw `Stem`. -- Updated the producer and programmatic-worker documentation snippets to use - `StemApp`/`StemClient` for their producer examples, keeping low-level worker - examples intact while reducing manual broker/backend wiring in the happy - path. -- Simplified the Postgres-backed enqueuer examples (`postgres_worker`, - `redis_postgres_worker`, and `postgres_tls`) to use `StemClient.create(...)` - with explicit broker/backend factories instead of hand-constructing `Stem` - for publishing. -- Simplified the `encrypted_payload`, `email_service`, and - `signing_key_rotation` producer/enqueuer examples to use client-backed - publishing instead of manually wiring `Stem` just to enqueue tasks. -- Simplified the `task_context_mixed` enqueue example to use - `StemClient.create(...)` with its existing SQLite broker helper instead of - manually creating a raw `Stem` producer for task submission. -- Simplified the `image_processor` HTTP API example to use - `StemClient.create(...)` with Redis broker/result backend factories instead - of manually constructing a raw `Stem` publisher service. -- Simplified the typed task-definition docs snippet to use - `StemApp.inMemory(...)` and `definition.enqueueAndWait(...)` instead of - manually wiring in-memory broker/backend instances and raw result waits. -- Simplified the `routing_parity` publisher example to use - `StemClient.create(...)` with its explicit routing registry and Redis broker - factory instead of manually constructing a raw `Stem` publisher. -- Simplified the best-practices docs snippet to use `StemApp.inMemory(...)` - and a generic `TaskEnqueuer` helper instead of manually wiring `Stem` and - `Worker` just to enqueue a single in-memory task. -- Simplified the `microservice/enqueuer` HTTP service to use - `StemClient.create(...)` for publishing while keeping `Canvas` and the - autofill controller on the client-owned broker/backend instances. -- Generalized the signing rotation docs snippet to target `TaskEnqueuer` - instead of a concrete `Stem` producer so the example teaches the shared - enqueue surface. -- Generalized the microservice enqueuer's autofill controller to target - `TaskEnqueuer` instead of a concrete `Stem`, keeping the enqueue loop on the - shared producer surface. -- Simplified the `encrypted_payload/docker` producer entrypoint to use - `StemClient.create(...)` instead of manually constructing a raw `Stem` - publisher inside the container example. -- Simplified the `rate_limit_delay` producer example to use - `StemClient.create(...)` with its existing broker/backend helpers instead of - building a raw `Stem` producer for the delayed enqueue loop. -- Simplified the `dlq_sandbox` producer example to use - `StemClient.create(...)` with its existing broker/backend helpers instead of - building a raw `Stem` producer for the dead-letter sandbox. -- Simplified the routing bootstrap docs snippet to use - `StemClient.create(...)` and `createWorker(...)` instead of manually opening - separate broker/backend pairs just to demonstrate routing subscription setup. -- Simplified the observability tracing docs snippet to use - `StemClient.inMemory(...)` instead of manually wiring in-memory broker and - backend instances for the traced producer path. -- Simplified the `otel_metrics` worker example to use - `StemClient.inMemory(...)` and `createWorker(...)` so the producer-side ping - loop no longer needs a raw `Stem` instance. -- Simplified the production signing checklist snippet to use - `StemClient.create(...)` with in-memory factories and `createWorker(...)`, - and updated the docs tab copy to describe the shared client setup instead of - raw `Stem` wiring. -- Simplified the persistence backend docs snippets to use - `StemClient.create(...)` while still showing the explicit in-memory, Redis, - Postgres, and SQLite backend choices. -- Simplified the README Redis task examples to use `StemClient.fromUrl(...)` - and `createWorker(...)` instead of manually wiring raw `Stem` and `Worker` - instances in the happy path. -- Simplified the mixed-cluster enqueuer example to use `StemClient.create(...)` - and normal client shutdown instead of returning raw `Stem` producers with - manual adapter-specific close logic. -- Simplified the developer-environment bootstrap snippet to use a shared - `StemClient` plus `createWorker(...)` while keeping the explicit Redis - adapter configuration visible. -- Simplified the unique-task example to use `StemClient.create(...)` and - `createWorker(...)` so the example stays focused on deduplication semantics - instead of raw producer wiring. -- Simplified the README unique-task deduplication snippet to use - `StemClient.create(...)` instead of a raw `Stem(...)` constructor example. -- Added `StemClient.createCanvas()` so shared-client setups can reuse the - client-owned broker/backend/registry for canvas dispatch without manually - constructing `Canvas(...)`. -- Added `getTaskStatus(...)` / `getGroupStatus(...)` to the shared - `TaskResultCaller` surface so apps and clients can inspect task/group state - without reaching into the raw result backend. -- Updated the developer environment and canvas docs to use the shared status - helpers as the default status-inspection path, keeping raw backend reads as - low-level guidance only. -- Simplified the `canvas_patterns` examples to use `StemApp.inMemory(...)`, - `app.canvas`, and the shared task/group status helpers instead of manual - broker/backend/worker wiring. -- Added `StemApp.createWorkflowApp(...)` and made `StemWorkflowApp.create( - stemApp: ...)` reuse `stemApp.module` by default while failing fast when the - reused worker does not actually cover the workflow/task queues the runtime - needs. -- Made `Canvas.group(..., groupId: ...)` auto-initialize missing groups so - custom group ids no longer require a manual `backend.initGroup(...)` first. -- Added `StemWorkflowApp.viewRunDetail(...)` so app-level workflow inspection - no longer needs to reach through `workflowApp.runtime`. -- Added `StemWorkflowApp.executeRun(...)` so examples and app code can drive a - workflow run directly without reaching through `workflowApp.runtime`. -- Added `StemWorkflowApp.viewRun(...)`, `viewCheckpoints(...)`, and - `listRunViews(...)` so app-level workflow inspection can stay on the app - surface instead of reaching through `workflowApp.runtime`. -- Added `StemWorkflowApp.workflowManifest()` so manifest inspection can stay on - the app surface instead of reaching through `workflowApp.runtime`. -- Added `StemWorkflowApp.registerModule(...)` so manual builder/module - registration no longer needs to reach through `app.runtime.registry` and - `app.app.registry`. -- Added `StemWorkflowApp.registerWorkflow(...)` so manual workflow definition - registration no longer needs to reach through `workflowApp.runtime`. -- Added `StemWorkflowApp.registerFlow(...)` and `registerScript(...)` so manual - flow/script registration no longer needs to pass `.definition`. -- Added `StemWorkflowApp.registerWorkflows(...)` so manual bulk definition - registration no longer needs repeated helper calls. -- Added `StemWorkflowApp.registerFlows(...)` and `registerScripts(...)` so - manual bulk registration no longer needs repeated helper calls. -- Added `continuationQueue:` and `executionQueue:` to `StemWorkflowApp` - bootstrap helpers so custom workflow channel wiring no longer needs manual - `WorkflowRuntime` construction. -- Clarified the README workflow guidance to prefer `StemWorkflowApp` - helpers in the happy path and reserve direct `WorkflowRuntime` usage for - low-level scenarios. -- Refreshed workflow docs snippets to match the app-level workflow helper - surface, including `resumeDueRuns(...)` guidance for fake-clock tests. -- Documented the new bulk registration helpers and custom workflow queue - options in the README and workflow guide. -- Added `StemWorkflowApp.rewindToCheckpoint(...)` so replay-oriented flows no - longer need to call `store.rewindToStep(...)` and `store.markRunning(...)` - directly. -- Added `StemWorkflowApp.listWatchers(...)` so event-watcher inspection no - longer needs to reach through `app.store`. -- Added `StemWorkflowApp.resumeDueRuns(...)` so sleep-based workflows no longer - need to call `store.dueRuns(...)`, `store.get(...)`, and `store.markResumed( - ...)` directly. -- Removed the remaining `client.stem` leak from the microservice enqueuer - example and clarified in the README/docs that `FlowContext` and - `WorkflowScriptStepContext` share the same child-workflow helper surface. -- Clarified in the worker-programmatic and signing docs that the remaining raw - `Stem` examples are intentionally the lower-level embedding path, not the - default happy path. -- Removed the now-unused raw `Stem` helper constructors from the DLQ sandbox - and rate-limit delay shared libraries after those demos moved to - `StemClient`-based producers. -- Flattened single-argument generated workflow/task refs and helper calls so - one-field annotated workflows/tasks now use direct values instead of - synthetic named-record wrappers in generated APIs, examples, and docs. -- Added `StemModule`, typed `WorkflowRef`/`WorkflowStartCall` helpers, and bundle-first `StemWorkflowApp`/`StemClient` composition for generated workflow and task definitions. -- Added `PayloadCodec`, typed workflow resume helpers, codec-backed workflow checkpoint/result persistence, typed task result waiting, and typed workflow event emit helpers for DTO-shaped payloads. -- Made `FlowContext` and `WorkflowScriptStepContext` implement `WorkflowCaller` - directly so child workflows can start and wait through - `WorkflowStartCall.startWith(...)` / `startAndWaitWith(...)` without special - context-only helper variants. -- Removed the redundant `startWithContext(...)` / - `startAndWaitWithContext(...)` workflow-ref helpers and the separate - `WorkflowChildCallerContext` contract so child workflow starts consistently - target the direct `WorkflowCaller` surface. -- Added `WorkflowEventEmitter` plus the unified `WorkflowEventRef.emitWith(...)` - helper so typed workflow events no longer branch between app-specific and - runtime-specific dispatch helpers. -- Removed the redundant app/runtime-specific workflow helper wrappers - (`startWithApp`, `startWithRuntime`, `startAndWaitWithApp`, - `startAndWaitWithRuntime`, `waitForWithRuntime`) so workflow refs and start - calls consistently use the generic `startWith(...)`, `startAndWaitWith(...)`, - and `waitFor(...)` surface. -- Simplified generated annotated task usage so `StemTaskDefinitions.*` is the - canonical surface, reusing shared `TaskCall.enqueue(...)` and - `TaskDefinition.waitFor(...)` helpers instead of emitting separate generated - enqueue/wait extension APIs. -- Added `WorkflowStartCall.startWith(...)` so workflow refs can dispatch - uniformly through apps, runtimes, and child-workflow callers instead of - dropping back to `startWorkflowRef(...)` in durable workflow code. -- Added `Flow.ref(...)` / `WorkflowScript.ref(...)` helpers so manual workflow - definitions can derive typed workflow refs without repeating workflow-name - strings or manual result decoder wiring. -- Updated the manual workflow examples and docs to start runs through typed - refs instead of repeating raw workflow-name strings in the happy path. -- Added `NoArgsWorkflowRef` plus `Flow.ref0()` / `WorkflowScript.ref0()` so - zero-input workflows can start directly without passing `const {}`. -- Added workflow manifests, runtime metadata views, and run/step drilldown APIs - for inspecting workflow definitions and persisted execution state. -- Clarified the workflow authoring model by distinguishing flow steps from - script checkpoints in manifests, docs, dashboard wording, and generated - workflow output. -- Improved workflow store contracts and runtime compatibility for caller- - supplied run ids and persisted runtime metadata attached to workflow params. -- Restored the deprecated `SimpleTaskRegistry` alias for source compatibility - and fixed workflow continuation routing to honor persisted queue metadata - when resuming suspended runs after runtime configuration changes. -- Added `tasks:`-first wiring across `Stem`, `Worker`, `Canvas`, and - `StemWorkflowApp`, removing the need for manual default-registry setup in - normal application code and examples. -- Renamed the default in-memory task registry surface to - `InMemoryTaskRegistry` and refreshed docs/examples to teach `tasks: [...]` - rather than explicit registry construction. -- Improved workflow logging with richer run/step context on worker lifecycle - lines plus enqueue/suspend/fail/complete runtime events. -- Exported logging types from `package:stem/stem.dart`, including `Level`, - `Logger`, and `Context`. -- Added an end-to-end ecommerce workflow example using mixed annotated/manual - workflows, `StemWorkflowApp`, and Ormed-backed SQLite models/migrations. -- Expanded span attribution across enqueue/consume/execute with task identity, - queue, worker, host, lineage, namespace, and workflow step metadata - (`run_id`, `step`, `step_id`, `step_index`, `step_attempt`, `iteration`). -- Improved worker retry republish behavior to preserve optional payload signing - when retrying deliveries. -- Added workflow metadata quality-of-life getters and watcher/run-state helpers - to make workflow introspection easier from task metadata. -- Strengthened tracing and workflow-related test coverage for metadata - propagation and contract behavior. -- Expanded the microservice example with richer workload generation, queue - diversity, updated scheduler/demo flows, and full local observability wiring - for Jaeger/Prometheus/Grafana through nginx. -- Improved bootstrap DX with explicit fail-fast errors across broker/backend/ - workflow/schedule/lock/revoke resolution paths in `StemStack.fromUrl`, - including actionable hints when adapters support a URL but do not implement - the requested store kind. -- Refreshed docs to lead with `StemClient` and document adapter-focused Task - workflows. -- Aligned in-memory broker and result backend semantics with shared adapter - contracts, including broadcast fan-out behavior for in-memory broker tests. -- Added Taskfile support for package-scoped test orchestration. -- Added Taskfile-based workflows for complex examples (microservice, encrypted - payloads, signing key rotation, security profiles, and Postgres TLS), - including secret/certificate bootstrap and binary build/run helpers. -- Added shared logger injection via `setStemLogger` and reusable structured - context helpers for consistent logging metadata across core components. +- Narrowed the public task and workflow invocation APIs around direct + `enqueue(...)` / `enqueueAndWait(...)` and `start(...)` / `startAndWait(...)` + calls, with explicit transport objects left as the advanced low-level path. +- Removed duplicate transport helpers and wrapper builder entrypoints such as + `.call(...)`, `prepareStart(...)`, `prepareEnqueue(...)`, builder dispatch + methods, and `copyWith(...)` on transport objects. +- Added shared execution-context interfaces for workflows and tasks so manual + handlers and checkpoints can use one typed context surface instead of several + partially overlapping ones. +- Added expression-style suspension and event APIs for workflows, plus direct + typed event emit/wait helpers on workflow event refs. +- Added module-first bootstrap improvements including module merge/combine, + inferred worker subscriptions, queue/subscription inspection helpers, and + shared app/client workflow bootstrap helpers. +- Expanded manual serialization support with `json(...)`, `versionedJson(...)`, + `versionedMap(...)`, registry-backed versioned factories, and codec-backed + low-level publish/start/emit helpers for tasks, workflows, and queue events. +- Added broad typed decode helpers across runtime, inspection, signal, queue, + status, and context surfaces so DTO reads no longer require raw map casts in + the common path. +- Refreshed examples and docs to use the narrowed happy-path APIs and to treat + transport objects as explicit advanced APIs rather than peer entrypoints. ## 0.1.0 diff --git a/packages/stem/pubspec.yaml b/packages/stem/pubspec.yaml index a7851561..1eba4da9 100644 --- a/packages/stem/pubspec.yaml +++ b/packages/stem/pubspec.yaml @@ -1,6 +1,6 @@ name: stem description: "Stem is a Dart-native background job platform with Redis Streams, retries, scheduling, observability, and security tooling." -version: 0.1.1 +version: 0.2.0 repository: https://github.com/kingwill101/stem resolution: workspace environment: diff --git a/packages/stem/test/unit/core/stem_event_test.dart b/packages/stem/test/unit/core/stem_event_test.dart index a9880d58..8dcc0e78 100644 --- a/packages/stem/test/unit/core/stem_event_test.dart +++ b/packages/stem/test/unit/core/stem_event_test.dart @@ -132,7 +132,7 @@ void main() { expect( event.metadataVersionedJson<_StepMetadata>( 'worker', - version: 2, + defaultVersion: 2, decode: _StepMetadata.fromVersionedJson, ), isA<_StepMetadata>().having( @@ -153,7 +153,7 @@ void main() { ); expect( event.metadataPayloadVersionedJson<_StepMetadata>( - version: 2, + defaultVersion: 2, decode: _StepMetadata.fromVersionedJson, ), isA<_StepMetadata>().having( @@ -194,7 +194,7 @@ void main() { expect( event.metadataVersionedJson<_RuntimeMetadata>( 'detail', - version: 2, + defaultVersion: 2, decode: _RuntimeMetadata.fromVersionedJson, ), isA<_RuntimeMetadata>().having( @@ -215,7 +215,7 @@ void main() { ); expect( event.metadataPayloadVersionedJson<_RuntimeMetadata>( - version: 2, + defaultVersion: 2, decode: _RuntimeMetadata.fromVersionedJson, ), isA<_RuntimeMetadata>().having( diff --git a/packages/stem_adapter_tests/pubspec.yaml b/packages/stem_adapter_tests/pubspec.yaml index 0819484c..01f091c6 100644 --- a/packages/stem_adapter_tests/pubspec.yaml +++ b/packages/stem_adapter_tests/pubspec.yaml @@ -7,7 +7,7 @@ environment: sdk: ">=3.9.2 <4.0.0" dependencies: - stem: ^0.1.1 + stem: ^0.2.0 test: ^1.29.0 dev_dependencies: diff --git a/packages/stem_builder/pubspec.yaml b/packages/stem_builder/pubspec.yaml index 0868c198..300d61db 100644 --- a/packages/stem_builder/pubspec.yaml +++ b/packages/stem_builder/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: dart_style: ^3.1.4 glob: ^2.1.3 source_gen: ^4.1.2 - stem: ^0.1.1 + stem: ^0.2.0 dev_dependencies: build_runner: ^2.10.5 diff --git a/packages/stem_cli/pubspec.yaml b/packages/stem_cli/pubspec.yaml index 6740cfe5..ede5f621 100644 --- a/packages/stem_cli/pubspec.yaml +++ b/packages/stem_cli/pubspec.yaml @@ -8,7 +8,7 @@ environment: dependencies: artisanal: ^0.2.0 - stem: ^0.1.0 + stem: ^0.2.0 stem_redis: ^0.1.0 stem_postgres: ^0.1.0 stem_sqlite: ^0.1.0 diff --git a/packages/stem_memory/pubspec.yaml b/packages/stem_memory/pubspec.yaml index f8aa99d8..cdf0dfb2 100644 --- a/packages/stem_memory/pubspec.yaml +++ b/packages/stem_memory/pubspec.yaml @@ -8,7 +8,7 @@ environment: dependencies: collection: ^1.19.1 - stem: ^0.1.1 + stem: ^0.2.0 uuid: ^4.5.2 dev_dependencies: diff --git a/packages/stem_postgres/pubspec.yaml b/packages/stem_postgres/pubspec.yaml index 742045af..658c02ad 100644 --- a/packages/stem_postgres/pubspec.yaml +++ b/packages/stem_postgres/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: ormed_postgres: ^0.2.0 path: ^1.9.1 postgres: ^3.5.9 - stem: ^0.1.1 + stem: ^0.2.0 uuid: ^4.5.2 dev_dependencies: diff --git a/packages/stem_redis/pubspec.yaml b/packages/stem_redis/pubspec.yaml index e2847326..fde9cf43 100644 --- a/packages/stem_redis/pubspec.yaml +++ b/packages/stem_redis/pubspec.yaml @@ -10,7 +10,7 @@ dependencies: async: ^2.13.0 collection: ^1.19.1 redis: ^4.0.0 - stem: ^0.1.1 + stem: ^0.2.0 uuid: ^4.5.2 dev_dependencies: diff --git a/packages/stem_sqlite/pubspec.yaml b/packages/stem_sqlite/pubspec.yaml index cad34eb3..774daa4a 100644 --- a/packages/stem_sqlite/pubspec.yaml +++ b/packages/stem_sqlite/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: ormed: ^0.2.0 ormed_sqlite: ^0.2.0 path: ^1.9.1 - stem: ^0.1.1 + stem: ^0.2.0 uuid: ^4.5.2 dev_dependencies: From 34defb1b95132e155fe285500064492fabc47e0b Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Sat, 21 Mar 2026 03:18:48 -0500 Subject: [PATCH 289/302] Add stack-based StemClient bootstrap --- packages/stem/CHANGELOG.md | 3 + packages/stem/README.md | 17 +++ .../stem/lib/src/bootstrap/stem_client.dart | 103 +++++++++++++++--- .../stem/test/bootstrap/stem_client_test.dart | 40 ++++++- .../stem/test/bootstrap/stem_stack_test.dart | 22 ++++ 5 files changed, 170 insertions(+), 15 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index d102a20c..cf282845 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.2.0 +- Added `StemClient.fromStack(...)` and `StemStack.createClient(...)` so + adapter-resolved broker/backend stacks have the same direct bootstrap path + as the higher-level app helpers. - Narrowed the public task and workflow invocation APIs around direct `enqueue(...)` / `enqueueAndWait(...)` and `start(...)` / `startAndWait(...)` calls, with explicit transport objects left as the advanced low-level path. diff --git a/packages/stem/README.md b/packages/stem/README.md index 6363ddeb..cf35bb85 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -109,6 +109,23 @@ final client = await StemClient.fromUrl( ); ``` +If adapter resolution already happens elsewhere, reuse the resolved stack +directly: + +```dart +final stack = StemStack.fromUrl( + 'redis://localhost:6379', + adapters: const [StemRedisAdapter()], + overrides: const StemStoreOverrides( + backend: 'redis://localhost:6379/1', + ), +); + +final client = await stack.createClient( + tasks: [HelloTask()], +); +``` + ### Direct enqueue (map-based) ```dart diff --git a/packages/stem/lib/src/bootstrap/stem_client.dart b/packages/stem/lib/src/bootstrap/stem_client.dart index a2d6017b..cb08d883 100644 --- a/packages/stem/lib/src/bootstrap/stem_client.dart +++ b/packages/stem/lib/src/bootstrap/stem_client.dart @@ -92,9 +92,6 @@ abstract class StemClient implements TaskResultCaller { /// /// This resolves broker/backend factories via [StemStack.fromUrl] so callers /// can avoid manual factory wiring for common Redis/Postgres/SQLite setups. - /// - /// When [stack] is supplied, the client reuses that pre-resolved adapter - /// stack instead of resolving broker/backend factories from [url] again. static Future fromUrl( String url, { StemModule? module, @@ -114,23 +111,62 @@ abstract class StemClient implements TaskResultCaller { TaskPayloadEncoder resultEncoder = const JsonTaskPayloadEncoder(), TaskPayloadEncoder argsEncoder = const JsonTaskPayloadEncoder(), Iterable additionalEncoders = const [], - StemStack? stack, }) { - final resolvedStack = - stack ?? - StemStack.fromUrl( - url, - adapters: adapters, - overrides: overrides, - ); + final stack = StemStack.fromUrl( + url, + adapters: adapters, + overrides: overrides, + ); + return fromStack( + stack, + module: module, + modules: modules, + tasks: tasks, + taskRegistry: taskRegistry, + workflowRegistry: workflowRegistry, + routing: routing, + retryStrategy: retryStrategy, + uniqueTaskCoordinator: uniqueTaskCoordinator, + middleware: middleware, + signer: signer, + defaultWorkerConfig: defaultWorkerConfig, + encoderRegistry: encoderRegistry, + resultEncoder: resultEncoder, + argsEncoder: argsEncoder, + additionalEncoders: additionalEncoders, + ); + } + + /// Creates a client from a pre-resolved [StemStack]. + /// + /// Use this when adapter resolution is managed elsewhere and the client + /// should reuse that broker/backend stack directly. + static Future fromStack( + StemStack stack, { + StemModule? module, + Iterable modules = const [], + Iterable> tasks = const [], + TaskRegistry? taskRegistry, + WorkflowRegistry? workflowRegistry, + RoutingRegistry? routing, + RetryStrategy? retryStrategy, + UniqueTaskCoordinator? uniqueTaskCoordinator, + Iterable middleware = const [], + PayloadSigner? signer, + StemWorkerConfig defaultWorkerConfig = const StemWorkerConfig(), + TaskPayloadEncoderRegistry? encoderRegistry, + TaskPayloadEncoder resultEncoder = const JsonTaskPayloadEncoder(), + TaskPayloadEncoder argsEncoder = const JsonTaskPayloadEncoder(), + Iterable additionalEncoders = const [], + }) { return create( module: module, modules: modules, tasks: tasks, taskRegistry: taskRegistry, workflowRegistry: workflowRegistry, - broker: resolvedStack.broker, - backend: resolvedStack.backend, + broker: stack.broker, + backend: stack.backend, routing: routing, retryStrategy: retryStrategy, uniqueTaskCoordinator: uniqueTaskCoordinator, @@ -524,3 +560,44 @@ class _DefaultStemClient extends StemClient { await disposeBackend(); } } + +/// Convenience helpers for bootstrapping clients from a resolved [StemStack]. +extension StemStackClientBootstrap on StemStack { + /// Creates a client using this resolved broker/backend stack. + Future createClient({ + StemModule? module, + Iterable modules = const [], + Iterable> tasks = const [], + TaskRegistry? taskRegistry, + WorkflowRegistry? workflowRegistry, + RoutingRegistry? routing, + RetryStrategy? retryStrategy, + UniqueTaskCoordinator? uniqueTaskCoordinator, + Iterable middleware = const [], + PayloadSigner? signer, + StemWorkerConfig defaultWorkerConfig = const StemWorkerConfig(), + TaskPayloadEncoderRegistry? encoderRegistry, + TaskPayloadEncoder resultEncoder = const JsonTaskPayloadEncoder(), + TaskPayloadEncoder argsEncoder = const JsonTaskPayloadEncoder(), + Iterable additionalEncoders = const [], + }) { + return StemClient.fromStack( + this, + module: module, + modules: modules, + tasks: tasks, + taskRegistry: taskRegistry, + workflowRegistry: workflowRegistry, + routing: routing, + retryStrategy: retryStrategy, + uniqueTaskCoordinator: uniqueTaskCoordinator, + middleware: middleware, + signer: signer, + defaultWorkerConfig: defaultWorkerConfig, + encoderRegistry: encoderRegistry, + resultEncoder: resultEncoder, + argsEncoder: argsEncoder, + additionalEncoders: additionalEncoders, + ); + } +} diff --git a/packages/stem/test/bootstrap/stem_client_test.dart b/packages/stem/test/bootstrap/stem_client_test.dart index 1f79e1f1..1f0d594c 100644 --- a/packages/stem/test/bootstrap/stem_client_test.dart +++ b/packages/stem/test/bootstrap/stem_client_test.dart @@ -512,9 +512,45 @@ void main() { ), ], ); + final client = await StemClient.fromStack( + stack, + tasks: [handler], + ); + + final worker = await client.createWorker(); + await worker.start(); + try { + final result = await definition.enqueueAndWait( + client, + timeout: const Duration(seconds: 2), + ); + expect(result?.value, 'ok'); + } finally { + await worker.shutdown(); + await client.close(); + } + }); + + test('StemClient fromUrl delegates to the same stack-backed path', () async { + final handler = FunctionTaskHandler( + name: 'client.from-url.delegates', + entrypoint: (context, args) async => 'ok', + ); + final definition = TaskDefinition.noArgs( + name: 'client.from-url.delegates', + ); final client = await StemClient.fromUrl( - 'memory://ignored', - stack: stack, + 'test://localhost', + adapters: [ + TestStoreAdapter( + scheme: 'test', + adapterName: 'client-test-adapter', + broker: StemBrokerFactory(create: () async => InMemoryBroker()), + backend: StemBackendFactory( + create: () async => InMemoryResultBackend(), + ), + ), + ], tasks: [handler], ); diff --git a/packages/stem/test/bootstrap/stem_stack_test.dart b/packages/stem/test/bootstrap/stem_stack_test.dart index 54fd37a6..d41d23e0 100644 --- a/packages/stem/test/bootstrap/stem_stack_test.dart +++ b/packages/stem/test/bootstrap/stem_stack_test.dart @@ -45,6 +45,28 @@ void main() { expect(workflowStore, isA()); }); + test('can create a client from a resolved stack', () async { + final stack = StemStack.fromUrl('memory://'); + final handler = FunctionTaskHandler( + name: 'stack.client.task', + entrypoint: (context, args) async => 'ok', + ); + final definition = TaskDefinition.noArgs(name: 'stack.client.task'); + final client = await stack.createClient(tasks: [handler]); + final worker = await client.createWorker(); + await worker.start(); + try { + final result = await definition.enqueueAndWait( + client, + timeout: const Duration(seconds: 2), + ); + expect(result?.value, 'ok'); + } finally { + await worker.shutdown(); + await client.close(); + } + }); + test('honors overrides for specific stores', () { final fooBroker = StemBrokerFactory( create: () async => InMemoryBroker(), From 8b64df07754a4f974ea3216dbd2f8cf166227523 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Sat, 21 Mar 2026 03:59:22 -0500 Subject: [PATCH 290/302] Prefer client bootstrap in public docs --- .site/docs/core-concepts/persistence.md | 5 ++ .site/docs/core-concepts/producer.md | 4 ++ .../getting-started/developer-environment.md | 5 ++ packages/stem/README.md | 35 +++++--------- .../lib/developer_environment.dart | 47 +++++++------------ .../docs_snippets/lib/persistence.dart | 47 +++++++------------ .../example/docs_snippets/lib/producer.dart | 13 ++--- .../lib/workers_programmatic.dart | 13 ++--- 8 files changed, 67 insertions(+), 102 deletions(-) diff --git a/.site/docs/core-concepts/persistence.md b/.site/docs/core-concepts/persistence.md index 905fdd9e..63956347 100644 --- a/.site/docs/core-concepts/persistence.md +++ b/.site/docs/core-concepts/persistence.md @@ -9,6 +9,11 @@ Use persistence when you need durable task state, workflow state, shared schedules, or revocation storage. Stem ships with Redis, Postgres, and SQLite adapters plus in-memory variants for local development. +For the normal path, prefer `StemClient.inMemory(...)`, +`StemClient.fromUrl(...)`, or a reusable `StemStack.fromUrl(...).createClient(...)`. +Drop to `StemClient.create(...)` only when you really need custom broker or +backend factories that the adapter stack cannot express. + import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; diff --git a/.site/docs/core-concepts/producer.md b/.site/docs/core-concepts/producer.md index f6e4b9a1..168b318c 100644 --- a/.site/docs/core-concepts/producer.md +++ b/.site/docs/core-concepts/producer.md @@ -9,6 +9,10 @@ Enqueue tasks from your Dart services through a `TaskEnqueuer` surface such as `StemClient`, `StemApp`, or `StemWorkflowApp`. Start with the in-memory broker, then opt into Redis/Postgres as needed. +For adapter-backed deployments, prefer `StemClient.fromUrl(...)` or +`StemStack.fromUrl(...).createClient(...)`. Keep `StemClient.create(...)` for +the rarer case where you must provide custom factories directly. + import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; diff --git a/.site/docs/getting-started/developer-environment.md b/.site/docs/getting-started/developer-environment.md index 8765ad1f..5328d30c 100644 --- a/.site/docs/getting-started/developer-environment.md +++ b/.site/docs/getting-started/developer-environment.md @@ -66,6 +66,11 @@ piece is easy to scan and reuse: Together, these steps give you access to routing, rate limiting, revoke storage, and queue configuration—all backed by Redis. +The recommended pattern here is to resolve a `StemStack` from the environment +once, build a shared `StemClient` from that stack, and then layer workers or +workflow apps on top. Manual broker/backend factory wiring is the fallback +path, not the default. + ## 3. Launch Workers, Beat, and Producers With the environment configured, run Stem components from separate terminals: diff --git a/packages/stem/README.md b/packages/stem/README.md index cf35bb85..0b62b315 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -79,21 +79,8 @@ Future main() async { `StemClient.createWorker(...)` infers queue subscriptions from the bundled or explicitly supplied task handlers when `workerConfig.subscription` is omitted. -For persistent adapters, keep `StemClient` as the entrypoint and resolve -broker/backend wiring from a URL: - -```dart -import 'package:stem/stem.dart'; -import 'package:stem_redis/stem_redis.dart'; - -final client = await StemClient.create( - broker: redisBrokerFactory('redis://localhost:6379'), - backend: redisResultBackendFactory('redis://localhost:6379/1'), - tasks: [HelloTask()], -); -``` - -or use the lower-boilerplate URL helper: +For persistent adapters, keep `StemClient` as the entrypoint and resolve the +broker/backend stack from a URL: ```dart import 'package:stem/stem.dart'; @@ -110,7 +97,7 @@ final client = await StemClient.fromUrl( ``` If adapter resolution already happens elsewhere, reuse the resolved stack -directly: +directly instead of rebuilding factories by hand: ```dart final stack = StemStack.fromUrl( @@ -126,6 +113,9 @@ final client = await stack.createClient( ); ``` +Reach for `StemClient.create(...)` only when the store factories are genuinely +custom and cannot be expressed through `StemStack.fromUrl(...)`. + ### Direct enqueue (map-based) ```dart @@ -1265,14 +1255,11 @@ final unique = UniqueTaskCoordinator( defaultTtl: const Duration(minutes: 5), ); -final client = await StemClient.create( - broker: StemBrokerFactory( - create: () => RedisStreamsBroker.connect('redis://localhost:6379'), - dispose: (broker) => broker.close(), - ), - backend: StemBackendFactory( - create: () => RedisResultBackend.connect('redis://localhost:6379/1'), - dispose: (backend) => backend.close(), +final client = await StemClient.fromUrl( + 'redis://localhost:6379', + adapters: const [StemRedisAdapter()], + overrides: const StemStoreOverrides( + backend: 'redis://localhost:6379/1', ), tasks: [OrdersSyncTask()], uniqueTaskCoordinator: unique, diff --git a/packages/stem/example/docs_snippets/lib/developer_environment.dart b/packages/stem/example/docs_snippets/lib/developer_environment.dart index 6b8027db..bb4f13a9 100644 --- a/packages/stem/example/docs_snippets/lib/developer_environment.dart +++ b/packages/stem/example/docs_snippets/lib/developer_environment.dart @@ -13,28 +13,22 @@ Future bootstrapStem(List> tasks) async { // #endregion dev-env-config // #region dev-env-adapters - final broker = StemBrokerFactory( - create: () => RedisStreamsBroker.connect(config.brokerUrl, tls: config.tls), - dispose: (broker) => broker.close(), - ); - final backend = StemBackendFactory( - create: () => RedisResultBackend.connect( - _resolveRedisUrl(config.brokerUrl, config.resultBackendUrl, 1), - tls: config.tls, + final stack = StemStack.fromUrl( + config.brokerUrl, + adapters: const [StemRedisAdapter()], + overrides: StemStoreOverrides( + backend: _resolveRedisUrl(config.brokerUrl, config.resultBackendUrl, 1), + revoke: _resolveRedisUrl(config.brokerUrl, config.revokeStoreUrl, 2), ), - dispose: (backend) => backend.close(), - ); - final revokeStore = await RedisRevokeStore.connect( - _resolveRedisUrl(config.brokerUrl, config.revokeStoreUrl, 2), + requireRevokeStore: true, ); + final revokeStore = await stack.revokeStore!.create(); final routing = await _loadRoutingRegistry(config); final rateLimiter = await connectRateLimiter(config); // #endregion dev-env-adapters // #region dev-env-stem - final client = await StemClient.create( - broker: broker, - backend: backend, + final client = await stack.createClient( tasks: tasks, routing: routing, ); @@ -115,25 +109,20 @@ Future runCanvasFlows( // #region dev-env-status Future inspectChordStatus(String chordId) async { final config = StemConfig.fromEnvironment(Platform.environment); - final client = await StemClient.create( - broker: StemBrokerFactory( - create: () => RedisStreamsBroker.connect(config.brokerUrl, tls: config.tls), - dispose: (broker) => broker.close(), - ), - backend: StemBackendFactory( - create: () => RedisResultBackend.connect( - _resolveRedisUrl( - config.brokerUrl, - config.resultBackendUrl, - 1, - ), - tls: config.tls, + final client = await StemClient.fromUrl( + config.brokerUrl, + adapters: const [StemRedisAdapter()], + overrides: StemStoreOverrides( + backend: _resolveRedisUrl( + config.brokerUrl, + config.resultBackendUrl, + 1, ), - dispose: (backend) => backend.close(), ), ); final status = await client.getTaskStatus(chordId); print('Chord completion state: ${status?.state}'); + await client.close(); } // #endregion dev-env-status diff --git a/packages/stem/example/docs_snippets/lib/persistence.dart b/packages/stem/example/docs_snippets/lib/persistence.dart index 9a6d7ab1..2a51f13f 100644 --- a/packages/stem/example/docs_snippets/lib/persistence.dart +++ b/packages/stem/example/docs_snippets/lib/persistence.dart @@ -23,11 +23,7 @@ final demoTasks = [ // #region persistence-backend-in-memory Future connectInMemoryBackend() async { - final client = await StemClient.create( - broker: StemBrokerFactory.inMemory(), - backend: StemBackendFactory.inMemory(), - tasks: demoTasks, - ); + final client = await StemClient.inMemory(tasks: demoTasks); await demoTaskDefinition.enqueue(client); await client.close(); } @@ -35,14 +31,11 @@ Future connectInMemoryBackend() async { // #region persistence-backend-redis Future connectRedisBackend() async { - final client = await StemClient.create( - broker: StemBrokerFactory( - create: () => RedisStreamsBroker.connect('redis://localhost:6379'), - dispose: (broker) => broker.close(), - ), - backend: StemBackendFactory( - create: () => RedisResultBackend.connect('redis://localhost:6379/1'), - dispose: (backend) => backend.close(), + final client = await StemClient.fromUrl( + 'redis://localhost:6379', + adapters: const [StemRedisAdapter()], + overrides: const StemStoreOverrides( + backend: 'redis://localhost:6379/1', ), tasks: demoTasks, ); @@ -53,16 +46,11 @@ Future connectRedisBackend() async { // #region persistence-backend-postgres Future connectPostgresBackend() async { - final client = await StemClient.create( - broker: StemBrokerFactory( - create: () => RedisStreamsBroker.connect('redis://localhost:6379'), - dispose: (broker) => broker.close(), - ), - backend: StemBackendFactory( - create: () => PostgresResultBackend.connect( - connectionString: 'postgres://postgres:postgres@localhost:5432/stem', - ), - dispose: (backend) => backend.close(), + final client = await StemClient.fromUrl( + 'redis://localhost:6379', + adapters: const [StemRedisAdapter(), StemPostgresAdapter()], + overrides: const StemStoreOverrides( + backend: 'postgres://postgres:postgres@localhost:5432/stem', ), tasks: demoTasks, ); @@ -73,14 +61,11 @@ Future connectPostgresBackend() async { // #region persistence-backend-sqlite Future connectSqliteBackend() async { - final client = await StemClient.create( - broker: StemBrokerFactory( - create: () => SqliteBroker.open(File('stem_broker.sqlite')), - dispose: (broker) => broker.close(), - ), - backend: StemBackendFactory( - create: () => SqliteResultBackend.open(File('stem_backend.sqlite')), - dispose: (backend) => backend.close(), + final client = await StemClient.fromUrl( + 'sqlite:///${File('stem_broker.sqlite').absolute.path}', + adapters: const [StemSqliteAdapter()], + overrides: StemStoreOverrides( + backend: 'sqlite:///${File('stem_backend.sqlite').absolute.path}', ), tasks: demoTasks, ); diff --git a/packages/stem/example/docs_snippets/lib/producer.dart b/packages/stem/example/docs_snippets/lib/producer.dart index 4e64b474..efdb6357 100644 --- a/packages/stem/example/docs_snippets/lib/producer.dart +++ b/packages/stem/example/docs_snippets/lib/producer.dart @@ -78,15 +78,10 @@ Future enqueueWithSigning() async { }, ), ]; - final client = await StemClient.create( - broker: StemBrokerFactory( - create: () => RedisStreamsBroker.connect( - config.brokerUrl, - tls: config.tls, - ), - dispose: (broker) => broker.close(), - ), - backend: StemBackendFactory.inMemory(), + final client = await StemClient.fromUrl( + config.brokerUrl, + adapters: const [StemRedisAdapter()], + overrides: const StemStoreOverrides(backend: 'memory://'), tasks: tasks, signer: PayloadSigner.maybe(config.signing), ); diff --git a/packages/stem/example/docs_snippets/lib/workers_programmatic.dart b/packages/stem/example/docs_snippets/lib/workers_programmatic.dart index a33ac95e..8123dcc3 100644 --- a/packages/stem/example/docs_snippets/lib/workers_programmatic.dart +++ b/packages/stem/example/docs_snippets/lib/workers_programmatic.dart @@ -67,15 +67,10 @@ Future redisProducer() async { Future signedProducer() async { final config = StemConfig.fromEnvironment(); final signer = PayloadSigner.maybe(config.signing); - final client = await StemClient.create( - broker: StemBrokerFactory( - create: () => RedisStreamsBroker.connect( - config.brokerUrl, - tls: config.tls, - ), - dispose: (broker) => broker.close(), - ), - backend: StemBackendFactory.inMemory(), + final client = await StemClient.fromUrl( + config.brokerUrl, + adapters: const [StemRedisAdapter()], + overrides: const StemStoreOverrides(backend: 'memory://'), tasks: [ FunctionTaskHandler( name: 'billing.charge', From f2210d729452f762d20389048c9b3d90a98ea759 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Sat, 21 Mar 2026 04:45:03 -0500 Subject: [PATCH 291/302] Prefer client-first bootstrap in public docs --- .site/docs/core-concepts/stem-builder.md | 22 +++++++---- .site/docs/workflows/annotated-workflows.md | 5 ++- .site/docs/workflows/getting-started.md | 6 +-- packages/stem/README.md | 37 +++++++++++-------- .../example/docs_snippets/lib/producer.dart | 12 ++++-- .../stem/example/docs_snippets/lib/tasks.dart | 12 ++++-- .../example/docs_snippets/lib/workflows.dart | 14 ++++--- 7 files changed, 69 insertions(+), 39 deletions(-) diff --git a/.site/docs/core-concepts/stem-builder.md b/.site/docs/core-concepts/stem-builder.md index 4addd92c..f745e275 100644 --- a/.site/docs/core-concepts/stem-builder.md +++ b/.site/docs/core-concepts/stem-builder.md @@ -78,13 +78,14 @@ Generated output (`workflow_defs.stem.g.dart`) includes: ## Wire Into StemWorkflowApp -Use the generated definitions/helpers directly with `StemWorkflowApp`: +Use the generated definitions/helpers directly through `StemClient`: ```dart -final workflowApp = await StemWorkflowApp.fromUrl( +final client = await StemClient.fromUrl( 'memory://', module: stemModule, ); +final workflowApp = await client.createWorkflowApp(); await workflowApp.start(); final result = await StemWorkflowDefinitions.userSignup.startAndWait( @@ -103,7 +104,8 @@ them before bootstrap: ```dart final module = StemModule.merge([authModule, billingModule, stemModule]); -final workflowApp = await StemWorkflowApp.inMemory(module: module); +final client = await StemClient.inMemory(module: module); +final workflowApp = await client.createWorkflowApp(); ``` `StemModule.merge(...)` fails fast when modules declare conflicting task or @@ -113,19 +115,21 @@ If you do not want to pre-merge them yourself, bootstrap helpers also accept `modules:` directly: ```dart -final workflowApp = await StemWorkflowApp.inMemory( +final client = await StemClient.inMemory( modules: [authModule, billingModule, stemModule], ); +final workflowApp = await client.createWorkflowApp(); ``` The same bundle-first path works for plain task apps too: ```dart -final taskApp = await StemApp.fromUrl( +final client = await StemClient.fromUrl( 'redis://localhost:6379', adapters: const [StemRedisAdapter()], module: stemModule, ); +final taskApp = await client.createApp(); ``` If you need to attach generated or hand-written task definitions after @@ -158,7 +162,7 @@ If you already manage a `StemApp` for a larger service, reuse it instead of bootstrapping a second app: ```dart -final stemApp = await StemApp.fromUrl( +final client = await StemClient.fromUrl( 'redis://localhost:6379', adapters: const [StemRedisAdapter()], module: stemModule, @@ -169,6 +173,7 @@ final stemApp = await StemApp.fromUrl( ), ), ); +final stemApp = await client.createApp(); final workflowApp = await stemApp.createWorkflowApp(); ``` @@ -180,18 +185,19 @@ need. If you want automatic queue inference, prefer `StemClient`. For task-only services, the same bundle works directly with `StemApp`: ```dart -final taskApp = await StemApp.fromUrl( +final client = await StemClient.fromUrl( 'redis://localhost:6379', adapters: const [StemRedisAdapter()], module: stemModule, ); +final taskApp = await client.createApp(); ``` Plain `StemApp` bootstrap infers task queue subscriptions from the bundled or explicitly supplied task handlers when `workerConfig.subscription` is omitted, and it lazy-starts on the first enqueue or wait call. -If you already centralize broker/backend wiring in a `StemClient`, prefer the +If you already centralize broker/backend wiring in a `StemClient`, stay on the shared-client path: ```dart diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index e790cb9e..829cc09f 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -22,13 +22,14 @@ remains the explicit low-level transport path, and it can publish from the definition metadata so producer processes do not need to register the worker handler locally just to enqueue typed task calls. -Wire the bundle directly into `StemWorkflowApp`: +Wire the bundle through `StemClient`: ```dart -final workflowApp = await StemWorkflowApp.fromUrl( +final client = await StemClient.fromUrl( 'memory://', module: stemModule, ); +final workflowApp = await client.createWorkflowApp(); ``` With `module: stemModule`, the workflow app infers the worker subscription diff --git a/.site/docs/workflows/getting-started.md b/.site/docs/workflows/getting-started.md index 27c602fe..d28e1d65 100644 --- a/.site/docs/workflows/getting-started.md +++ b/.site/docs/workflows/getting-started.md @@ -15,9 +15,9 @@ Pass normal task handlers through `tasks:` if the workflow also needs to enqueue regular Stem tasks. If you need separate workflow lanes, pass `continuationQueue:` and -`executionQueue:` into the `StemWorkflowApp.*` bootstrap helpers. When the app -is creating the managed worker for you, those queue names are inferred into -the worker subscription automatically. +`executionQueue:` into `client.createWorkflowApp(...)`. When the app is +creating the managed worker for you, those queue names are inferred into the +worker subscription automatically. ## 2. Start the managed worker diff --git a/packages/stem/README.md b/packages/stem/README.md index 0b62b315..b5bdf833 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -452,7 +452,8 @@ final demoWorkflow = Flow( }, ); -final app = await StemWorkflowApp.inMemory( +final client = await StemClient.inMemory(); +final app = await client.createWorkflowApp( flows: [demoWorkflow], ); @@ -462,12 +463,13 @@ print(result?.value); // 'hello world' print(result?.state.status); // WorkflowStatus.completed await app.shutdown(); +await client.close(); ``` If you need separate workflow lanes, pass `continuationQueue:` and -`executionQueue:` into the `StemWorkflowApp.*` bootstrap helpers. When -`StemWorkflowApp` is creating the managed worker for you, those queue names are -inferred into the worker subscription automatically. +`executionQueue:` into `client.createWorkflowApp(...)`. When the workflow app +is creating the managed worker for you, those queue names are inferred into the +worker subscription automatically. For late registration, prefer the app helpers: @@ -492,7 +494,8 @@ while retaining the same durability semantics (checkpoints, resume payloads, auto-versioning) as the lower-level API: ```dart -final app = await StemWorkflowApp.inMemory( +final client = await StemClient.inMemory(); +final app = await client.createWorkflowApp( scripts: [ WorkflowScript( name: 'orders.workflow', @@ -615,8 +618,8 @@ final approvalsFlow = Flow( final approvalsRef = approvalsFlow.refJson( ); -final app = await StemWorkflowApp.fromUrl( - 'memory://', +final client = await StemClient.fromUrl('memory://'); +final app = await client.createWorkflowApp( flows: [approvalsFlow], tasks: const [], ); @@ -629,6 +632,7 @@ final runId = await approvalsRef.start( final result = await approvalsRef.waitFor(app, runId); print(result?.value); await app.close(); +await client.close(); ``` When you need advanced start options without dropping back to raw workflow @@ -708,7 +712,8 @@ final billingRetryScript = WorkflowScript( }, ); -final app = await StemWorkflowApp.inMemory( +final client = await StemClient.inMemory(); +final app = await client.createWorkflowApp( scripts: [billingRetryScript], tasks: const [], ); @@ -836,10 +841,8 @@ dart run build_runner build Wire the generated bundle directly into `StemWorkflowApp`: ```dart -final app = await StemWorkflowApp.fromUrl( - 'memory://', - module: stemModule, -); +final client = await StemClient.fromUrl('memory://', module: stemModule); +final app = await client.createWorkflowApp(); final result = await StemWorkflowDefinitions.userSignup.startAndWait( app, @@ -865,11 +868,13 @@ Generated output gives you: The same bundle also works for plain task apps: ```dart -final taskApp = await StemApp.fromUrl( +final client = await StemClient.fromUrl( 'redis://localhost:6379', adapters: const [StemRedisAdapter()], module: stemModule, ); + +final taskApp = await client.createApp(); ``` `StemApp` lazy-starts its managed worker on the first enqueue, wait, or @@ -891,7 +896,8 @@ once and pass the combined module through bootstrap: ```dart final module = StemModule.merge([authModule, billingModule, stemModule]); -final app = await StemWorkflowApp.inMemory(module: module); +final client = await StemClient.inMemory(module: module); +final app = await client.createWorkflowApp(); ``` `StemModule.merge(...)` fails fast when modules declare the same task or @@ -901,9 +907,10 @@ Bootstrap helpers also accept `modules:` directly when you would rather let the app/client merge them for you: ```dart -final app = await StemWorkflowApp.inMemory( +final client = await StemClient.inMemory( modules: [authModule, billingModule, stemModule], ); +final app = await client.createWorkflowApp(); ``` If you want to inspect what a bundled module will require before bootstrapping, diff --git a/packages/stem/example/docs_snippets/lib/producer.dart b/packages/stem/example/docs_snippets/lib/producer.dart index efdb6357..9f8054ad 100644 --- a/packages/stem/example/docs_snippets/lib/producer.dart +++ b/packages/stem/example/docs_snippets/lib/producer.dart @@ -8,7 +8,7 @@ import 'package:stem_redis/stem_redis.dart'; // #region producer-in-memory Future enqueueInMemory() async { - final app = await StemApp.inMemory( + final client = await StemClient.inMemory( tasks: [ FunctionTaskHandler( name: 'hello.print', @@ -20,6 +20,7 @@ Future enqueueInMemory() async { ), ], ); + final app = await client.createApp(); final taskId = await app.enqueue( 'hello.print', @@ -29,6 +30,7 @@ Future enqueueInMemory() async { print('Enqueued $taskId'); await app.close(); + await client.close(); } // #endregion producer-in-memory @@ -122,7 +124,8 @@ class GenerateReportTask extends TaskHandler { } Future enqueueTyped() async { - final app = await StemApp.inMemory(tasks: [GenerateReportTask()]); + final client = await StemClient.inMemory(tasks: [GenerateReportTask()]); + final app = await client.createApp(); final result = await GenerateReportTask.definition.enqueueAndWait( app, @@ -132,6 +135,7 @@ Future enqueueTyped() async { ); print(result?.value); await app.close(); + await client.close(); } // #endregion producer-typed @@ -145,14 +149,16 @@ class AesPayloadEncoder extends TaskPayloadEncoder { } Future configureProducerEncoders() async { - final app = await StemApp.inMemory( + final client = await StemClient.inMemory( tasks: const [], argsEncoder: const AesPayloadEncoder(), resultEncoder: const JsonTaskPayloadEncoder(), additionalEncoders: const [CustomBinaryEncoder()], ); + final app = await client.createApp(); await app.close(); + await client.close(); } // #endregion producer-encoders diff --git a/packages/stem/example/docs_snippets/lib/tasks.dart b/packages/stem/example/docs_snippets/lib/tasks.dart index 936fe229..bd9caae4 100644 --- a/packages/stem/example/docs_snippets/lib/tasks.dart +++ b/packages/stem/example/docs_snippets/lib/tasks.dart @@ -81,9 +81,10 @@ class PublishInvoiceTask extends TaskHandler { } Future runTypedDefinitionExample() async { - final app = await StemApp.inMemory( + final client = await StemClient.inMemory( tasks: [PublishInvoiceTask()], ); + final app = await client.createApp(); final result = await PublishInvoiceTask.definition.enqueueAndWait( app, @@ -93,6 +94,7 @@ Future runTypedDefinitionExample() async { print('Invoice published'); } await app.close(); + await client.close(); } // #endregion tasks-typed-definition @@ -168,13 +170,15 @@ class Base64PayloadEncoder extends TaskPayloadEncoder { } Future configureEncoders() async { - final app = await StemApp.inMemory( + final client = await StemClient.inMemory( tasks: [EmailTask()], argsEncoder: const Base64PayloadEncoder(), resultEncoder: const Base64PayloadEncoder(), additionalEncoders: const [MyOtherEncoder()], ); + final app = await client.createApp(); await app.close(); + await client.close(); } // #endregion tasks-encoders-global @@ -217,7 +221,8 @@ class MyOtherEncoder extends TaskPayloadEncoder { } Future main() async { - final app = await StemApp.inMemory(tasks: [EmailTask()]); + final client = await StemClient.inMemory(tasks: [EmailTask()]); + final app = await client.createApp(); final taskId = await app.enqueue( 'email.send', @@ -230,4 +235,5 @@ Future main() async { print('Email task state: ${result?.status.state}'); await app.close(); + await client.close(); } diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index 5305d098..fd0f104d 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -44,13 +44,15 @@ class ChargePrepared { // #region workflows-runtime Future bootstrapWorkflowApp() async { // #region workflows-app-create - final workflowApp = await StemWorkflowApp.fromUrl( + final client = await StemClient.fromUrl( 'redis://127.0.0.1:56379', adapters: const [StemRedisAdapter(), StemPostgresAdapter()], overrides: const StemStoreOverrides( backend: 'redis://127.0.0.1:56379/1', workflow: 'postgresql://:@127.0.0.1:65432/stem', ), + ); + final workflowApp = await client.createWorkflowApp( flows: [ApprovalsFlow.flow], scripts: [retryScript], eventBusFactory: WorkflowEventBusFactory.inMemory(), @@ -179,14 +181,14 @@ final encoders = TaskPayloadEncoderRegistry( ); Future configureWorkflowEncoders() async { - final app = await StemWorkflowApp.fromUrl( - 'memory://', - flows: [ApprovalsFlow.flow], + final client = await StemClient.inMemory( encoderRegistry: encoders, additionalEncoders: const [GzipPayloadEncoder()], ); + final app = await client.createWorkflowApp(flows: [ApprovalsFlow.flow]); await app.close(); + await client.close(); } // #endregion workflows-encoders @@ -296,7 +298,8 @@ Future main() async { }, ); - final app = await StemWorkflowApp.inMemory(flows: [demoFlow]); + final client = await StemClient.inMemory(); + final app = await client.createWorkflowApp(flows: [demoFlow]); final runId = await demoFlow.start(app); final result = await demoFlow.waitFor( @@ -307,4 +310,5 @@ Future main() async { print('Workflow result: ${result?.status} value=${result?.value}'); await app.close(); + await client.close(); } From c10ea64b609aaf873404acaee12b4b8c5aafa3d2 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Sat, 21 Mar 2026 05:01:38 -0500 Subject: [PATCH 292/302] Prefer adapter bootstrap in runnable producers --- .../autoscaling_demo/bin/producer.dart | 14 ++++----- .../example/dlq_sandbox/bin/producer.dart | 14 ++++----- .../example/email_service/bin/enqueuer.dart | 19 +++--------- .../enqueuer/bin/enqueue.dart | 16 +++------- .../stem/example/image_processor/bin/api.dart | 16 +++------- .../microservice/enqueuer/bin/main.dart | 19 +++--------- .../mixed_cluster/enqueuer/bin/enqueue.dart | 30 +++++++------------ .../ops_health_suite/bin/producer.dart | 14 ++++----- .../postgres_worker/enqueuer/bin/enqueue.dart | 18 ++++------- .../progress_heartbeat/bin/producer.dart | 14 ++++----- .../rate_limit_delay/bin/producer.dart | 14 ++++----- .../enqueuer/bin/enqueue.dart | 21 +++++-------- .../example/routing_parity/bin/publisher.dart | 13 +++----- .../signing_key_rotation/bin/producer.dart | 14 ++++----- .../worker_control_lab/bin/producer.dart | 14 ++++----- 15 files changed, 78 insertions(+), 172 deletions(-) diff --git a/packages/stem/example/autoscaling_demo/bin/producer.dart b/packages/stem/example/autoscaling_demo/bin/producer.dart index f9805772..f32053a9 100644 --- a/packages/stem/example/autoscaling_demo/bin/producer.dart +++ b/packages/stem/example/autoscaling_demo/bin/producer.dart @@ -1,20 +1,16 @@ import 'dart:io'; import 'package:stem/stem.dart'; +import 'package:stem_redis/stem_redis.dart'; import 'package:stem_autoscaling_demo/shared.dart'; Future main() async { final config = StemConfig.fromEnvironment(); final backendUrl = config.resultBackendUrl ?? config.brokerUrl; - final client = await StemClient.create( - broker: StemBrokerFactory( - create: () => connectBroker(config.brokerUrl, tls: config.tls), - dispose: (broker) => broker.close(), - ), - backend: StemBackendFactory( - create: () => connectBackend(backendUrl, tls: config.tls), - dispose: (backend) => backend.close(), - ), + final client = await StemClient.fromUrl( + config.brokerUrl, + adapters: [StemRedisAdapter(tls: config.tls)], + overrides: StemStoreOverrides(backend: backendUrl), tasks: buildTasks(), ); diff --git a/packages/stem/example/dlq_sandbox/bin/producer.dart b/packages/stem/example/dlq_sandbox/bin/producer.dart index ef7fb36d..ced5bbd3 100644 --- a/packages/stem/example/dlq_sandbox/bin/producer.dart +++ b/packages/stem/example/dlq_sandbox/bin/producer.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:stem/stem.dart'; +import 'package:stem_redis/stem_redis.dart'; import 'package:stem_dlq_sandbox/shared.dart'; Future main() async { @@ -12,15 +13,10 @@ Future main() async { stdout.writeln('[producer] connecting broker=$brokerUrl backend=$backendUrl'); final tasks = buildTasks(); - final client = await StemClient.create( - broker: StemBrokerFactory( - create: () => connectBroker(brokerUrl), - dispose: (broker) => broker.close(), - ), - backend: StemBackendFactory( - create: () => connectBackend(backendUrl), - dispose: (backend) => backend.close(), - ), + final client = await StemClient.fromUrl( + brokerUrl, + adapters: const [StemRedisAdapter()], + overrides: StemStoreOverrides(backend: backendUrl), tasks: tasks, ); diff --git a/packages/stem/example/email_service/bin/enqueuer.dart b/packages/stem/example/email_service/bin/enqueuer.dart index 4ebd4a21..5fa20e83 100644 --- a/packages/stem/example/email_service/bin/enqueuer.dart +++ b/packages/stem/example/email_service/bin/enqueuer.dart @@ -28,21 +28,10 @@ Future main(List args) async { ), ]; - final client = await StemClient.create( - broker: StemBrokerFactory( - create: () => RedisStreamsBroker.connect( - config.brokerUrl, - tls: config.tls, - ), - dispose: (broker) => broker.close(), - ), - backend: StemBackendFactory( - create: () => RedisResultBackend.connect( - backendUrl, - tls: config.tls, - ), - dispose: (backend) => backend.close(), - ), + final client = await StemClient.fromUrl( + config.brokerUrl, + adapters: [StemRedisAdapter(tls: config.tls)], + overrides: StemStoreOverrides(backend: backendUrl), tasks: tasks, signer: signer, ); diff --git a/packages/stem/example/encrypted_payload/enqueuer/bin/enqueue.dart b/packages/stem/example/encrypted_payload/enqueuer/bin/enqueue.dart index d55ddfb3..5869b88b 100644 --- a/packages/stem/example/encrypted_payload/enqueuer/bin/enqueue.dart +++ b/packages/stem/example/encrypted_payload/enqueuer/bin/enqueue.dart @@ -24,18 +24,10 @@ Future main(List args) async { ), ]; - final client = await StemClient.create( - broker: StemBrokerFactory( - create: () => RedisStreamsBroker.connect( - config.brokerUrl, - tls: config.tls, - ), - dispose: (broker) => broker.close(), - ), - backend: StemBackendFactory( - create: () => RedisResultBackend.connect(backendUrl, tls: config.tls), - dispose: (backend) => backend.close(), - ), + final client = await StemClient.fromUrl( + config.brokerUrl, + adapters: [StemRedisAdapter(tls: config.tls)], + overrides: StemStoreOverrides(backend: backendUrl), tasks: tasks, signer: PayloadSigner.maybe(config.signing), ); diff --git a/packages/stem/example/image_processor/bin/api.dart b/packages/stem/example/image_processor/bin/api.dart index 1d0e5096..ab0afb08 100644 --- a/packages/stem/example/image_processor/bin/api.dart +++ b/packages/stem/example/image_processor/bin/api.dart @@ -28,18 +28,10 @@ Future main(List args) async { ), ]; - final client = await StemClient.create( - broker: StemBrokerFactory( - create: () => RedisStreamsBroker.connect( - config.brokerUrl, - tls: config.tls, - ), - dispose: (broker) => broker.close(), - ), - backend: StemBackendFactory( - create: () => RedisResultBackend.connect(backendUrl, tls: config.tls), - dispose: (backend) => backend.close(), - ), + final client = await StemClient.fromUrl( + config.brokerUrl, + adapters: [StemRedisAdapter(tls: config.tls)], + overrides: StemStoreOverrides(backend: backendUrl), tasks: tasks, signer: signer, ); diff --git a/packages/stem/example/microservice/enqueuer/bin/main.dart b/packages/stem/example/microservice/enqueuer/bin/main.dart index 75787dd0..5f3984df 100644 --- a/packages/stem/example/microservice/enqueuer/bin/main.dart +++ b/packages/stem/example/microservice/enqueuer/bin/main.dart @@ -110,21 +110,10 @@ Future main(List args) async { .toList(growable: false); // #region signing-producer-stem - final client = await StemClient.create( - broker: StemBrokerFactory( - create: () => RedisStreamsBroker.connect( - config.brokerUrl, - tls: config.tls, - ), - dispose: (broker) => broker.close(), - ), - backend: StemBackendFactory( - create: () => RedisResultBackend.connect( - backendUrl, - tls: config.tls, - ), - dispose: (backend) => backend.close(), - ), + final client = await StemClient.fromUrl( + config.brokerUrl, + adapters: [StemRedisAdapter(tls: config.tls)], + overrides: StemStoreOverrides(backend: backendUrl), tasks: tasks, signer: signer, ); diff --git a/packages/stem/example/mixed_cluster/enqueuer/bin/enqueue.dart b/packages/stem/example/mixed_cluster/enqueuer/bin/enqueue.dart index 90b586e4..7ede601b 100644 --- a/packages/stem/example/mixed_cluster/enqueuer/bin/enqueue.dart +++ b/packages/stem/example/mixed_cluster/enqueuer/bin/enqueue.dart @@ -63,16 +63,10 @@ Future _buildRedisClient(StemConfig config) async { ), ]; - return StemClient.create( - broker: StemBrokerFactory( - create: () => - RedisStreamsBroker.connect(config.brokerUrl, tls: config.tls), - dispose: (broker) => broker.close(), - ), - backend: StemBackendFactory( - create: () => RedisResultBackend.connect(backendUrl, tls: config.tls), - dispose: (backend) => backend.close(), - ), + return StemClient.fromUrl( + config.brokerUrl, + adapters: [StemRedisAdapter(tls: config.tls)], + overrides: StemStoreOverrides(backend: backendUrl), tasks: tasks, signer: PayloadSigner.maybe(config.signing), ); @@ -92,19 +86,15 @@ Future _buildPostgresClient(StemConfig config) async { ), ]; - return StemClient.create( - broker: StemBrokerFactory( - create: () => PostgresBroker.connect( - config.brokerUrl, + return StemClient.fromUrl( + config.brokerUrl, + adapters: [ + StemPostgresAdapter( applicationName: 'stem-mixed-enqueuer', tls: config.tls, ), - dispose: (broker) => broker.close(), - ), - backend: StemBackendFactory( - create: () => PostgresResultBackend.connect(connectionString: backendUrl), - dispose: (backend) => backend.close(), - ), + ], + overrides: StemStoreOverrides(backend: backendUrl), tasks: tasks, signer: PayloadSigner.maybe(config.signing), ); diff --git a/packages/stem/example/ops_health_suite/bin/producer.dart b/packages/stem/example/ops_health_suite/bin/producer.dart index 40a03420..32a170e2 100644 --- a/packages/stem/example/ops_health_suite/bin/producer.dart +++ b/packages/stem/example/ops_health_suite/bin/producer.dart @@ -1,20 +1,16 @@ import 'dart:io'; import 'package:stem/stem.dart'; +import 'package:stem_redis/stem_redis.dart'; import 'package:stem_ops_health_suite/shared.dart'; Future main() async { final config = StemConfig.fromEnvironment(); final backendUrl = config.resultBackendUrl ?? config.brokerUrl; - final client = await StemClient.create( - broker: StemBrokerFactory( - create: () => connectBroker(config.brokerUrl, tls: config.tls), - dispose: (broker) => broker.close(), - ), - backend: StemBackendFactory( - create: () => connectBackend(backendUrl, tls: config.tls), - dispose: (backend) => backend.close(), - ), + final client = await StemClient.fromUrl( + config.brokerUrl, + adapters: [StemRedisAdapter(tls: config.tls)], + overrides: StemStoreOverrides(backend: backendUrl), tasks: buildTasks(), ); diff --git a/packages/stem/example/postgres_worker/enqueuer/bin/enqueue.dart b/packages/stem/example/postgres_worker/enqueuer/bin/enqueue.dart index e503407f..d9e3bb6c 100644 --- a/packages/stem/example/postgres_worker/enqueuer/bin/enqueue.dart +++ b/packages/stem/example/postgres_worker/enqueuer/bin/enqueue.dart @@ -22,21 +22,15 @@ Future main(List args) async { ), ]; - final client = await StemClient.create( - broker: StemBrokerFactory( - create: () => PostgresBroker.connect( - config.brokerUrl, + final client = await StemClient.fromUrl( + config.brokerUrl, + adapters: [ + StemPostgresAdapter( applicationName: 'stem-postgres-enqueuer', tls: config.tls, ), - dispose: (broker) => broker.close(), - ), - backend: StemBackendFactory( - create: () => PostgresResultBackend.connect( - connectionString: backendUrl, - ), - dispose: (backend) => backend.close(), - ), + ], + overrides: StemStoreOverrides(backend: backendUrl), tasks: tasks, signer: PayloadSigner.maybe(config.signing), ); diff --git a/packages/stem/example/progress_heartbeat/bin/producer.dart b/packages/stem/example/progress_heartbeat/bin/producer.dart index 1a1d5c15..37ad95e7 100644 --- a/packages/stem/example/progress_heartbeat/bin/producer.dart +++ b/packages/stem/example/progress_heartbeat/bin/producer.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:stem/stem.dart'; +import 'package:stem_redis/stem_redis.dart'; import 'package:stem_progress_heartbeat/shared.dart'; Future main() async { @@ -17,15 +18,10 @@ Future main() async { '[producer] broker=$brokerUrl backend=$backendUrl tasks=$taskCount', ); - final client = await StemClient.create( - broker: StemBrokerFactory( - create: () => connectBroker(brokerUrl), - dispose: (broker) => broker.close(), - ), - backend: StemBackendFactory( - create: () => connectBackend(backendUrl), - dispose: (backend) => backend.close(), - ), + final client = await StemClient.fromUrl( + brokerUrl, + adapters: const [StemRedisAdapter()], + overrides: StemStoreOverrides(backend: backendUrl), tasks: buildTasks(), ); const taskOptions = TaskOptions(queue: progressQueue); diff --git a/packages/stem/example/rate_limit_delay/bin/producer.dart b/packages/stem/example/rate_limit_delay/bin/producer.dart index 0f4ff8b8..fc6e3404 100644 --- a/packages/stem/example/rate_limit_delay/bin/producer.dart +++ b/packages/stem/example/rate_limit_delay/bin/producer.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:stem/stem.dart'; +import 'package:stem_redis/stem_redis.dart'; import 'package:stem_rate_limit_delay_demo/shared.dart'; Future main() async { @@ -13,15 +14,10 @@ Future main() async { final tasks = buildTasks(); final routing = buildRoutingRegistry(); - final client = await StemClient.create( - broker: StemBrokerFactory( - create: () => connectBroker(brokerUrl), - dispose: (broker) => broker.close(), - ), - backend: StemBackendFactory( - create: () => connectBackend(backendUrl), - dispose: (backend) => backend.close(), - ), + final client = await StemClient.fromUrl( + brokerUrl, + adapters: const [StemRedisAdapter()], + overrides: StemStoreOverrides(backend: backendUrl), tasks: tasks, routing: routing, ); diff --git a/packages/stem/example/redis_postgres_worker/enqueuer/bin/enqueue.dart b/packages/stem/example/redis_postgres_worker/enqueuer/bin/enqueue.dart index 60db0451..021e751c 100644 --- a/packages/stem/example/redis_postgres_worker/enqueuer/bin/enqueue.dart +++ b/packages/stem/example/redis_postgres_worker/enqueuer/bin/enqueue.dart @@ -23,20 +23,13 @@ Future main(List args) async { ), ]; - final client = await StemClient.create( - broker: StemBrokerFactory( - create: () => RedisStreamsBroker.connect( - config.brokerUrl, - tls: config.tls, - ), - dispose: (broker) => broker.close(), - ), - backend: StemBackendFactory( - create: () => PostgresResultBackend.connect( - connectionString: backendUrl, - ), - dispose: (backend) => backend.close(), - ), + final client = await StemClient.fromUrl( + config.brokerUrl, + adapters: [ + StemRedisAdapter(tls: config.tls), + StemPostgresAdapter(), + ], + overrides: StemStoreOverrides(backend: backendUrl), tasks: tasks, signer: PayloadSigner.maybe(config.signing), ); diff --git a/packages/stem/example/routing_parity/bin/publisher.dart b/packages/stem/example/routing_parity/bin/publisher.dart index 0f870d70..4b5e411d 100644 --- a/packages/stem/example/routing_parity/bin/publisher.dart +++ b/packages/stem/example/routing_parity/bin/publisher.dart @@ -10,15 +10,10 @@ Future main() async { final routing = buildRoutingRegistry(); final tasks = buildDemoTasks(); - final client = await StemClient.create( - broker: StemBrokerFactory( - create: () => RedisStreamsBroker.connect( - redisUrl, - namespace: 'stem-routing-demo', - ), - dispose: (broker) => broker.close(), - ), - backend: StemBackendFactory.inMemory(), + final client = await StemClient.fromUrl( + redisUrl, + adapters: const [StemRedisAdapter(namespace: 'stem-routing-demo')], + overrides: const StemStoreOverrides(backend: 'memory://'), tasks: tasks, routing: routing, ); diff --git a/packages/stem/example/signing_key_rotation/bin/producer.dart b/packages/stem/example/signing_key_rotation/bin/producer.dart index f2116a87..c5bef9f7 100644 --- a/packages/stem/example/signing_key_rotation/bin/producer.dart +++ b/packages/stem/example/signing_key_rotation/bin/producer.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:stem/stem.dart'; +import 'package:stem_redis/stem_redis.dart'; import 'package:stem_signing_key_rotation/shared.dart'; Future main() async { @@ -14,15 +15,10 @@ Future main() async { final tasks = buildTasks(); // #region signing-rotation-producer-stem - final client = await StemClient.create( - broker: StemBrokerFactory( - create: () => connectBroker(config.brokerUrl, tls: config.tls), - dispose: (broker) => broker.close(), - ), - backend: StemBackendFactory( - create: () => connectBackend(backendUrl, tls: config.tls), - dispose: (backend) => backend.close(), - ), + final client = await StemClient.fromUrl( + config.brokerUrl, + adapters: [StemRedisAdapter(tls: config.tls)], + overrides: StemStoreOverrides(backend: backendUrl), tasks: tasks, signer: signer, ); diff --git a/packages/stem/example/worker_control_lab/bin/producer.dart b/packages/stem/example/worker_control_lab/bin/producer.dart index af95de49..b5ab3b3e 100644 --- a/packages/stem/example/worker_control_lab/bin/producer.dart +++ b/packages/stem/example/worker_control_lab/bin/producer.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:stem/stem.dart'; +import 'package:stem_redis/stem_redis.dart'; import 'package:stem_worker_control_lab/shared.dart'; Future main() async { @@ -19,15 +20,10 @@ Future main() async { '[producer] broker=$brokerUrl backend=$backendUrl long=$longCount quick=$quickCount', ); - final client = await StemClient.create( - broker: StemBrokerFactory( - create: () => connectBroker(brokerUrl), - dispose: (broker) => broker.close(), - ), - backend: StemBackendFactory( - create: () => connectBackend(backendUrl), - dispose: (backend) => backend.close(), - ), + final client = await StemClient.fromUrl( + brokerUrl, + adapters: const [StemRedisAdapter()], + overrides: StemStoreOverrides(backend: backendUrl), tasks: buildTasks(), ); From 1d4a2d261db9f771cdbcffba176eafbcf64ddd80 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Sat, 21 Mar 2026 05:10:12 -0500 Subject: [PATCH 293/302] Clean Stem analyzer noise and runtime tests --- packages/stem/lib/src/core/clock.dart | 1 + .../workflow/core/workflow_checkpoint.dart | 24 +++---- .../workflow/core/workflow_definition.dart | 20 ++---- .../core/workflow_runtime_metadata.dart | 64 ++++++++++--------- .../lib/src/workflow/core/workflow_store.dart | 2 +- .../workflow/runtime/workflow_manifest.dart | 4 +- .../workflow/runtime/workflow_runtime.dart | 11 ++-- .../stem/test/bootstrap/stem_client_test.dart | 6 +- .../stem/test/bootstrap/stem_stack_test.dart | 4 +- .../stem/test/unit/core/stem_core_test.dart | 6 +- .../unit/core/task_context_enqueue_test.dart | 7 +- .../unit/core/task_enqueue_builder_test.dart | 14 ++-- .../workflow/in_memory_event_bus_test.dart | 2 +- .../unit/workflow/workflow_manifest_test.dart | 4 +- .../workflow/workflow_runtime_ref_test.dart | 6 +- .../test/workflow/workflow_runtime_test.dart | 45 ++++++++----- 16 files changed, 122 insertions(+), 98 deletions(-) diff --git a/packages/stem/lib/src/core/clock.dart b/packages/stem/lib/src/core/clock.dart index c8e90e92..30e681ea 100644 --- a/packages/stem/lib/src/core/clock.dart +++ b/packages/stem/lib/src/core/clock.dart @@ -1,6 +1,7 @@ import 'dart:async'; /// Shared clock abstraction used across the Stem ecosystem. +// ignore: one_member_abstracts abstract class StemClock { /// Creates a clock implementation. const StemClock(); diff --git a/packages/stem/lib/src/workflow/core/workflow_checkpoint.dart b/packages/stem/lib/src/workflow/core/workflow_checkpoint.dart index 4845e59f..dfdeadcf 100644 --- a/packages/stem/lib/src/workflow/core/workflow_checkpoint.dart +++ b/packages/stem/lib/src/workflow/core/workflow_checkpoint.dart @@ -24,6 +24,18 @@ class WorkflowCheckpoint { taskNames = List.unmodifiable(taskNames), metadata = metadata == null ? null : Map.unmodifiable(metadata); + /// Rehydrates declared checkpoint metadata from serialized JSON. + factory WorkflowCheckpoint.fromJson(Map json) { + return WorkflowCheckpoint( + name: json['name']?.toString() ?? '', + title: json['title']?.toString(), + kind: _checkpointKindFromJson(json['kind']), + taskNames: (json['taskNames'] as List?)?.cast() ?? const [], + autoVersion: json['autoVersion'] == true, + metadata: (json['metadata'] as Map?)?.cast(), + ); + } + /// Creates checkpoint metadata backed by a typed [valueCodec]. static WorkflowCheckpoint typed({ required String name, @@ -46,18 +58,6 @@ class WorkflowCheckpoint { ); } - /// Rehydrates declared checkpoint metadata from serialized JSON. - factory WorkflowCheckpoint.fromJson(Map json) { - return WorkflowCheckpoint( - name: json['name']?.toString() ?? '', - title: json['title']?.toString(), - kind: _checkpointKindFromJson(json['kind']), - taskNames: (json['taskNames'] as List?)?.cast() ?? const [], - autoVersion: json['autoVersion'] == true, - metadata: (json['metadata'] as Map?)?.cast(), - ); - } - /// Checkpoint name used for persistence and replay. final String name; diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index 91260583..fbd5f5ec 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -183,12 +183,8 @@ class WorkflowDefinition { typeName: resultTypeName ?? '$T', )); if (resolvedResultCodec != null) { - resultEncoder = (Object? value) { - return resolvedResultCodec.encodeDynamic(value); - }; - resultDecoder = (Object? payload) { - return resolvedResultCodec.decodeDynamic(payload); - }; + resultEncoder = resolvedResultCodec.encodeDynamic; + resultDecoder = resolvedResultCodec.decodeDynamic; } return WorkflowDefinition._( name: name, @@ -330,12 +326,8 @@ class WorkflowDefinition { typeName: resultTypeName ?? '$T', )); if (resolvedResultCodec != null) { - resultEncoder = (Object? value) { - return resolvedResultCodec.encodeDynamic(value); - }; - resultDecoder = (Object? payload) { - return resolvedResultCodec.decodeDynamic(payload); - }; + resultEncoder = resolvedResultCodec.encodeDynamic; + resultDecoder = resolvedResultCodec.decodeDynamic; } return WorkflowDefinition._( name: name, @@ -740,8 +732,10 @@ class WorkflowDefinition { String _stableHexDigest(String input) { final bytes = utf8.encode(input); + // FNV-1a uses this exact 64-bit offset basis; keep the literal stable. + // ignore: avoid_js_rounded_ints var hash = 0xcbf29ce484222325; - const prime = 0x00000100000001B3; + const prime = 0x100000001b3; for (final value in bytes) { hash ^= value; hash = (hash * prime) & 0xFFFFFFFFFFFFFFFF; diff --git a/packages/stem/lib/src/workflow/core/workflow_runtime_metadata.dart b/packages/stem/lib/src/workflow/core/workflow_runtime_metadata.dart index 263c4ec4..24b9db3f 100644 --- a/packages/stem/lib/src/workflow/core/workflow_runtime_metadata.dart +++ b/packages/stem/lib/src/workflow/core/workflow_runtime_metadata.dart @@ -54,36 +54,30 @@ class WorkflowRunRuntimeMetadata { factory WorkflowRunRuntimeMetadata.fromJson(Map json) { return WorkflowRunRuntimeMetadata( workflowId: json['workflowId']?.toString() ?? '', - orchestrationQueue: - json['orchestrationQueue']?.toString().trim().isNotEmpty == true - ? json['orchestrationQueue']!.toString().trim() - : 'workflow', - continuationQueue: - json['continuationQueue']?.toString().trim().isNotEmpty == true - ? json['continuationQueue']!.toString().trim() - : 'workflow', - executionQueue: - json['executionQueue']?.toString().trim().isNotEmpty == true - ? json['executionQueue']!.toString().trim() - : 'default', - serializationFormat: - json['serializationFormat']?.toString().trim().isNotEmpty == true - ? json['serializationFormat']!.toString().trim() - : 'json', - serializationVersion: - json['serializationVersion']?.toString().trim().isNotEmpty == true - ? json['serializationVersion']!.toString().trim() - : '1', - frameFormat: json['frameFormat']?.toString().trim().isNotEmpty == true - ? json['frameFormat']!.toString().trim() - : 'json-frame', - frameVersion: json['frameVersion']?.toString().trim().isNotEmpty == true - ? json['frameVersion']!.toString().trim() - : '1', - encryptionScope: - json['encryptionScope']?.toString().trim().isNotEmpty == true - ? json['encryptionScope']!.toString().trim() - : 'none', + orchestrationQueue: _stringOrDefault( + json, + 'orchestrationQueue', + 'workflow', + ), + continuationQueue: _stringOrDefault( + json, + 'continuationQueue', + 'workflow', + ), + executionQueue: _stringOrDefault(json, 'executionQueue', 'default'), + serializationFormat: _stringOrDefault( + json, + 'serializationFormat', + 'json', + ), + serializationVersion: _stringOrDefault( + json, + 'serializationVersion', + '1', + ), + frameFormat: _stringOrDefault(json, 'frameFormat', 'json-frame'), + frameVersion: _stringOrDefault(json, 'frameVersion', '1'), + encryptionScope: _stringOrDefault(json, 'encryptionScope', 'none'), encryptionEnabled: json['encryptionEnabled'] == true, streamId: json['streamId']?.toString(), ); @@ -179,3 +173,13 @@ class WorkflowRunRuntimeMetadata { return UnmodifiableMapView(copy); } } + +String _stringOrDefault( + Map json, + String key, + String fallback, +) { + final raw = json[key]?.toString().trim(); + if (raw == null || raw.isEmpty) return fallback; + return raw; +} diff --git a/packages/stem/lib/src/workflow/core/workflow_store.dart b/packages/stem/lib/src/workflow/core/workflow_store.dart index 029a7f2e..c064e54e 100644 --- a/packages/stem/lib/src/workflow/core/workflow_store.dart +++ b/packages/stem/lib/src/workflow/core/workflow_store.dart @@ -8,9 +8,9 @@ import 'package:stem/src/workflow/core/workflow_watcher.dart'; abstract class WorkflowStore { /// Creates a new workflow run record and returns its run id. Future createRun({ - String? runId, required String workflow, required Map params, + String? runId, String? parentRunId, Duration? ttl, diff --git a/packages/stem/lib/src/workflow/runtime/workflow_manifest.dart b/packages/stem/lib/src/workflow/runtime/workflow_manifest.dart index 6dfa3ddb..311456b5 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_manifest.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_manifest.dart @@ -228,8 +228,10 @@ extension WorkflowManifestDefinition on WorkflowDefinition { String _stableHexDigest(String input) { final bytes = utf8.encode(input); + // FNV-1a uses this exact 64-bit offset basis; keep the literal stable. + // ignore: avoid_js_rounded_ints var hash = 0xcbf29ce484222325; - const prime = 0x00000100000001B3; + const prime = 0x100000001b3; for (final value in bytes) { hash ^= value; hash = (hash * prime) & 0xFFFFFFFFFFFFFFFF; diff --git a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart index 5e83e6af..94f318cb 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart @@ -178,9 +178,7 @@ class WorkflowRuntime implements WorkflowCaller, WorkflowEventEmitter { continuationQueue: continuationQueue, executionQueue: executionQueue, serializationFormat: _stem.payloadEncoders.defaultArgsEncoder.id, - serializationVersion: '1', frameFormat: 'stem-envelope', - frameVersion: '1', encryptionScope: _stem.signer != null ? 'signed-envelope' : 'none', encryptionEnabled: _stem.signer != null, streamId: '${name}_$requestedRunId', @@ -361,6 +359,7 @@ class WorkflowRuntime implements WorkflowCaller, WorkflowEventEmitter { } /// Waits for [runId] using the decoding rules from a [WorkflowRef]. + @override Future?> waitForWorkflowRef( String runId, @@ -963,7 +962,7 @@ class WorkflowRuntime implements WorkflowCaller, WorkflowEventEmitter { step: step.name, extra: { 'workflowSuspensionType': 'event', - 'topic': control.topic!, + 'topic': control.topic, 'workflowIteration': iteration, if (deadline != null) 'deadline': deadline.toIso8601String(), 'runtimeId': _runtimeId, @@ -1296,9 +1295,9 @@ class WorkflowRuntime implements WorkflowCaller, WorkflowEventEmitter { /// Enqueues a workflow run execution task. Future _enqueueRun( String runId, { - String? workflow, required bool continuation, required WorkflowContinuationReason reason, + String? workflow, WorkflowRunRuntimeMetadata? runtimeMetadata, }) async { final metadata = @@ -1923,7 +1922,7 @@ class _WorkflowScriptExecution implements WorkflowScriptContext { step: stepName, extra: { 'workflowSuspensionType': 'event', - 'topic': control.topic!, + 'topic': control.topic, 'workflowIteration': iteration, if (deadline != null) 'deadline': deadline.toIso8601String(), 'runtimeId': runtime._runtimeId, @@ -2230,7 +2229,7 @@ class _WorkflowStepEnqueuer implements TaskEnqueuer { TaskEnqueueOptions? enqueueOptions, }) { final mergedMeta = Map.from(baseMeta)..addAll(call.meta); - TaskOptions? resolvedOptions = call.options; + var resolvedOptions = call.options; if (resolvedOptions == null) { final inherited = call.definition.defaultOptions; if (inherited.queue == 'default' && executionQueue != 'default') { diff --git a/packages/stem/test/bootstrap/stem_client_test.dart b/packages/stem/test/bootstrap/stem_client_test.dart index 1f0d594c..a4a96b3b 100644 --- a/packages/stem/test/bootstrap/stem_client_test.dart +++ b/packages/stem/test/bootstrap/stem_client_test.dart @@ -141,7 +141,7 @@ void main() { expect((await client.getTaskStatus(taskId))?.state, TaskState.succeeded); final dispatch = await client.createCanvas().group([ - task('client.status.task', args: const {}), + task('client.status.task'), ]); try { final groupStatus = await _waitForClientGroupStatus( @@ -170,7 +170,7 @@ void main() { final canvas = client.createCanvas(); final taskId = await canvas.send( - task('client.canvas.task', args: const {}), + task('client.canvas.task'), ); final result = await client.waitForTask( taskId, @@ -195,7 +195,7 @@ void main() { final canvas = client.createCanvas(tasks: [extraTask]); final taskId = await canvas.send( - task('client.canvas.extra', args: const {}), + task('client.canvas.extra'), ); final result = await client.waitForTask( taskId, diff --git a/packages/stem/test/bootstrap/stem_stack_test.dart b/packages/stem/test/bootstrap/stem_stack_test.dart index d41d23e0..a94c6400 100644 --- a/packages/stem/test/bootstrap/stem_stack_test.dart +++ b/packages/stem/test/bootstrap/stem_stack_test.dart @@ -51,7 +51,9 @@ void main() { name: 'stack.client.task', entrypoint: (context, args) async => 'ok', ); - final definition = TaskDefinition.noArgs(name: 'stack.client.task'); + final definition = TaskDefinition.noArgs( + name: 'stack.client.task', + ); final client = await stack.createClient(tasks: [handler]); final worker = await client.createWorker(); await worker.start(); diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index bb6f119c..a8cd359c 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -376,7 +376,8 @@ void main() { ); test( - 'versioned json registry task definitions can derive versioned result metadata', + 'versioned json registry task definitions can derive versioned result ' + 'metadata', () async { final broker = _RecordingBroker(); final backend = _RecordingBackend(); @@ -406,7 +407,8 @@ void main() { final broker = _RecordingBroker(); final backend = _RecordingBackend(); final stem = Stem(broker: broker, backend: backend); - final definition = TaskDefinition<_CodecTaskArgs, _CodecReceipt>.versionedMap( + final definition = + TaskDefinition<_CodecTaskArgs, _CodecReceipt>.versionedMap( name: 'sample.versioned_map.result', version: 2, encodeArgs: (args) => {'legacy_value': args.value}, diff --git a/packages/stem/test/unit/core/task_context_enqueue_test.dart b/packages/stem/test/unit/core/task_context_enqueue_test.dart index 013e6ff2..e93346ea 100644 --- a/packages/stem/test/unit/core/task_context_enqueue_test.dart +++ b/packages/stem/test/unit/core/task_context_enqueue_test.dart @@ -136,7 +136,9 @@ void main() { expect(enqueuer.records.single.args, equals({'value': 42})); }); - test('enqueueValue encodes typed payloads through the supplied codec', () async { + test( + 'enqueueValue encodes typed payloads through the supplied codec', + () async { final enqueuer = _RecordingEnqueuer(); final context = TaskContext( id: 'parent-3b', @@ -163,7 +165,8 @@ void main() { expect(record.meta[_parentTaskIdKey], equals('parent-3b')); expect(record.meta[_parentAttemptKey], equals(1)); expect(record.headers['x-trace-id'], equals('trace-2')); - }); + }, + ); test('merges headers/meta overrides with defaults', () async { final enqueuer = _RecordingEnqueuer(); diff --git a/packages/stem/test/unit/core/task_enqueue_builder_test.dart b/packages/stem/test/unit/core/task_enqueue_builder_test.dart index 28ea5f06..b79d6a79 100644 --- a/packages/stem/test/unit/core/task_enqueue_builder_test.dart +++ b/packages/stem/test/unit/core/task_enqueue_builder_test.dart @@ -160,7 +160,9 @@ void main() { expect(updated.meta['m2'], 2); }); - test('NoArgsTaskDefinition.asDefinition.buildCall builds an empty call', () { + test( + 'NoArgsTaskDefinition.asDefinition.buildCall builds an empty call', + () { final definition = TaskDefinition.noArgs(name: 'demo.no_args'); final call = definition.asDefinition.buildCall( @@ -173,7 +175,8 @@ void main() { expect(call.encodeArgs(), isEmpty); expect(call.headers, containsPair('h', 'v')); expect(call.meta, containsPair('m', 1)); - }); + }, + ); test('NoArgsTaskDefinition.asDefinition.buildCall accepts overrides', () { final definition = TaskDefinition.noArgs(name: 'demo.no_args'); @@ -188,7 +191,9 @@ void main() { expect(call.encodeArgs(), isEmpty); }); - test('NoArgsTaskDefinition.enqueue uses the TaskEnqueuer surface', () async { + test( + 'NoArgsTaskDefinition.enqueue uses the TaskEnqueuer surface', + () async { final definition = TaskDefinition.noArgs(name: 'demo.no_args'); final enqueuer = _RecordingTaskEnqueuer(); @@ -204,7 +209,8 @@ void main() { expect(enqueuer.lastCall!.encodeArgs(), isEmpty); expect(enqueuer.lastCall!.headers, containsPair('h', 'v')); expect(enqueuer.lastCall!.meta, containsPair('m', 1)); - }); + }, + ); }); } diff --git a/packages/stem/test/unit/workflow/in_memory_event_bus_test.dart b/packages/stem/test/unit/workflow/in_memory_event_bus_test.dart index cbcaed1c..d3a4b8a4 100644 --- a/packages/stem/test/unit/workflow/in_memory_event_bus_test.dart +++ b/packages/stem/test/unit/workflow/in_memory_event_bus_test.dart @@ -14,9 +14,9 @@ class _NoopWorkflowStore implements WorkflowStore { @override Future createRun({ - String? runId, required String workflow, required Map params, + String? runId, String? parentRunId, Duration? ttl, WorkflowCancellationPolicy? cancellationPolicy, diff --git a/packages/stem/test/unit/workflow/workflow_manifest_test.dart b/packages/stem/test/unit/workflow/workflow_manifest_test.dart index 21d27960..d7a7ecd2 100644 --- a/packages/stem/test/unit/workflow/workflow_manifest_test.dart +++ b/packages/stem/test/unit/workflow/workflow_manifest_test.dart @@ -33,20 +33,18 @@ void main() { final definition = WorkflowScript>( name: 'manifest.script', run: (script) async { - final email = script.params['email'] as String; + final email = script.params['email']! as String; return {'email': email, 'status': 'done'}; }, checkpoints: [ WorkflowCheckpoint( name: 'create-user', title: 'Create user', - kind: WorkflowStepKind.task, taskNames: const ['user.create'], ), WorkflowCheckpoint( name: 'send-welcome-email', title: 'Send welcome email', - kind: WorkflowStepKind.task, taskNames: const ['email.send'], ), ], diff --git a/packages/stem/test/workflow/workflow_runtime_ref_test.dart b/packages/stem/test/workflow/workflow_runtime_ref_test.dart index 8552bb2e..e6ecbe7f 100644 --- a/packages/stem/test/workflow/workflow_runtime_ref_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_ref_test.dart @@ -70,9 +70,7 @@ const _greetingResultRegistry = PayloadVersionRegistry<_GreetingResult>( class _LegacyGreetingParams { const _LegacyGreetingParams({required this.name}); - final String name; - - static _LegacyGreetingParams fromVersionedMap( + factory _LegacyGreetingParams.fromVersionedMap( Map json, int version, ) { @@ -80,6 +78,8 @@ class _LegacyGreetingParams { name: '${json['display_name']! as String} v$version', ); } + + final String name; } final _userUpdatedEvent = WorkflowEventRef<_GreetingParams>.json( diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index df120dcd..9add7da0 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -815,7 +815,9 @@ void main() { expect(completed?.result, 'user-typed-2'); }); - test('emitEvent resumes flows with versioned-json workflow event refs', () async { + test( + 'emitEvent resumes flows with versioned-json workflow event refs', + () async { final event = WorkflowEventRef<_UserUpdatedEvent>.versionedJson( topic: 'user.updated.versioned.ref', version: 2, @@ -860,9 +862,12 @@ void main() { expect(completed?.status, WorkflowStatus.completed); expect(observedPayload?.id, 'user-versioned-ref-2'); expect(completed?.result, 'user-versioned-ref-2'); - }); + }, + ); - test('emitEvent resumes flows with registry-backed workflow event refs', () async { + test( + 'emitEvent resumes flows with registry-backed workflow event refs', + () async { final event = WorkflowEventRef<_UserUpdatedEvent>.versionedJsonRegistry( topic: 'user.updated.registry.ref', version: 2, @@ -907,9 +912,12 @@ void main() { expect(completed?.status, WorkflowStatus.completed); expect(observedPayload?.id, 'user-registry-ref-2'); expect(completed?.result, 'user-registry-ref-2'); - }); + }, + ); - test('emitEvent resumes flows with versioned-map workflow event refs', () async { + test( + 'emitEvent resumes flows with versioned-map workflow event refs', + () async { final event = WorkflowEventRef<_UserUpdatedEvent>.versionedMap( topic: 'user.updated.versioned.map.ref', encode: (value) => {'user_id': value.id}, @@ -938,7 +946,9 @@ void main() { ).definition, ); - final runId = await runtime.startWorkflow('event.versioned.map.ref.workflow'); + final runId = await runtime.startWorkflow( + 'event.versioned.map.ref.workflow', + ); await runtime.executeRun(runId); final suspended = await store.get(runId); @@ -955,7 +965,8 @@ void main() { expect(completed?.status, WorkflowStatus.completed); expect(observedPayload?.id, 'user-versioned-map-ref-v3'); expect(completed?.result, 'user-versioned-map-ref-v3'); - }); + }, + ); test('emit persists payload before worker resumes execution', () async { runtime.registerWorkflow( @@ -1640,6 +1651,8 @@ void main() { }, ).definition, ); + // Keep this direct call form; cascading a single registration is noisier. + // ignore: cascade_invocations runtime.registerWorkflow( Flow( name: 'logging.complete.workflow', @@ -1785,15 +1798,11 @@ const _userUpdatedEventCodec = PayloadCodec<_UserUpdatedEvent>.json( class _UserUpdatedEvent { const _UserUpdatedEvent({required this.id}); - final String id; - - Map toJson() => {'id': id}; - - static _UserUpdatedEvent fromJson(Map json) { - return _UserUpdatedEvent(id: json['id'] as String); + factory _UserUpdatedEvent.fromJson(Map json) { + return _UserUpdatedEvent(id: json['id']! as String); } - static _UserUpdatedEvent fromVersionedJson( + factory _UserUpdatedEvent.fromVersionedJson( Map json, int version, ) { @@ -1801,17 +1810,21 @@ class _UserUpdatedEvent { return _UserUpdatedEvent(id: json['id'] as String); } - static _UserUpdatedEvent fromV2Json(Map json) { + factory _UserUpdatedEvent.fromV2Json(Map json) { return _UserUpdatedEvent(id: json['id'] as String); } - static _UserUpdatedEvent fromVersionedMap( + factory _UserUpdatedEvent.fromVersionedMap( Map json, int version, ) { expect(version, 3); return _UserUpdatedEvent(id: '${json['user_id'] as String}-v$version'); } + + final String id; + + Map toJson() => {'id': id}; } const _userUpdatedEventRegistry = PayloadVersionRegistry<_UserUpdatedEvent>( From d9c24ccb5c794ffe6337942e320920bb8187b396 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Sat, 21 Mar 2026 05:10:27 -0500 Subject: [PATCH 294/302] Bump stem_builder to 0.2.0 --- packages/stem_builder/CHANGELOG.md | 59 +++++++++--------------------- packages/stem_builder/pubspec.yaml | 2 +- 2 files changed, 18 insertions(+), 43 deletions(-) diff --git a/packages/stem_builder/CHANGELOG.md b/packages/stem_builder/CHANGELOG.md index bb7d85f5..001006de 100644 --- a/packages/stem_builder/CHANGELOG.md +++ b/packages/stem_builder/CHANGELOG.md @@ -1,49 +1,24 @@ # Changelog +## 0.2.0 + +- Generated output now centers on `stemModule`, `StemWorkflowDefinitions`, and + `StemTaskDefinitions` with the same narrowed happy-path APIs as `stem` + itself. +- Generated child-workflow examples and docs now prefer direct + `start(...)` / `startAndWait(...)` helpers in durable boundaries, leaving + explicit transport objects as the advanced path. +- Generated DTO payload codecs now use the shorter `json(...)`, `map(...)`, + and registry-backed versioned factories where appropriate. +- Builder diagnostics now catch duplicate/conflicting workflow checkpoint + names and redundant manual `script.step(...)` wrappers around annotated + checkpoints, including context-aware cases. +- Annotated workflow/task generation now supports shared execution contexts, + direct typed starter output, and bundle-first bootstrap guidance that aligns + with the `StemClient`-first runtime model. + ## 0.1.0 -- Refreshed the builder README flow-step examples to prefer direct - `start(...)` aliases in the common case. -- Refreshed the builder README examples to prefer direct `start(...)` aliases - in the common case. -- Refreshed generated child-workflow examples and docs to prefer the direct - `startAndWait(...)` alias in the common case. -- Switched generated DTO payload codecs to the shorter - `PayloadCodec.json(...)` form for types that already expose `toJson()` and - `fromJson(...)`. -- Switched generated DTO payload codecs to `PayloadCodec.map(...)`, removing - the old emitted map-normalization helper and the need for handwritten - `Object?` decode wrappers in generated output. -- Updated the builder docs and annotated workflow example to prefer direct - child-workflow helpers like `ref.startAndWaitWith(context, value)` in - durable boundaries, leaving caller-bound builders for advanced overrides. -- Warned when a manual `script.step(...)` wrapper redundantly encloses an - annotated checkpoint, including context-aware checkpoints that can now use - direct annotated method calls with injected workflow-step context. -- Flattened generated single-argument workflow/task definitions so one-field - annotated workflows/tasks emit direct typed refs instead of named-record - wrappers. -- Switched generated output to a bundle-first surface with `stemModule`, `StemWorkflowDefinitions`, `StemTaskDefinitions`, generated typed wait helpers, and payload codec generation for DTO-backed workflow/task APIs. -- Removed generated task-specific enqueue/wait extension APIs in favor of the - shared `TaskCall.enqueue(...)` and `TaskDefinition.waitFor(...)` surface - from `stem`, reducing duplicate happy paths in generated task code. -- Added builder diagnostics for duplicate or conflicting annotated workflow checkpoint names and refreshed generated examples around typed workflow refs. -- Refreshed generated child-workflow examples and docs to use the unified - `startWith(...)` / `startAndWaitWith(...)` helper surface inside durable - workflow contexts. -- Clarified the builder README to treat direct `WorkflowRuntime` usage as a - low-level path and prefer app helpers for the common case. -- Added typed workflow starter generation and app helper output for annotated - workflow/task definitions. -- Switched generated output to per-file `part` generation using `.stem.g.dart` - files instead of a shared standalone registry file. -- Added support for plain `@WorkflowRun` entrypoints and configurable starter - naming in generated APIs. -- Refreshed the builder README, example package, and annotated workflow demos - to match the generated `tasks:`-first runtime wiring. -- Switched generated script metadata from `steps:` to `checkpoints:` and - expanded docs/examples around direct step calls, context injection, and - serializable parameter rules. - Initial builder for annotated Stem workflow/task registries. - Expanded the registry builder implementation and hardened generation output. - Added build configuration, analysis options, and tests for registry builds. diff --git a/packages/stem_builder/pubspec.yaml b/packages/stem_builder/pubspec.yaml index 300d61db..43b5b059 100644 --- a/packages/stem_builder/pubspec.yaml +++ b/packages/stem_builder/pubspec.yaml @@ -1,6 +1,6 @@ name: stem_builder description: Build-time registry generator for annotated Stem workflows and tasks. -version: 0.1.0 +version: 0.2.0 repository: https://github.com/kingwill101/stem resolution: workspace From f1585b02c636f9d85ce5681a03ae3b83b7cb9fee Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Sat, 21 Mar 2026 06:07:01 -0500 Subject: [PATCH 295/302] Add pretty mode to Stem logging --- .site/docs/core-concepts/observability.md | 4 ++ .../docs_snippets/lib/observability.dart | 1 + .../stem/lib/src/observability/logging.dart | 52 +++++++++++++----- .../test/unit/observability/logging_test.dart | 54 +++++++++++++++++++ 4 files changed, 99 insertions(+), 12 deletions(-) diff --git a/.site/docs/core-concepts/observability.md b/.site/docs/core-concepts/observability.md index a3f88e89..f5a831db 100644 --- a/.site/docs/core-concepts/observability.md +++ b/.site/docs/core-concepts/observability.md @@ -120,6 +120,10 @@ Use `stemLogger` (Contextual logger) for structured logs. ``` +For local development, switch the shared logger to colored terminal output with +`configureStemLogging(format: StemLogFormat.pretty)`. Keep the default plain +formatter for production log shipping and machine parsing. + Workers automatically include attempt, queue, and worker id in log contexts when `StemSignals` are enabled. diff --git a/packages/stem/example/docs_snippets/lib/observability.dart b/packages/stem/example/docs_snippets/lib/observability.dart index 802598b7..a7d8b84e 100644 --- a/packages/stem/example/docs_snippets/lib/observability.dart +++ b/packages/stem/example/docs_snippets/lib/observability.dart @@ -43,6 +43,7 @@ void recordQueueDepth(String queue, int depth) { // #region observability-logging void logTaskStart(Envelope envelope) { + configureStemLogging(format: StemLogFormat.pretty); stemLogger.info( 'Task started', Context({'task': envelope.name, 'id': envelope.id}), diff --git a/packages/stem/lib/src/observability/logging.dart b/packages/stem/lib/src/observability/logging.dart index 7d820031..4ce446f5 100644 --- a/packages/stem/lib/src/observability/logging.dart +++ b/packages/stem/lib/src/observability/logging.dart @@ -1,18 +1,40 @@ import 'package:contextual/contextual.dart'; -Logger _buildStemLogger() { - return Logger()..addChannel( - 'console', - ConsoleLogDriver(), - formatter: PlainTextLogFormatter( - settings: FormatterSettings( - includePrefix: false, - ), - ), - ); +/// Available output formats for the shared Stem logger. +enum StemLogFormat { + /// Plain logfmt-style output without ANSI color codes. + plain, + + /// Colored terminal output intended for interactive local development. + pretty, +} + +/// Creates a formatter matching the shared Stem logging presets. +LogMessageFormatter createStemLogFormatter(StemLogFormat format) { + final settings = FormatterSettings(includePrefix: false); + return switch (format) { + StemLogFormat.pretty => PrettyLogFormatter(settings: settings), + StemLogFormat.plain => PlainTextLogFormatter(settings: settings), + }; } -Logger _stemLogger = _buildStemLogger(); +/// Creates a logger configured the same way Stem configures its shared logger. +Logger createStemLogger({ + Level level = Level.info, + StemLogFormat format = StemLogFormat.plain, + bool enableConsole = true, +}) { + final logger = Logger( + formatter: createStemLogFormatter(format), + defaultChannelEnabled: false, + )..setLevel(level); + if (enableConsole) { + logger.addChannel('console', ConsoleLogDriver()); + } + return logger; +} + +Logger _stemLogger = createStemLogger(); /// Shared logger configured with console output suitable for worker /// diagnostics. @@ -52,6 +74,12 @@ Context stemLogContext({ } /// Sets the minimum log [level] for the shared [stemLogger]. -void configureStemLogging({Level level = Level.info}) { +void configureStemLogging({ + Level level = Level.info, + StemLogFormat? format, +}) { stemLogger.setLevel(level); + if (format != null) { + stemLogger.formatter(createStemLogFormatter(format)); + } } diff --git a/packages/stem/test/unit/observability/logging_test.dart b/packages/stem/test/unit/observability/logging_test.dart index da513331..4bed7548 100644 --- a/packages/stem/test/unit/observability/logging_test.dart +++ b/packages/stem/test/unit/observability/logging_test.dart @@ -1,3 +1,10 @@ +import 'package:contextual/contextual.dart' + show + LogDriver, + LogEntry, + LoggerChannelSelection, + PlainTextLogFormatter, + PrettyLogFormatter; import 'package:stem/stem.dart'; import 'package:test/test.dart'; @@ -21,6 +28,42 @@ void main() { configureStemLogging(level: Level.warning); }); + test('createStemLogFormatter returns the pretty formatter', () { + expect( + createStemLogFormatter(StemLogFormat.pretty), + isA(), + ); + }); + + test( + 'configureStemLogging can switch the shared logger to pretty mode', + () async { + final original = stemLogger; + addTearDown(() => setStemLogger(original)); + final replacement = createStemLogger(enableConsole: false); + final driver = _RecordingLogDriver(); + replacement.addChannel('recording', driver); + setStemLogger(replacement); + + configureStemLogging(format: StemLogFormat.pretty); + stemLogger.channel('recording').info('pretty shared mode'); + await stemLogger.shutdown(); + + expect(driver.entries, hasLength(1)); + expect( + createStemLogFormatter(StemLogFormat.pretty), + isA(), + ); + }, + ); + + test('createStemLogFormatter returns the plain formatter', () { + expect( + createStemLogFormatter(StemLogFormat.plain), + isA(), + ); + }); + test('setStemLogger replaces the shared logger', () { final original = stemLogger; addTearDown(() => setStemLogger(original)); @@ -42,3 +85,14 @@ void main() { expect(context['queue'], 'default'); }); } + +class _RecordingLogDriver extends LogDriver { + _RecordingLogDriver() : entries = [], super('recording'); + + final List entries; + + @override + Future log(LogEntry entry) async { + entries.add(entry); + } +} From dec8a5c3c9b1400ba52d49b4649595ceef4944a8 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Sat, 21 Mar 2026 06:08:42 -0500 Subject: [PATCH 296/302] Make pretty logging the default --- .site/docs/core-concepts/observability.md | 6 +++--- packages/stem/lib/src/observability/logging.dart | 2 +- .../test/unit/observability/logging_test.dart | 15 +++++++++++++-- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/.site/docs/core-concepts/observability.md b/.site/docs/core-concepts/observability.md index f5a831db..a8ec2eb0 100644 --- a/.site/docs/core-concepts/observability.md +++ b/.site/docs/core-concepts/observability.md @@ -120,9 +120,9 @@ Use `stemLogger` (Contextual logger) for structured logs. ``` -For local development, switch the shared logger to colored terminal output with -`configureStemLogging(format: StemLogFormat.pretty)`. Keep the default plain -formatter for production log shipping and machine parsing. +Stem now defaults to `StemLogFormat.pretty` for interactive console output. +When you want machine-oriented output for production log shipping, switch back +with `configureStemLogging(format: StemLogFormat.plain)`. Workers automatically include attempt, queue, and worker id in log contexts when `StemSignals` are enabled. diff --git a/packages/stem/lib/src/observability/logging.dart b/packages/stem/lib/src/observability/logging.dart index 4ce446f5..0c74ffc8 100644 --- a/packages/stem/lib/src/observability/logging.dart +++ b/packages/stem/lib/src/observability/logging.dart @@ -21,7 +21,7 @@ LogMessageFormatter createStemLogFormatter(StemLogFormat format) { /// Creates a logger configured the same way Stem configures its shared logger. Logger createStemLogger({ Level level = Level.info, - StemLogFormat format = StemLogFormat.plain, + StemLogFormat format = StemLogFormat.pretty, bool enableConsole = true, }) { final logger = Logger( diff --git a/packages/stem/test/unit/observability/logging_test.dart b/packages/stem/test/unit/observability/logging_test.dart index 4bed7548..823b5a80 100644 --- a/packages/stem/test/unit/observability/logging_test.dart +++ b/packages/stem/test/unit/observability/logging_test.dart @@ -28,6 +28,17 @@ void main() { configureStemLogging(level: Level.warning); }); + test('createStemLogger defaults to the pretty formatter', () async { + final logger = createStemLogger(enableConsole: false); + final driver = _RecordingLogDriver(); + logger.addChannel('recording', driver); + + logger.channel('recording').info('default pretty mode'); + await logger.shutdown(); + + expect(driver.entries, hasLength(1)); + }); + test('createStemLogFormatter returns the pretty formatter', () { expect( createStemLogFormatter(StemLogFormat.pretty), @@ -41,8 +52,8 @@ void main() { final original = stemLogger; addTearDown(() => setStemLogger(original)); final replacement = createStemLogger(enableConsole: false); - final driver = _RecordingLogDriver(); - replacement.addChannel('recording', driver); + final driver = _RecordingLogDriver(); + replacement.addChannel('recording', driver); setStemLogger(replacement); configureStemLogging(format: StemLogFormat.pretty); From 8c7ea3833e1e15020d8c3ea0d8851f290cecabaa Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Sat, 21 Mar 2026 06:18:39 -0500 Subject: [PATCH 297/302] Increase pretty log contrast --- .../stem/lib/src/observability/logging.dart | 211 +++++++++++++++++- packages/stem/pubspec.yaml | 1 + .../test/unit/observability/logging_test.dart | 22 +- 3 files changed, 229 insertions(+), 5 deletions(-) diff --git a/packages/stem/lib/src/observability/logging.dart b/packages/stem/lib/src/observability/logging.dart index 0c74ffc8..dfa1f074 100644 --- a/packages/stem/lib/src/observability/logging.dart +++ b/packages/stem/lib/src/observability/logging.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; + +import 'package:ansicolor/ansicolor.dart'; import 'package:contextual/contextual.dart'; /// Available output formats for the shared Stem logger. @@ -13,7 +16,7 @@ enum StemLogFormat { LogMessageFormatter createStemLogFormatter(StemLogFormat format) { final settings = FormatterSettings(includePrefix: false); return switch (format) { - StemLogFormat.pretty => PrettyLogFormatter(settings: settings), + StemLogFormat.pretty => _StemPrettyLogFormatter(settings: settings), StemLogFormat.plain => PlainTextLogFormatter(settings: settings), }; } @@ -83,3 +86,209 @@ void configureStemLogging({ stemLogger.formatter(createStemLogFormatter(format)); } } + +class _StemPrettyLogFormatter extends LogMessageFormatter { + _StemPrettyLogFormatter({super.settings}); + + static final AnsiPen _keyPen = AnsiPen()..blue(bold: true); + static final AnsiPen _timestampPen = AnsiPen()..blue(); + static final AnsiPen _contextKeyPen = AnsiPen()..magenta(bold: true); + static final AnsiPen _prefixPen = AnsiPen()..cyan(); + static final AnsiPen _stackTracePen = AnsiPen()..red(); + + @override + String format(LogRecord record) { + final levelPen = _levelPen(record.level); + final parts = []; + + if (settings.includeTimestamp) { + final timestamp = settings.formatTimestamp(record.time); + parts.add( + '${_keyPen('time')}=${_timestampPen(_formatLogfmtValue(timestamp))}', + ); + } + + if (settings.includeLevel) { + parts.add( + '${_keyPen('level')}=' + '${levelPen(_formatLogfmtValue(record.level.name))}', + ); + } + + if (settings.includePrefix && record.context.has('prefix')) { + final prefix = record.context.get('prefix'); + parts.add( + '${_keyPen('prefix')}=${_prefixPen(_formatLogfmtValue(prefix))}', + ); + } + + final formattedMessage = _interpolateStemMessage( + record.message, + record.context, + ); + parts.add('${_keyPen('msg')}=${_formatLogfmtValue(formattedMessage)}'); + + final contextData = settings.includeHidden + ? record.context.all() + : record.context.visible(); + if (settings.includeContext && contextData.isNotEmpty) { + final contextEntries = Map.from(contextData); + if (settings.includePrefix) { + contextEntries.remove('prefix'); + } + final flattened = _flattenLogfmtContext(contextEntries); + for (final entry in flattened.entries) { + parts.add( + '${_contextKeyPen(_formatLogfmtKey(entry.key))}' + '=${_formatLogfmtValue(entry.value)}', + ); + } + } + + if (record.stackTraceProvided && record.stackTrace != null) { + parts.add( + '${_keyPen('stackTrace')}=' + '${_stackTracePen(_formatLogfmtValue(record.stackTrace.toString()))}', + ); + } + + return parts.join(' '); + } + + AnsiPen _levelPen(Level level) { + return switch (level) { + Level.debug => AnsiPen()..blue(), + Level.info => AnsiPen()..green(), + Level.notice => AnsiPen()..cyan(), + Level.warning => AnsiPen()..yellow(), + Level.error => AnsiPen()..red(), + Level.alert || Level.emergency => AnsiPen()..red(bold: true), + _ => AnsiPen()..white(), + }; + } +} + +String _interpolateStemMessage(String message, Context context) { + var resolved = message; + final placeholderPattern = RegExp(r'\{([^}]+)\}'); + final matches = placeholderPattern.allMatches(resolved).toList(); + + for (final match in matches) { + final rawKey = match.group(1)!; + final value = _dotLookup(context.all(), rawKey)?.toString(); + if (value == null) continue; + if (!resolved.contains('{$rawKey}')) continue; + resolved = resolved.replaceAll('{$rawKey}', value); + } + + return resolved; +} + +Object? _dotLookup(Map source, String key) { + final segments = key.split('.'); + Object? current = source; + for (final segment in segments) { + if (current is! Map) return null; + current = current[segment]; + } + return current; +} + +final _logfmtKeyChar = RegExp('[A-Za-z0-9_.:-]'); + +String _formatLogfmtKey(String key) { + if (key.isEmpty) { + return 'context'; + } + final buffer = StringBuffer(); + for (final rune in key.runes) { + final char = String.fromCharCode(rune); + buffer.write(_logfmtKeyChar.hasMatch(char) ? char : '_'); + } + return buffer.toString(); +} + +String _formatLogfmtValue(Object? value) { + final raw = _stringifyLogfmtValue(value); + if (_needsLogfmtQuoting(raw)) { + return '"${_escapeLogfmt(raw)}"'; + } + return raw; +} + +Map _flattenLogfmtContext( + Map context, { + String prefix = '', +}) { + final flattened = {}; + + void addEntry(String key, dynamic value) { + final fullKey = prefix.isEmpty ? key : '$prefix$key'; + if (value is Map) { + value.forEach((nestedKey, nestedValue) { + final nestedKeyString = nestedKey?.toString() ?? ''; + final combinedKey = fullKey.isEmpty + ? nestedKeyString + : '$fullKey.$nestedKeyString'; + flattened.addAll( + _flattenLogfmtContext({combinedKey: nestedValue}), + ); + }); + return; + } + flattened[fullKey] = value; + } + + context.forEach(addEntry); + return flattened; +} + +String _stringifyLogfmtValue(Object? value) { + if (value == null) return 'null'; + if (value is String) return value; + if (value is num || value is bool) return value.toString(); + if (value is DateTime) return value.toIso8601String(); + if (value is Map || value is Iterable) { + try { + return jsonEncode(value); + } on Object { + return value.toString(); + } + } + return value.toString(); +} + +bool _needsLogfmtQuoting(String value) { + if (value.isEmpty) return true; + for (var i = 0; i < value.length; i++) { + final code = value.codeUnitAt(i); + if (code == 0x20 || code == 0x09 || code == 0x0A || code == 0x0D) { + return true; + } + if (code == 0x22 || code == 0x5C || code == 0x3D) { + return true; + } + } + return false; +} + +String _escapeLogfmt(String value) { + final buffer = StringBuffer(); + for (final rune in value.runes) { + switch (rune) { + case 0x22: + buffer.write(r'\"'); + case 0x5C: + buffer.write(r'\\'); + case 0x0A: + buffer.write(r'\n'); + case 0x0D: + buffer.write(r'\r'); + case 0x09: + buffer.write(r'\t'); + default: + buffer.writeCharCode(rune); + } + } + return buffer.toString(); +} diff --git a/packages/stem/pubspec.yaml b/packages/stem/pubspec.yaml index 1eba4da9..cd3ff58b 100644 --- a/packages/stem/pubspec.yaml +++ b/packages/stem/pubspec.yaml @@ -8,6 +8,7 @@ environment: # Add regular dependencies here. dependencies: + ansicolor: ^2.0.3 collection: ^1.19.1 contextual: ^2.2.0 crypto: ^3.0.7 diff --git a/packages/stem/test/unit/observability/logging_test.dart b/packages/stem/test/unit/observability/logging_test.dart index 823b5a80..f0691025 100644 --- a/packages/stem/test/unit/observability/logging_test.dart +++ b/packages/stem/test/unit/observability/logging_test.dart @@ -1,7 +1,9 @@ +import 'package:ansicolor/ansicolor.dart' show ansiColorDisabled; import 'package:contextual/contextual.dart' show LogDriver, LogEntry, + LogRecord, LoggerChannelSelection, PlainTextLogFormatter, PrettyLogFormatter; @@ -40,10 +42,22 @@ void main() { }); test('createStemLogFormatter returns the pretty formatter', () { - expect( - createStemLogFormatter(StemLogFormat.pretty), - isA(), + final originalAnsiSetting = ansiColorDisabled; + addTearDown(() => ansiColorDisabled = originalAnsiSetting); + ansiColorDisabled = false; + + final formatter = createStemLogFormatter(StemLogFormat.pretty); + final output = formatter.format( + LogRecord( + time: DateTime.utc(2026, 3, 21, 12), + level: Level.info, + message: 'hello', + ), ); + + expect(output, contains('\x1B[38;5;12m')); + expect(output, isNot(contains('\x1B[38;5;255m'))); + expect(formatter, isNot(isA())); }); test( @@ -63,7 +77,7 @@ void main() { expect(driver.entries, hasLength(1)); expect( createStemLogFormatter(StemLogFormat.pretty), - isA(), + isNot(isA()), ); }, ); From 254a8d83b1776cb1d34401ce463aa3c87034f933 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Sat, 21 Mar 2026 06:40:35 -0500 Subject: [PATCH 298/302] Disable Stem logging by default --- .site/docs/core-concepts/observability.md | 7 ++++--- .../stem/lib/src/observability/logging.dart | 8 +++++++- .../test/unit/observability/logging_test.dart | 19 ++++++++++++++----- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/.site/docs/core-concepts/observability.md b/.site/docs/core-concepts/observability.md index a8ec2eb0..2a590e95 100644 --- a/.site/docs/core-concepts/observability.md +++ b/.site/docs/core-concepts/observability.md @@ -120,9 +120,10 @@ Use `stemLogger` (Contextual logger) for structured logs. ``` -Stem now defaults to `StemLogFormat.pretty` for interactive console output. -When you want machine-oriented output for production log shipping, switch back -with `configureStemLogging(format: StemLogFormat.plain)`. +The shared `stemLogger` starts silent by default, so opt in explicitly with +`configureStemLogging(level: Level.info, format: StemLogFormat.pretty)`. +When you want machine-oriented output for production log shipping, switch to +`configureStemLogging(format: StemLogFormat.plain)`. Workers automatically include attempt, queue, and worker id in log contexts when `StemSignals` are enabled. diff --git a/packages/stem/lib/src/observability/logging.dart b/packages/stem/lib/src/observability/logging.dart index dfa1f074..c732c292 100644 --- a/packages/stem/lib/src/observability/logging.dart +++ b/packages/stem/lib/src/observability/logging.dart @@ -25,7 +25,7 @@ LogMessageFormatter createStemLogFormatter(StemLogFormat format) { Logger createStemLogger({ Level level = Level.info, StemLogFormat format = StemLogFormat.pretty, - bool enableConsole = true, + bool enableConsole = false, }) { final logger = Logger( formatter: createStemLogFormatter(format), @@ -80,11 +80,17 @@ Context stemLogContext({ void configureStemLogging({ Level level = Level.info, StemLogFormat? format, + bool enableConsole = true, }) { stemLogger.setLevel(level); if (format != null) { stemLogger.formatter(createStemLogFormatter(format)); } + if (enableConsole) { + stemLogger.addChannel('console', ConsoleLogDriver()); + } else { + stemLogger.removeChannel('console'); + } } class _StemPrettyLogFormatter extends LogMessageFormatter { diff --git a/packages/stem/test/unit/observability/logging_test.dart b/packages/stem/test/unit/observability/logging_test.dart index f0691025..0d4c319b 100644 --- a/packages/stem/test/unit/observability/logging_test.dart +++ b/packages/stem/test/unit/observability/logging_test.dart @@ -30,12 +30,12 @@ void main() { configureStemLogging(level: Level.warning); }); - test('createStemLogger defaults to the pretty formatter', () async { - final logger = createStemLogger(enableConsole: false); + test('createStemLogger defaults to a silent logger', () async { + final logger = createStemLogger()..info('default silent mode'); + final driver = _RecordingLogDriver(); logger.addChannel('recording', driver); - - logger.channel('recording').info('default pretty mode'); + logger.channel('recording').info('explicit recording mode'); await logger.shutdown(); expect(driver.entries, hasLength(1)); @@ -65,7 +65,7 @@ void main() { () async { final original = stemLogger; addTearDown(() => setStemLogger(original)); - final replacement = createStemLogger(enableConsole: false); + final replacement = createStemLogger(); final driver = _RecordingLogDriver(); replacement.addChannel('recording', driver); setStemLogger(replacement); @@ -82,6 +82,15 @@ void main() { }, ); + test('configureStemLogging can keep the shared logger silent', () { + final original = stemLogger; + addTearDown(() => setStemLogger(original)); + final replacement = createStemLogger(enableConsole: true); + setStemLogger(replacement); + + configureStemLogging(enableConsole: false); + }); + test('createStemLogFormatter returns the plain formatter', () { expect( createStemLogFormatter(StemLogFormat.plain), From 8b6f0308ae79c77c7018007bbf9ff73b40e9fdb5 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Sat, 21 Mar 2026 14:05:21 -0500 Subject: [PATCH 299/302] readme update --- packages/stem/README.md | 1488 ++++----------------------------------- 1 file changed, 127 insertions(+), 1361 deletions(-) diff --git a/packages/stem/README.md b/packages/stem/README.md index b5bdf833..758a3afd 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -1,1426 +1,251 @@ +

+ Stem Logo +

+ [![pub package](https://img.shields.io/pub/v/stem.svg)](https://pub.dev/packages/stem) [![Dart](https://img.shields.io/badge/dart-%3E%3D3.9.2-blue.svg)](https://dart.dev/) [![License](https://img.shields.io/badge/license-MIT-purple.svg)](LICENSE) -[![Build Status](https://github.com/kingwill101/stem/workflows/ci/badge.svg)](https://github.com/kingwill101/stem/actions) -[![Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/kingwill101/stem/main/packages/stem/coverage/coverage.json)](https://github.com/kingwill101/stem/actions/workflows/stem.yaml) [![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-support-yellow.svg)](https://www.buymeacoffee.com/kingwill101) -

- Stem Logo -

- # Stem - Stem is a Dart-native background job platform. It gives you Celery-style -task execution with a Dart-first API, Redis Streams integration, retries, -scheduling, observability, and security tooling-all without leaving the Dart -ecosystem. +Stem is a Dart-first background job and workflow platform: enqueue work, run workers, and orchestrate durable workflows. -## Install +For full docs, API references, and in-depth guides, visit +https://kingwill101.github.io/stem. -```bash -dart pub add stem # core runtime APIs -dart pub add stem_redis # Redis broker + result backend -dart pub add stem_postgres # (optional) Postgres broker + backend -dart pub add stem_sqlite # (optional) SQLite broker + backend -dart pub add -d stem_builder # (optional) workflow/task code generator -dart pub global activate stem_cli -``` -Add the pub-cache bin directory to your `PATH` so the `stem_cli` tool is available: + +## Features + +- **Task pipeline** - enqueue with delays, priorities, idempotency helpers, and retries. +- **Workers** - isolate pools with soft/hard time limits, autoscaling, and remote control (`stem worker ping|revoke|shutdown`). +- **Scheduling** - Beat-style scheduler with interval/cron/solar/clocked entries and drift tracking. +- **Workflows** - Durable `Flow` runtime with pluggable stores (in-memory, + Redis, Postgres, SQLite) and CLI introspection via `stem wf`. +- **Observability** - Dartastic OpenTelemetry metrics/traces, heartbeats, CLI inspection (`stem observe`, `stem dlq`). +- **Security** - Payload signing (HMAC or Ed25519), TLS automation scripts, revocation persistence. +- **Adapters** - In-memory drivers included here; Redis Streams and Postgres adapters ship via the `stem_redis` and `stem_postgres` packages. +- **Specs & tooling** - OpenSpec change workflow, quality gates (see `example/quality_gates`), chaos/regression suites. + +## Install ```bash -export PATH="$HOME/.pub-cache/bin:$PATH" -stem --help +dart pub add stem +# Optional adapters +dart pub add stem_redis # Redis broker/backend +dart pub add stem_postgres # Postgres broker/backend +dart pub add stem_sqlite # SQLite broker/backend +dart pub add -d stem_builder # for annotations/codegen (optional) +dart pub add -d stem_cli # for CLI tooling ``` -## Quick Start -### StemClient entrypoint +## Examples -Use a single entrypoint to share broker/backend/config between workers and -workflow apps. +### Minimal in-memory task + worker ```dart -import 'dart:async'; -import 'package:stem/stem.dart'; - -class HelloTask implements TaskHandler { - static final definition = TaskDefinition.noArgs(name: 'demo.hello'); - - @override - String get name => definition.name; +import "dart:async"; +import "package:stem/stem.dart"; +class HelloTask extends TaskHandler { @override - TaskOptions get options => const TaskOptions(queue: 'default'); + String get name => "demo.hello"; @override Future call(TaskContext context, Map args) async { - print('Hello from StemClient'); + final name = args.valueOr("name", "world"); + print("Hello $name"); } } Future main() async { final client = await StemClient.inMemory(tasks: [HelloTask()]); - final worker = await client.createWorker(); unawaited(worker.start()); - await HelloTask.definition.enqueueAndWait( - client, - timeout: const Duration(seconds: 1), - ); + await client.enqueueValue("demo.hello", const {"name": "Stem"}); + await Future.delayed(const Duration(seconds: 1)); await worker.shutdown(); await client.close(); } ``` -`StemClient.createWorker(...)` infers queue subscriptions from the bundled or -explicitly supplied task handlers when `workerConfig.subscription` is omitted. - -For persistent adapters, keep `StemClient` as the entrypoint and resolve the -broker/backend stack from a URL: - -```dart -import 'package:stem/stem.dart'; -import 'package:stem_redis/stem_redis.dart'; - -final client = await StemClient.fromUrl( - 'redis://localhost:6379', - adapters: const [StemRedisAdapter()], - overrides: const StemStoreOverrides( - backend: 'redis://localhost:6379/1', - ), - tasks: [HelloTask()], -); -``` - -If adapter resolution already happens elsewhere, reuse the resolved stack -directly instead of rebuilding factories by hand: - -```dart -final stack = StemStack.fromUrl( - 'redis://localhost:6379', - adapters: const [StemRedisAdapter()], - overrides: const StemStoreOverrides( - backend: 'redis://localhost:6379/1', - ), -); - -final client = await stack.createClient( - tasks: [HelloTask()], -); -``` - -Reach for `StemClient.create(...)` only when the store factories are genuinely -custom and cannot be expressed through `StemStack.fromUrl(...)`. - -### Direct enqueue (map-based) +### Reusable stack from URL (Redis) ```dart -import 'dart:async'; -import 'package:stem/stem.dart'; -import 'package:stem_redis/stem_redis.dart'; - -class HelloTask implements TaskHandler { - @override - String get name => 'demo.hello'; - - @override - TaskOptions get options => const TaskOptions( - queue: 'default', - maxRetries: 3, - rateLimit: '10/s', - visibilityTimeout: Duration(seconds: 60), - ); - - @override - Future call(TaskContext context, Map args) async { - final who = args.valueOr('name', 'world'); - print('Hello $who (attempt ${context.attempt})'); - } -} +import "package:stem/stem.dart"; +import "package:stem_redis/stem_redis.dart"; Future main() async { final client = await StemClient.fromUrl( - 'redis://localhost:6379', + "redis://localhost:6379", adapters: const [StemRedisAdapter()], overrides: const StemStoreOverrides( - backend: 'redis://localhost:6379/1', + backend: "redis://localhost:6379/1", ), tasks: [HelloTask()], ); final worker = await client.createWorker(); unawaited(worker.start()); - await client.enqueueValue( - 'demo.hello', - const HelloArgs(name: 'Stem'), - codec: const PayloadCodec.json( - decode: HelloArgs.fromJson, - ), - ); + + await client.enqueueValue("demo.hello", const {"name": "Redis"}); await Future.delayed(const Duration(seconds: 1)); + await worker.shutdown(); await client.close(); } ``` -### Typed helpers with `TaskDefinition` - -Use the new typed wrapper when you want compile-time checking and shared metadata: +### Typed task definition and waiting for result ```dart -class HelloTask implements TaskHandler { - static final definition = TaskDefinition.json( - name: 'demo.hello', - metadata: TaskMetadata(description: 'Simple hello world example'), - ); - - @override - String get name => 'demo.hello'; - - @override - TaskOptions get options => const TaskOptions(maxRetries: 3); - - @override - TaskMetadata get metadata => definition.metadata; - - @override - Future call(TaskContext context, Map args) async { - final who = args.valueOr('name', 'world'); - print('Hello $who (attempt ${context.attempt})'); - } -} - class HelloArgs { const HelloArgs({required this.name}); final String name; - Map toJson() => {'name': name}; - - factory HelloArgs.fromJson(Map json) { - return HelloArgs(name: json['name']! as String); - } + Map toJson() => {"name": name}; + factory HelloArgs.fromJson(Map json) => + HelloArgs(name: json["name"] as String); } -Future main() async { - final client = await StemClient.fromUrl( - 'redis://localhost:6379', - adapters: const [StemRedisAdapter()], - overrides: const StemStoreOverrides( - backend: 'redis://localhost:6379/1', - ), - tasks: [HelloTask()], +class HelloTask2 extends TaskHandler { + static final definition = TaskDefinition.json( + name: "demo.hello2", + metadata: const TaskMetadata(description: "typed hello task"), ); - final worker = await client.createWorker(); - unawaited(worker.start()); - await HelloTask.definition.enqueue( - client, - const HelloArgs(name: 'Stem'), - ); - await Future.delayed(const Duration(seconds: 1)); - await worker.shutdown(); - await client.close(); -} -``` - -`Stem.enqueueCall(...)` remains the explicit low-level transport path for a -prebuilt `TaskCall`, and it can publish from the `TaskDefinition` metadata -alone. Producer-only processes therefore do not need to register the worker -handler locally just to enqueue typed calls. - -Use `TaskDefinition.json(...)` when your manual task args are normal -DTOs with `toJson()`. Use `TaskDefinition.versionedJson(...)` when the DTO -schema is expected to evolve and the published payload should persist an -explicit `__stemPayloadVersion`. Drop down to `TaskDefinition.codec(...)` only -when you need a custom `PayloadCodec`. Task args still need to encode to a -string-keyed map (typically `Map`) because they are published -as JSON-shaped data. -If the args need a custom map encoder and still need an explicit stored schema -version, use `TaskDefinition.versionedMap(...)`. -If the args stay unversioned but the stored result carries an explicit schema -version, `TaskDefinition.json(...)` also accepts -`decodeResultVersionedJson:` plus `defaultDecodeVersion:`. - -For manual handlers, use the context arg helpers or the typed payload readers -on the raw map instead of repeating casts. For workflows, use the context -param/result helpers: - -```dart -final customerId = context.requiredArg('customerId'); -final tenant = context.argOr('tenant', 'global'); -final draft = ctx.requiredParam( - 'draft', - codec: approvalDraftCodec, -); -final taskArgs = context.argsJson( - decode: InvoicePayload.fromJson, -); -final workflowParams = ctx.paramsAs( - codec: approvalDraftCodec, -); -final versionedParams = ctx.paramsVersionedJson( - defaultVersion: 2, - decode: ApprovalDraft.fromVersionedJson, -); -final nestedDraft = ctx.paramVersionedJson( - 'draft', - defaultVersion: 2, - decode: ApprovalDraft.fromVersionedJson, -); -``` - -For read-side `...VersionedJson(...)` helpers, `defaultVersion:` is only the -fallback used when an older stored payload does not already include -`__stemPayloadVersion`. Keep `version:` for write-side helpers that are -actually persisting a new schema version. - -For typed task calls, the definition and call objects now expose the common -producer operations directly. Prefer `enqueueAndWait(...)` when you only need -the final typed result: - -```dart -final result = await HelloTask.definition.enqueueAndWait( - stem, - const HelloArgs(name: 'Stem'), -); -``` - -For tasks without producer inputs, use `TaskDefinition.noArgs(...)` so callers -can publish directly instead of passing a fake empty map: - -```dart -final healthcheckDefinition = TaskDefinition.noArgs( - name: 'demo.healthcheck', -); - -await healthcheckDefinition.enqueue(stem); -``` - -If a no-arg task returns a DTO, prefer `TaskDefinition.noArgsJson(...)` in the -common `toJson()` / `Type.fromJson(...)` case. Use -`TaskDefinition.noArgsVersionedJson(...)` when the stored result needs an -explicit schema version, and `TaskDefinition.noArgsCodec(...)` when you need a -custom payload codec. These paths keep waiting helpers typed and advertise the -right result encoder in task metadata. - -For argful manual tasks, `TaskDefinition.versionedJson(...)` also accepts -`decodeResultVersionedJson:` when the stored result needs the same explicit -schema-version decode path. - -When a DTO payload needs an explicit persisted schema version, prefer -`PayloadCodec.versionedJson(...)`. It stores `__stemPayloadVersion` beside the -JSON payload and passes the persisted version into the decoder so you can keep -older payloads readable while newer producers emit the latest shape. - -If the payload evolves through multiple stored versions, prefer -`PayloadVersionRegistry` with `PayloadCodec.versionedJsonRegistry(...)` so -version-specific decoders live in one reusable registry instead of being -repeated inline at every call site. - -Use `PayloadCodec.versionedMap(...)` instead when the payload still needs a -custom map encoder or a nonstandard version-aware decode shape. -`PayloadCodec.versionedMapRegistry(...)` provides the same registry-backed -pattern for that custom-map case. - -The same registry-backed model is available on the author-facing factories: -- `TaskDefinition.versionedJsonRegistry(...)` -- `TaskDefinition.versionedMapRegistry(...)` -- `WorkflowRef.versionedJsonRegistry(...)` -- `WorkflowRef.versionedMapRegistry(...)` -- `WorkflowEventRef.versionedJsonRegistry(...)` -- `WorkflowEventRef.versionedMapRegistry(...)` -- `Flow.versionedJsonRegistry(...)` / `Flow.versionedMapRegistry(...)` -- `WorkflowScript.versionedJsonRegistry(...)` / - `WorkflowScript.versionedMapRegistry(...)` - -The same pattern now carries through the low-level readback helpers: -`status.payloadVersionedJson(...)`, `result.payloadVersionedJson(...)`, -`workflowResult.payloadVersionedJson(...)`, and -`runState.resultVersionedJson(...)`. - -You can also build requests fluently from the task definition itself: - -```dart -final result = await HelloTask.definition.enqueueAndWait( - stem, - const HelloArgs(name: 'Tenant A'), - headers: const {'x-tenant': 'tenant-a'}, - options: const TaskOptions(priority: 5), - notBefore: stemNow().add(const Duration(seconds: 30)), -); - -print(result?.value); -``` - -Treat `buildCall(...)` as the advanced path when you need an explicit -transport object with custom headers, metadata, delay, priority, or other -overrides. Build the final call directly with the overrides you need. For -the normal case, prefer direct `enqueue(...)` or `enqueueAndWait(...)`. - -### Enqueue from inside a task - -Handlers can enqueue follow-up work using `TaskContext.enqueue` and request -retries directly: - -```dart -class ParentTask implements TaskHandler { - @override - String get name => 'demo.parent'; - @override - TaskOptions get options => const TaskOptions(maxRetries: 3); - - @override - Future call(TaskContext context, Map args) async { - await context.enqueue( - 'demo.child', - args: {'id': 'child-1'}, - enqueueOptions: TaskEnqueueOptions( - countdown: const Duration(seconds: 30), - retry: true, - retryPolicy: TaskRetryPolicy(backoff: true), - ), - ); - - if (context.attempt == 0) { - await context.retry(countdown: const Duration(seconds: 10)); - } - } -} -``` - -If you inspect raw task progress signals, prefer -`signal.dataJson('key', ...)`, `signal.dataVersionedJson('key', ...)`, -`signal.dataAs('key', codec: ...)`, or `signal.dataValue('key')` for keyed -reads, and `signal.payloadJson(...)`, -`signal.payloadVersionedJson(...)`, or `signal.payloadAs(codec: ...)` when the -entire progress payload is one DTO. -Shared `TaskExecutionContext` implementations also expose -`context.retry(...)`, so typed annotated tasks can request retries without -depending on a concrete task runtime class. - -When a task runs inside a workflow-enabled runtime like `StemWorkflowApp`, -`TaskExecutionContext` can also start typed child workflows and emit typed -workflow events: - -```dart -final childWorkflow = Flow( - name: 'demo.child.workflow', - build: (flow) { - flow.step('complete', (ctx) async => 'done'); - }, -); -const childReadyEvent = WorkflowEventRef>( - topic: 'demo.child.workflow.ready', -); - -class ParentTask implements TaskHandler { - @override - String get name => 'demo.parent'; + String get name => definition.name; @override Future call(TaskContext context, Map args) async { - final result = await childWorkflow.startAndWait(context); - return result?.value ?? 'missing'; + final payload = HelloArgs.fromJson(args.cast()); + return "Hello ${payload.name}"; } } -class NotifyTask implements TaskHandler { - @override - String get name => 'demo.notify'; - - @override - Future call(TaskContext context, Map args) async { - await childReadyEvent.emit(context, {'status': 'ready'}); - } -} -``` - -### Bootstrap helpers - -Spin up a full runtime in one call using the bootstrap APIs: - -```dart -final demoWorkflow = Flow( - name: 'demo.workflow', - build: (flow) { - flow.step('hello', (ctx) async => 'done'); - }, -); - -final client = await StemClient.inMemory(); -final app = await client.createWorkflowApp( - flows: [demoWorkflow], -); +Future main() async { + final client = await StemClient.inMemory(tasks: [HelloTask2()]); + final worker = await client.createWorker(); + unawaited(worker.start()); -final runId = await demoWorkflow.start(app); -final result = await demoWorkflow.waitFor(app, runId); -print(result?.value); // 'hello world' -print(result?.state.status); // WorkflowStatus.completed + final result = await HelloTask2.definition.enqueueAndWait( + client, + const HelloArgs(name: "Typed"), + ); + print(result?.value); -await app.shutdown(); -await client.close(); + await worker.shutdown(); + await client.close(); +} ``` -If you need separate workflow lanes, pass `continuationQueue:` and -`executionQueue:` into `client.createWorkflowApp(...)`. When the workflow app -is creating the managed worker for you, those queue names are inferred into the -worker subscription automatically. - -For late registration, prefer the app helpers: - -- `registerWorkflow(...)` / `registerWorkflows(...)` -- `registerFlow(...)` / `registerFlows(...)` -- `registerScript(...)` / `registerScripts(...)` -- `registerModule(...)` - -If you are registering raw `WorkflowDefinition` values directly, prefer -`WorkflowDefinition.flowJson(...)` / `.scriptJson(...)` for the common DTO -path, `WorkflowDefinition.flowVersionedJson(...)` / -`.scriptVersionedJson(...)` when the stored result needs an explicit schema -version, and `WorkflowDefinition.flowCodec(...)` / `.scriptCodec(...)` for -custom result codecs. - -### Workflow script facade - -Prefer the high-level `WorkflowScript` facade when you want to author a -workflow as a single async function. The facade wraps `FlowBuilder` so your -code can `await script.step`, `await step.sleep`, and `await step.awaitEvent` -while retaining the same durability semantics (checkpoints, resume payloads, -auto-versioning) as the lower-level API: +### Workflow quick-start (Flow) ```dart -final client = await StemClient.inMemory(); -final app = await client.createWorkflowApp( - scripts: [ - WorkflowScript( - name: 'orders.workflow', - run: (script) async { - final checkout = await script.step('checkout', (step) async { - return await chargeCustomer( - step.params.requiredValue('userId'), - ); - }); - - await script.step('poll-shipment', (step) async { - await step.sleepFor(duration: const Duration(seconds: 30)); - final status = await fetchShipment(checkout.id); - if (!status.isComplete) { - await step.sleep(const Duration(seconds: 30)); - return 'waiting'; - } - return status.value; - }, autoVersion: true); - - final receipt = await script.step('notify', (step) async { - await sendReceiptEmail(checkout); - return 'emailed'; - }); - - return receipt; - }, - ), - ], -); -``` - -Inside a script checkpoint you can access the same metadata as `FlowContext`: - -- `step.previousResult` contains the prior step’s persisted value. -- `step.param()` / `step.requiredParam()` read workflow params without - repeating raw map lookups. -- `step.paramJson()` / `step.requiredParamJson()` decode nested DTO - params without a separate codec constant. -- `step.previousValue()` / `step.requiredPreviousValue()` read the prior - persisted value without repeating manual casts. -- `step.previousJson()` / `step.requiredPreviousJson()` decode prior DTO - results without a separate codec constant. -- `step.iteration` tracks the current auto-version suffix when - `autoVersion: true` is set. -- `step.idempotencyKey('scope')` builds stable outbound identifiers. -- `await step.sleepFor(duration: ...)` is the expression-style sleep path. -- `await step.waitForEvent(topic: ..., codec: ...)` is the expression-style - event wait path. -- `await event.wait(step)` keeps typed event waits on the - `WorkflowEventRef` surface. -- `step.sleepUntilResumed(...)` handles the common sleep-once, continue-on- - resume path. -- `step.waitForEventValue(...)` handles the common wait-for-one-event path. -- `step.waitForEventValueJson(...)` handles the same path for DTO event - payloads without a separate codec constant. -- `event.waitValue(step)` handles the same path when you already have a typed - `WorkflowEventRef`. -- `event.awaitOn(step)` keeps the lower-level flow-control suspend-first path - on that same typed event ref instead of dropping back to a raw topic string. -- `step.takeResumeData()`, `step.takeResumeValue(codec: ...)`, and - `step.takeResumeJson(...)` surface payloads from sleeps or awaited events - when you need lower-level control. - -### Current workflow model - -Stem supports three workflow authoring styles today: - -1. `Flow` for explicit orchestration -2. `WorkflowScript` for function-style durable workflows -3. `stem_builder` for annotated workflows with generated workflow refs +import "package:stem/stem.dart"; -The runtime shape is the same in every case: - -- bootstrap a `StemWorkflowApp` -- pass `flows:`, `scripts:`, and `tasks:` directly -- start runs with direct workflow helpers or generated workflow refs -- use `enqueueValue(...)`, `startWorkflow(...)` / - `startWorkflowValue(...)` / `startWorkflowJson(...)`, `emitJson(...)`, and - `waitForCompletion(...)` when names come from config, CLI input, or other - dynamic sources - -You do not need to build task registries manually for normal workflow usage. - -#### Manual `Flow` - -Use `Flow` when you want explicit step orchestration and fine control over -resume behavior: - -```dart -final approvalsFlow = Flow( - name: 'approvals.flow', +final onboardingFlow = Flow( + name: "demo.onboarding", build: (flow) { - flow.step('draft', (ctx) async { - final draft = ctx.requiredParamJson( - 'draft', - decode: ApprovalDraft.fromJson, - ); - return draft.documentId; - }); - - flow.step('manager-review', (ctx) async { - final resume = ctx.waitForEventValueJson( - 'approvals.manager', - decode: ApprovalDecision.fromJson, - ); - if (resume == null) { - return null; - } - return resume.approvedBy; - }); - - flow.step('finalize', (ctx) async { - final approvedBy = ctx.previousValue(); - return 'approved-by:$approvedBy'; + flow.step("welcome", (ctx) async { + return "Welcome ${ctx.requiredParam("name")}"; }); + flow.step("done", (ctx) async => "Done"); }, ); -final approvalsRef = approvalsFlow.refJson( -); - -final client = await StemClient.fromUrl('memory://'); -final app = await client.createWorkflowApp( - flows: [approvalsFlow], - tasks: const [], -); - -final runId = await approvalsRef.start( - app, - params: const ApprovalDraft(documentId: 'doc-42'), -); - -final result = await approvalsRef.waitFor(app, runId); -print(result?.value); -await app.close(); -await client.close(); -``` - -When you need advanced start options without dropping back to raw workflow -names, keep using the direct typed ref helpers: - -```dart -final runId = await approvalsRef.start( - app, - params: const ApprovalDraft(documentId: 'doc-42'), - parentRunId: 'parent-run', - ttl: const Duration(hours: 1), - cancellationPolicy: const WorkflowCancellationPolicy( - maxRuntime: Duration(minutes: 10), - ), -); -``` - -Use `refJson(...)` when your manual workflow start params are DTOs with -`toJson()`, or when the final result also needs a `Type.fromJson(...)` -decoder. Use `refVersionedJson(...)` when the start payload schema is expected -to evolve and the persisted params should store `__stemPayloadVersion`. Drop -down to `refCodec(...)` when you need a custom `PayloadCodec`. Workflow -params still need to encode to a string-keyed map (typically -`Map`) because they are persisted as JSON-shaped data. -If the params need a custom map encoder and still need an explicit stored -schema version, use `refVersionedMap(...)` / `WorkflowRef.versionedMap(...)`. -If the params stay unversioned but the stored result carries an explicit schema -version, `refJson(...)` / `WorkflowRef.json(...)` also accept -`decodeResultVersionedJson:` plus `defaultDecodeVersion:`. - -If a manual flow or script only needs DTO result decoding, prefer -`Flow.json(...)` or `WorkflowScript.json(...)`. Use -`Flow.versionedJson(...)` / `WorkflowScript.versionedJson(...)` when the stored -result needs an explicit schema version. If the final result needs a custom -codec, prefer `Flow.codec(...)` or `WorkflowScript.codec(...)` instead of -passing `resultCodec:` to the base constructor. If the result still needs a -custom map encoder plus an explicit stored schema version, use -`Flow.versionedMap(...)` / `WorkflowScript.versionedMap(...)`. -For manual typed refs, `refVersionedJson(...)` / `WorkflowRef.versionedJson(...)` -also accept `decodeResultVersionedJson:` when the stored result should use the -same explicit schema-version decode path. - -For workflows without start parameters, start directly from the flow or script -itself: - -```dart -final runId = await healthcheckFlow.start(app); -``` - -If you need to pass a no-args workflow through another API, `ref0()` still -builds the explicit `NoArgsWorkflowRef`. - -#### Manual `WorkflowScript` - -Use `WorkflowScript` when you want your workflow to read like a normal async -function while still persisting durable checkpoints: - -```dart -final billingRetryScript = WorkflowScript( - name: 'billing.retry-script', - run: (script) async { - final chargeId = await script.step('charge', (ctx) async { - final resume = ctx.waitForEventValueJson( - 'billing.charge.prepared', - decode: ChargePrepared.fromJson, - ); - if (resume == null) { - return 'pending'; - } - return resume.chargeId; - }); +Future main() async { + final appClient = await StemClient.inMemory(); + final app = await appClient.createWorkflowApp(flows: [onboardingFlow]); - return script.step('confirm', (ctx) async { - ctx.idempotencyKey('confirm-$chargeId'); - return 'receipt-$chargeId'; - }); - }, -); + final ref = onboardingFlow.refJson(HelloArgs.fromJson); + final runId = await ref.start(app, params: const HelloArgs(name: "Stem")); + final result = await ref.waitFor(app, runId); -final client = await StemClient.inMemory(); -final app = await client.createWorkflowApp( - scripts: [billingRetryScript], - tasks: const [], -); + print(result?.value); + await app.shutdown(); + await appClient.close(); +} ``` -#### Annotated workflows with `stem_builder` - -Use `stem_builder` when you want the best DX: plain method signatures, -generated manifests, and typed workflow refs. - -The important part of the model is that `run(...)` calls other annotated -methods directly. Those method calls are what become durable script checkpoints in -the generated proxy. - -The conceptual split is: - -- `Flow`: declared steps are the execution plan -- `WorkflowScript`: `run(...)` is the execution plan, and declared checkpoints - are manifest/introspection metadata +### Annotated workflow + task with `stem_builder` ```dart -import 'package:stem/stem.dart'; +import "package:stem/stem.dart"; +import "package:stem_builder/stem_builder.dart"; -part 'definitions.stem.g.dart'; +part "definitions.stem.g.dart"; -@WorkflowDefn(name: 'builder.example.user_signup', kind: WorkflowKind.script) -class BuilderUserSignupWorkflow { - Future> run(String email) async { - final user = await createUser(email); - await sendWelcomeEmail(email); - await sendOneWeekCheckInEmail(email); - return {'userId': user['id'], 'status': 'done'}; +@WorkflowDefn(name: "builder.signup", kind: WorkflowKind.script) +class BuilderSignupWorkflow { + Future run(String email) async { + final userId = await createUser(email); + await finalizeSignup(userId: userId); + return userId; } - @WorkflowStep(name: 'create-user') - Future> createUser(String email) async { - return {'id': 'user:$email'}; + @WorkflowStep(name: "create-user") + Future createUser(String email) async { + return "user-$email"; } - @WorkflowStep(name: 'send-welcome-email') - Future sendWelcomeEmail(String email) async {} - - @WorkflowStep(name: 'send-one-week-check-in-email') - Future sendOneWeekCheckInEmail(String email) async {} + @WorkflowStep(name: "finalize") + Future finalizeSignup({required String userId}) async {} } -@TaskDefn(name: 'builder.example.task') -Future builderExampleTask( - Map args, - {TaskExecutionContext? context} -) async {} +@TaskDefn(name: "builder.send_welcome") +Future sendWelcomeEmail( + String email, { + TaskExecutionContext? context, +}) async { + // optional: use context for logger/meta/retry helpers +} ``` -Script workflows use one authoring model: - -- start with a plain `run(String email, ...)` method -- add an optional named injected context when you need runtime metadata: - - `Future run(String email, {WorkflowScriptContext? context})` - - `Future capture(String email, {WorkflowExecutionContext? context})` -- direct checkpoint method calls still stay the default happy path - -Context injection works at every runtime layer: - -- flow steps can take `FlowContext` or `WorkflowExecutionContext` -- script runs can take `WorkflowScriptContext` -- script checkpoints can take `WorkflowScriptStepContext` or - `WorkflowExecutionContext` -- tasks can take `TaskExecutionContext` - -Durable workflow execution contexts enqueue tasks directly: - -- `WorkflowExecutionContext.enqueue(...)` -- typed task definitions can target those contexts via `enqueue(...)` - -Child workflows belong in durable execution boundaries: - -- `WorkflowExecutionContext` implements `WorkflowCaller`, so prefer - `ref.startAndWait(context, params: value)` inside flow steps and script - checkpoints -- pass `ttl:`, `parentRunId:`, or `cancellationPolicy:` directly to - `ref.start(...)` / `ref.startAndWait(...)` for normal override cases -- when you need an explicit low-level transport object, prefer - `ref.buildStart(...)` for the rarer explicit transport cases -- do not start child workflows from the raw `WorkflowScriptContext` body unless - you are deliberately managing replay/idempotency yourself - -For annotated workflows/tasks, the preferred shape is an optional named context -parameter. The runtime injects it, and it is not part of the durable payload. - -Serializable parameter rules for generated workflows and tasks are strict: - -- supported: - - `String`, `bool`, `int`, `double`, `num`, `Object?`, `null` - - `List` where `T` is serializable - - `Map` where `T` is serializable - - DTO classes with: - - a string-keyed `toJson()` map (typically `Map`) - - `factory Type.fromJson(Map json)` or an equivalent - named `fromJson` constructor -- not supported directly: - - optional/named business parameters on generated workflow/task entrypoints - -Typed task results can use the same DTO convention. - -Workflow inputs, checkpoint values, and final workflow results can use the same -DTO convention. The generated `PayloadCodec` persists the JSON form while -workflow code continues to work with typed objects. - -See the runnable example: - - - [example/annotated_workflows](example/annotated_workflows) - - `FlowContext` metadata - - plain proxy-driven script checkpoint calls - - `WorkflowScriptContext` + `WorkflowScriptStepContext` - - optional named context injection - - codec-backed workflow checkpoint values and workflow results - - typed `@TaskDefn` decoding scalar, `Map`, and `List` parameters - -Generate code: - ```bash dart run build_runner build -``` - -Wire the generated bundle directly into `StemWorkflowApp`: - -```dart -final client = await StemClient.fromUrl('memory://', module: stemModule); -final app = await client.createWorkflowApp(); - -final result = await StemWorkflowDefinitions.userSignup.startAndWait( - app, - params: 'user@example.com', -); -print(result?.value); -await app.close(); -``` - -When you use `module: stemModule`, `StemWorkflowApp` infers the worker -subscription from the workflow queue plus the default queues declared on the -bundled task handlers. You only need to set `workerConfig.subscription` -explicitly when your routing goes beyond those defaults. - -Generated output gives you: - -- `stemModule` -- `StemWorkflowDefinitions` -- `StemTaskDefinitions` -- typed workflow refs and task definitions whose advanced explicit transport - path uses `WorkflowStartCall` / `TaskCall` - -The same bundle also works for plain task apps: - -```dart -final client = await StemClient.fromUrl( - 'redis://localhost:6379', - adapters: const [StemRedisAdapter()], - module: stemModule, -); - -final taskApp = await client.createApp(); -``` -`StemApp` lazy-starts its managed worker on the first enqueue, wait, or -`app.canvas` dispatch call, so you only need `await taskApp.start()` when you -want explicit lifecycle control. - -For late registration on the plain task side, prefer the app helpers instead -of reaching through the registry: - -- `registerTask(...)` / `registerTasks(...)` -- `registerModule(...)` / `registerModules(...)` - -When you bootstrap a plain `StemApp`, the worker infers task queue -subscriptions from the bundled or explicitly supplied task handlers. Set -`workerConfig.subscription` explicitly only when you need broader routing. - -If you need to compose multiple generated or hand-written bundles, merge them -once and pass the combined module through bootstrap: - -```dart -final module = StemModule.merge([authModule, billingModule, stemModule]); -final client = await StemClient.inMemory(module: module); -final app = await client.createWorkflowApp(); +# After generation, use module + generated defs ``` -`StemModule.merge(...)` fails fast when modules declare the same task or -workflow name with different underlying definitions. - -Bootstrap helpers also accept `modules:` directly when you would rather let -the app/client merge them for you: - ```dart -final client = await StemClient.inMemory( - modules: [authModule, billingModule, stemModule], -); +// example usage after codegen +final client = await StemClient.inMemory(module: stemModule); final app = await client.createWorkflowApp(); -``` - -If you want to inspect what a bundled module will require before bootstrapping, -use `requiredTaskQueues()` for task-only workers and -`requiredWorkflowQueues(...)` for workflow-capable workers: - -```dart -final queues = stemModule.requiredWorkflowQueues( - continuationQueue: 'workflow-continue', - executionQueue: 'workflow-step', -); - -print(queues); -``` - -For low-level worker wiring, the module can also give you the exact -subscription directly: - -```dart -final subscription = stemModule.requiredWorkflowSubscription( - continuationQueue: 'workflow-continue', - executionQueue: 'workflow-step', -); -``` - -If your service already owns a `StemApp`, reuse it: - -```dart -final client = await StemClient.fromUrl( - 'redis://localhost:6379', - adapters: const [StemRedisAdapter()], - module: stemModule, -); - -final workflowApp = await client.createWorkflowApp(); -``` - -If you reuse an existing `StemApp`, its worker subscription stays authoritative. -Workflow-side queue inference only applies when `StemWorkflowApp` is also -creating the worker. - -#### Mixing workflows and normal tasks - -A workflow can orchestrate durable steps and still enqueue ordinary Stem tasks -for side effects: - -```dart -flow.step('emit-side-effects', (ctx) async { - final order = ctx.requiredPreviousValue>(); - - await ctx.enqueue( - 'ecommerce.audit.log', - args: { - 'event': 'order.checked_out', - 'entityId': order['id'], - 'detail': 'cart=${order['cartId']}', - }, - options: const TaskOptions(queue: 'default'), - ); - - return order; -}); -``` - -That split is the intended model: - -- workflows coordinate durable state transitions -- regular tasks handle side effects and background execution -- both are wired into the same app, and generated modules bundle the two - surfaces together - -### Typed workflow completion - -All workflow definitions (flows and scripts) accept an optional type argument -representing the value they produce. For workflows you define in code, prefer -their direct helpers or typed refs: - -```dart -final result = await ordersWorkflow.startAndWait(app); -print(result.requiredValue().total); -``` - -`StemWorkflowApp.waitForCompletion` is the low-level completion API for -name-based runs. It accepts `decode:`, the shorter `decodeJson:` shortcut for -plain DTOs, or `decodeVersionedJson:` for schema-versioned DTOs, and exposes -the decoded value along with the raw `RunState`, letting you work with domain -models without manual casts: - -```dart -final runId = await app.startWorkflow('orders.workflow'); -final result = await app.waitForCompletion( - runId, - decodeJson: OrderReceipt.fromJson, -); -if (result?.isCompleted == true) { - print(result!.requiredValue().total); -} else if (result?.timedOut == true) { - inspectSuspension(result?.state); -} -``` - -If you already have a raw `WorkflowResult`, use -`result.payloadJson(...)` or `result.payloadAs(codec: ...)` to decode the -stored workflow result without another cast/closure. -If you are inspecting the underlying `RunState` directly, use -`state.paramsJson(...)`, `state.paramsAs(codec: ...)`, -`state.resultJson(...)`, `state.resultAs(codec: ...)`, -`state.resultVersionedJson(...)`, `state.suspensionPayloadJson(...)`, -`state.suspensionPayloadVersionedJson(...)`, -`state.lastErrorJson(...)`, `state.runtimeJson(...)`, -`state.cancellationDataJson(...)`, or `state.suspensionPayloadAs(codec: ...)` -instead of manual raw-map casts. -Workflow run detail views expose the same convenience surface via -`runView.paramsJson(...)`, `runView.paramsAs(codec: ...)`, -`runView.resultJson(...)`, `runView.resultAs(codec: ...)`, -`runView.resultVersionedJson(...)`, `runView.suspensionPayloadJson(...)`, -`runView.suspensionPayloadVersionedJson(...)`, `runView.lastErrorJson(...)`, -`runView.runtimeJson(...)`, and `runView.suspensionPayloadAs(codec: ...)`. -Checkpoint entries from `viewCheckpoints(...)` and -`WorkflowCheckpointView.fromEntry(...)` expose the same surface via -`entry.valueJson(...)`, `entry.valueVersionedJson(...)`, and -`entry.valueAs(codec: ...)`. -Workflow introspection events expose matching helpers via -`event.resultJson(...)`, `event.resultVersionedJson(...)`, and -`event.resultAs(codec: ...)`, plus metadata helpers via -`event.metadataJson('key', ...)`, `event.metadataVersionedJson('key', ...)`, -`event.metadataAs('key', codec: ...)`, `event.metadataPayloadJson(...)`, and -`event.metadataPayloadVersionedJson(...)`. Worker events expose matching typed helpers on -`WorkerEvent.data` via `event.dataJson(...)`, -`event.dataVersionedJson(...)`, and `event.dataAs(codec: ...)`. Control -command completion signals expose the same surface on `response` and `error` -via `payload.responseJson(...)`, `payload.responseVersionedJson(...)`, -`payload.responseAs(codec: ...)`, `payload.errorJson(...)`, -`payload.errorVersionedJson(...)`, and `payload.errorAs(codec: ...)`. -For lower-level suspension directives, prefer `step.sleepJson(...)`, -`step.sleepVersionedJson(...)`, `step.awaitEventJson(...)`, -`step.awaitEventVersionedJson(...)`, and -`FlowStepControl.awaitTopicJson(...)` over hand-built maps. -Task lifecycle signals expose matching result helpers on -`TaskPostrunPayload` and `TaskSuccessPayload` via -`payload.resultJson(...)`, `payload.resultVersionedJson(...)`, and -`payload.resultAs(codec: ...)`. -Workflow lifecycle signals expose matching metadata helpers on -`WorkflowRunPayload` via `payload.metadataJson('key', ...)`, -`payload.metadataVersionedJson('key', ...)`, -`payload.metadataAs('key', codec: ...)`, and -`payload.metadataValue('key')`. When the whole metadata map is one DTO, -prefer `payload.metadataPayloadJson(...)`, -`payload.metadataPayloadVersionedJson(...)`, or -`payload.metadataPayloadAs(codec: ...)`. -Low-level `FlowStepControl` objects expose matching suspension metadata -helpers via `control.dataJson(...)`, `control.dataVersionedJson(...)`, and -`control.dataAs(codec: ...)`. -Workflow execution contexts expose the same version-aware decode path for -prior step results and resume/event payloads via -`step.requiredPreviousVersionedJson(...)`, -`step.takeResumeVersionedJson(...)`, -`step.waitForEventValueVersionedJson(...)`, and -`step.waitForEventVersionedJson(...)`. - -In the example above, these calls inside `run(...)`: - -```dart -final user = await createUser(email); -await sendWelcomeEmail(email); -await sendOneWeekCheckInEmail(email); -``` -are transformed by generated code into durable `script.step(...)` calls. See -the generated proxy in -`packages/stem_builder/example/lib/definitions.stem.g.dart` for the concrete -lowering. - -### Typed task completion - -Producers can now wait for individual task results using either -`TaskDefinition.enqueueAndWait(...)`, `TaskDefinition.waitFor(...)`, or -`Stem.waitForTask` with optional decoders. These helpers return a -`TaskResult` containing the underlying `TaskStatus`, decoded payload, and a -timeout flag. For low-level DTO waits, `Stem.waitForTask` also accepts -`decodeJson:` and `decodeVersionedJson:`: - -```dart -final charge = await ChargeCustomer.definition.enqueueAndWait( - stem, - ChargeArgs(orderId: '123'), -); -if (charge?.isSucceeded == true) { - print('Captured ${charge!.requiredValue().total}'); -} else if (charge?.isFailed == true) { - log.severe('Charge failed: ${charge!.status.error}'); -} -``` - -Use `waitFor(...)` when you need to keep the task id for inspection or pass it -through another boundary before waiting. -If you already have a raw `TaskStatus`, use `status.payloadJson(...)` or -`status.payloadAs(codec: ...)` to decode the whole payload DTO without another -cast/closure. If the whole task metadata map is one DTO, use -`status.metaJson(...)` or `status.metaAs(codec: ...)` instead of manual -`status.meta[...]` casts. -If you already have a raw `TaskResult`, use `result.payloadJson(...)` -or `result.payloadAs(codec: ...)` to decode the stored task result DTO without -another cast/closure. -If you are inspecting a low-level `TaskError`, use `error.metaJson(...)`, -`error.metaVersionedJson(...)`, or `error.metaAs(codec: ...)` instead of -manual `error.meta[...]` casts. - -Generated annotated tasks use the same surface: - -```dart -final receipt = await StemTaskDefinitions.sendEmailTyped.enqueueAndWait( - stem, - EmailDispatch( - email: 'typed@example.com', - subject: 'Welcome', - body: 'Codec-backed DTO payloads', - tags: ['welcome'], - ), -); -print(receipt?.requiredValue().deliveryId); -``` - -### Typed canvas helpers - -`TaskSignature` (and the `task()` helper) lets you declare the result type -for canvas primitives. The existing `Canvas.group`, `Canvas.chain`, and -`Canvas.chord` APIs now accept generics so typed values flow through sequential -steps, groups, and chords without manual casts: - -```dart -final dispatch = await canvas.group([ - task( - 'orders.fetch', - args: {'storeId': 42}, - decode: (payload) => OrderSummary.fromJson( - payload! as Map, - ), - ), - task('orders.refresh'), -]); - -dispatch.results.listen((result) { - if (result.isSucceeded) { - dashboard.update(result.requiredValue()); - } -}); - -final chainResult = await canvas.chain([ - task('metrics.seed', args: {'value': 1}), - task('metrics.bump', args: {'add': 3}), -]); -print(chainResult.value); // 4 - -final chordResult = await canvas.chord( - body: [ - task('image.resize', args: {'size': 256}), - task('image.resize', args: {'size': 512}), - ], - callback: task('image.aggregate'), -); -print('Body results: ${chordResult.values}'); -``` - -If you later inspect the aggregate backend record via -`StemApp.getGroupStatus(...)` or `StemClient.getGroupStatus(...)`, use -`status.resultValues()` for scalar child results or -`status.resultJson(...)` / `status.resultAs(codec: ...)` for DTO payloads -instead of manually mapping `status.results.values`. - -### Task payload encoders - -By default Stem stores handler arguments/results exactly as provided (JSON-friendly -structures). Configure default `TaskPayloadEncoder`s when bootstrapping -`StemClient`, `StemApp`, `StemWorkflowApp`, or `Canvas` to plug in custom -serialization (encryption, compression, base64 wrappers, etc.) for both task -arguments and persisted results: - -```dart -import 'dart:convert'; - -class Base64ResultEncoder extends TaskPayloadEncoder { - const Base64ResultEncoder(); - - @override - Object? encode(Object? value) { - if (value is String) { - return base64Encode(utf8.encode(value)); - } - return value; - } - - @override - Object? decode(Object? stored) { - if (stored is String) { - return utf8.decode(base64Decode(stored)); - } - return stored; - } -} - -final client = await StemClient.inMemory( - tasks: [...], - resultEncoder: const Base64ResultEncoder(), - argsEncoder: const Base64ResultEncoder(), - additionalEncoders: const [MyOtherEncoder()], -); - -final canvas = client.createCanvas(); -``` - -Every envelope published by Stem carries the argument encoder id in headers/meta -(`stem-args-encoder` / `__stemArgsEncoder`) and every status stored in a result -backend carries the result encoder id (`__stemResultEncoder`). Workers use the same -`TaskPayloadEncoderRegistry` to resolve IDs, ensuring payloads are decoded exactly -once regardless of how many custom encoders you register. - -Per-task overrides live on `TaskMetadata`, so both handlers and the corresponding -`TaskDefinition` share the same configuration: - -```dart -class SecretTask extends TaskHandler { - static const _encoder = Base64ResultEncoder(); - - @override - TaskMetadata get metadata => const TaskMetadata( - description: 'Encrypt args + results', - argsEncoder: _encoder, - resultEncoder: _encoder, - ); - - // ... -} -``` - -Encoders run exactly once per persistence/read cycle and fall back to the JSON -behavior when none is provided. - -### Unique task deduplication - -Set `TaskOptions(unique: true)` to prevent duplicate enqueues when a matching -task is already in-flight. Stem uses a `UniqueTaskCoordinator` backed by a -`LockStore` (Redis or in-memory) to claim uniqueness before publishing: - -```dart -final lockStore = await RedisLockStore.connect('redis://localhost:6379'); -final unique = UniqueTaskCoordinator( - lockStore: lockStore, - defaultTtl: const Duration(minutes: 5), -); - -final client = await StemClient.fromUrl( - 'redis://localhost:6379', - adapters: const [StemRedisAdapter()], - overrides: const StemStoreOverrides( - backend: 'redis://localhost:6379/1', - ), - tasks: [OrdersSyncTask()], - uniqueTaskCoordinator: unique, +final runId = await StemWorkflowDefinitions.builderSignup.startAndWait( + app, + "alice@example.com", ); +final result = await StemWorkflowDefinitions.builderSignup.waitFor(app, runId); +print(result?.value); // {user: alice@example.com} ``` -The unique key is derived from: - -- task name -- queue name -- task arguments -- headers -- metadata (excluding keys prefixed with `stem.`) +### 5) CLI at a glance -Keys are canonicalized (sorted maps, stable JSON) so equivalent inputs produce -the same hash. Use `uniqueFor` to control the TTL; when unset, the coordinator -falls back to `visibilityTimeout` or its default TTL. - -Override the unique key when needed: - -```dart -final id = await client.enqueue( - 'orders.sync', - args: {'id': 42}, - options: const TaskOptions(unique: true, uniqueFor: Duration(minutes: 10)), - meta: {UniqueTaskMetadata.override: 'order-42'}, -); +```bash +# Start a worker or run built-in introspection commands +stem --help +stem worker start --help +stem wf --help ``` -When a duplicate is skipped, Stem returns the existing task id, emits the -`stem.tasks.deduplicated` metric, and appends a duplicate entry to the result -backend metadata under `stem.unique.duplicates`. - -### Durable workflow semantics - -- Chords dispatch from workers. Once every branch completes, any worker may enqueue the callback, ensuring producer crashes do not block completion. -- Steps may run multiple times. The runtime replays a step from the top after - every suspension (sleep, awaited event, rewind) and after worker crashes, so - handlers must be idempotent. -- Event waits are durable watchers. When a step calls `awaitEvent`, the runtime - registers the run in the store so the next emitted payload is persisted - atomically and delivered exactly once on resume. Operators can inspect - suspended runs via `WorkflowStore.listWatchers` or `runsWaitingOn`. When the - watcher metadata is one DTO, prefer `watcher.dataJson(...)` or - `watcher.dataAs(codec: ...)`. When only the nested watcher payload is a DTO, - use `watcher.payloadJson(...)` or `watcher.payloadAs(codec: ...)`. -- Checkpoints act as heartbeats. Every successful `saveStep` refreshes the run's - `updatedAt` timestamp so operators (and future reclaim logic) can distinguish - actively-owned runs from ones that need recovery. -- Run execution is lease-based. The runtime claims each run with a lease - (`runLeaseDuration`) and renews it while work continues. If another worker - owns the lease, the task is retried so a takeover can occur once the lease - expires. Keep `runLeaseDuration` at least as long as the broker visibility - timeout and ensure `leaseExtension` renewals happen before either expires. -- Sleeps persist wake timestamps. When a resumed step calls `sleep` again, the - runtime skips re-suspending once the stored `resumeAt` is reached so loop - handlers can simply call `sleep` without extra guards. -- Prefer the higher-level helpers for common cases: - - ```dart - await ctx.sleepFor(duration: const Duration(milliseconds: 200)); - ``` - - ```dart - final payload = await ctx.waitForEvent>( - topic: 'demo.event', - ); - ``` - -- Use `ctx.takeResumeData()` or `ctx.takeResumeValue(codec: ...)` when you - need lower-level control over the resume payload or need to distinguish - custom suspension markers yourself. -- When you suspend with the low-level API, provide a marker in the `data` - payload so the resumed step can distinguish the wake-up path. For example: - - ```dart - if (!ctx.sleepUntilResumed(const Duration(milliseconds: 200))) { - return null; - } - ``` - -- Awaited events behave the same way: the emitted payload is delivered via - `takeResumeData()` / `takeResumeValue(codec: ...)` when the run resumes. -- When you have a DTO event, emit it through `workflowApp.emitJson(...)` / - `workflowApp.emitVersionedJson(...)` / `workflowApp.emitValue(...)` (or - `runtime.emitJson(...)` / `runtime.emitVersionedJson(...)` / - `runtime.emitValue(...)` when you are intentionally using the low-level - runtime) with a `PayloadCodec`, or use `WorkflowEventRef.json(...)` / - `WorkflowEventRef.versionedJson(...)` / - `WorkflowEventRef.versionedMap(...)` as the shortest typed event forms - and call `event.emit(emitter, dto)` as the happy path. - Pair that with `await event.wait(ctx)`. Event payloads still serialize onto - a string-keyed JSON-like map. -- Only return values you want persisted. If a handler returns `null`, the - runtime treats it as "no result yet" and will run the step again on resume. -- Derive outbound idempotency tokens with `ctx.idempotencyKey('charge')` so - retries reuse the same stable identifier (`workflow/run/scope`). -- Use `autoVersion: true` on steps that you plan to re-execute (e.g. after - rewinding). Each completion stores a checkpoint like `step#0`, `step#1`, ... and - the handler receives the current iteration via `ctx.iteration`. -- Set an optional `WorkflowCancellationPolicy` when starting runs to auto-cancel - workflows that exceed a wall-clock budget or stay suspended beyond an allowed - duration. When a policy trips, the run transitions to `cancelled` and the - reason is surfaced via `stem wf show`. - -```dart -flow.step( - 'process-item', - autoVersion: true, - (ctx) async { - final iteration = ctx.iteration; - final item = items[iteration]; - return await process(item); - }, -); -final runId = await workflowApp.startWorkflow( - 'demo.workflow', - params: const {'userId': '42'}, - cancellationPolicy: const WorkflowCancellationPolicy( - maxRunDuration: Duration(minutes: 15), - maxSuspendDuration: Duration(minutes: 5), - ), -); -``` +## Want depth? -When those low-level name-based paths already have DTO inputs, prefer -`client.enqueueValue(...)` plus `workflowApp.startWorkflowValue(...)`, -`workflowApp.startWorkflowJson(...)`, or -`workflowApp.startWorkflowVersionedJson(...)` over hand-built map payloads. -Use `PayloadCodec.versionedJson(...)` with `enqueueValue(...)` or -`startWorkflowValue(...)`, or the workflow-specific versioned helpers, when -the DTO schema is expected to evolve and you want the payload to persist an -explicit `__stemPayloadVersion`. +This README is intentionally example-focused. +For implementation details, runtime semantics, adapter tuning, and operational playbooks, +see the full docs at https://kingwill101.github.io/stem. -Adapter packages expose typed factories (e.g. `redisBrokerFactory`, -`postgresResultBackendFactory`, `sqliteWorkflowStoreFactory`) so you can replace -drivers by importing the adapter you need. - -## Features - -- **Task pipeline** - enqueue with delays, priorities, idempotency helpers, and retries. -- **Workers** - isolate pools with soft/hard time limits, autoscaling, and remote control (`stem worker ping|revoke|shutdown`). -- **Scheduling** - Beat-style scheduler with interval/cron/solar/clocked entries and drift tracking. -- **Workflows** - Durable `Flow` runtime with pluggable stores (in-memory, - Redis, Postgres, SQLite) and CLI introspection via `stem wf`. -- **Observability** - Dartastic OpenTelemetry metrics/traces, heartbeats, CLI inspection (`stem observe`, `stem dlq`). -- **Security** - Payload signing (HMAC or Ed25519), TLS automation scripts, revocation persistence. -- **Adapters** - In-memory drivers included here; Redis Streams and Postgres adapters ship via the `stem_redis` and `stem_postgres` packages. -- **Specs & tooling** - OpenSpec change workflow, quality gates (see `example/quality_gates`), chaos/regression suites. ## Documentation & Examples -- Full docs: [Full docs](.site/docs) (run `npm install && npm start` inside `.site/`). + - Guided onboarding: [Guided onboarding](.site/docs/getting-started/) (install → infra → ops → production). - Examples (each has its own README): - [workflows](example/workflows/) - end-to-end workflow samples (in-memory, sleep/event, SQLite, Redis). See `versioned_rewind.dart` for auto-versioned step rewinds. @@ -1441,62 +266,3 @@ drivers by importing the adapter you need. - [security examples](example/security/*) - payload signing + TLS profiles. - [postgres_tls](example/postgres_tls) - Redis broker + Postgres backend secured via the shared `STEM_TLS_*` settings. - [otel_metrics](example/otel_metrics) - OTLP collectors + Grafana dashboards. - -## Running Tests Locally - -Start the dockerised dependencies and export the integration variables before -invoking the test suite: - -```bash -source packages/stem_cli/_init_test_env -dart test -``` - -The helper script launches `packages/stem_cli/docker/testing/docker-compose.yml` -(Redis + Postgres) and populates `STEM_TEST_*` environment variables needed by -the integration suites. - -### Adapter Contract Tests - -Stem ships a reusable adapter contract suite in -`packages/stem_adapter_tests`. Adapter packages (Redis broker/postgres -backend, SQLite adapters, and any future integrations) add it as a -`dev_dependency` and invoke `runBrokerContractTests` / -`runResultBackendContractTests` from their integration tests. The harness -exercises core behaviours-enqueue/ack/nack, dead-letter replay, lease -extension, result persistence, group aggregation, and heartbeat storage-so -all adapters stay aligned with the broker and result backend contracts. See -`test/integration/brokers/postgres_broker_integration_test.dart` and -`test/integration/backends/postgres_backend_integration_test.dart` for -reference usage. - -### Testing helpers - -Use `FakeStem` from `package:stem/stem.dart` in unit tests when you want to -record enqueued jobs without standing up brokers: - -```dart -final fake = FakeStem(); -await fake.enqueue('tasks.email', args: {'id': 1}); -final recorded = fake.enqueues.single; -expect(recorded.name, 'tasks.email'); -``` - -- `FakeWorkflowClock` keeps workflow tests deterministic. Inject the same clock - into your runtime and store, then advance it directly instead of sleeping. - At the app layer, prefer `workflowApp.resumeDueRuns(clock.now())` once that - same fake clock is wired into the store backing the app: - - ```dart - final clock = FakeWorkflowClock(DateTime.utc(2024, 1, 1)); - final store = InMemoryWorkflowStore(clock: clock); - final runtime = WorkflowRuntime( - stem: stem, - store: store, - eventBus: InMemoryEventBus(store), - clock: clock, - ); - - clock.advance(const Duration(seconds: 5)); - final dueRuns = await store.dueRuns(clock.now()); - ``` From 970f59d2a800c7e1b999266a714973f03928d96e Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Sat, 28 Mar 2026 01:28:01 -0500 Subject: [PATCH 300/302] Control worker auto-start in app shortcuts --- packages/stem/README.md | 180 +++++++++++++++++- .../example/workflows/multiple_workers.dart | 112 +++++++++++ packages/stem/lib/src/bootstrap/stem_app.dart | 36 +++- .../stem/lib/src/bootstrap/stem_client.dart | 4 + .../stem/lib/src/bootstrap/workflow_app.dart | 165 +++++++++------- .../shortcut_allow_auto_start_test.dart | 86 +++++++++ 6 files changed, 502 insertions(+), 81 deletions(-) create mode 100644 packages/stem/example/workflows/multiple_workers.dart create mode 100644 packages/stem/test/bootstrap/shortcut_allow_auto_start_test.dart diff --git a/packages/stem/README.md b/packages/stem/README.md index 758a3afd..3c215742 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -43,6 +43,13 @@ dart pub add -d stem_cli # for CLI tooling ## Examples +`StemApp` and `StemWorkflowApp` shortcut helpers lazily start their managed +worker by default. Pass `allowWorkerAutoStart: false` when you want producer +or orchestration shortcuts without starting that worker in the background, +then call `start()` explicitly when you're ready. `StemWorkflowApp` also +exposes `startRuntime()` and `startWorker()` when you want those lifecycles +split. + ### Minimal in-memory task + worker ```dart @@ -161,7 +168,11 @@ final onboardingFlow = Flow( Future main() async { final appClient = await StemClient.inMemory(); - final app = await appClient.createWorkflowApp(flows: [onboardingFlow]); + final app = await appClient.createWorkflowApp( + flows: [onboardingFlow], + allowWorkerAutoStart: false, + ); + await app.start(); final ref = onboardingFlow.refJson(HelloArgs.fromJson); final runId = await ref.start(app, params: const HelloArgs(name: "Stem")); @@ -216,7 +227,8 @@ dart run build_runner build ```dart // example usage after codegen final client = await StemClient.inMemory(module: stemModule); -final app = await client.createWorkflowApp(); +final app = await client.createWorkflowApp(allowWorkerAutoStart: false); +await app.start(); final runId = await StemWorkflowDefinitions.builderSignup.startAndWait( app, @@ -226,6 +238,94 @@ final result = await StemWorkflowDefinitions.builderSignup.waitFor(app, runId); print(result?.value); // {user: alice@example.com} ``` +### Workflow with multiple worker queues + +```dart +import "package:stem/stem.dart"; + +final onboardingFlow = Flow>( + name: "workflow.multi_workers", + build: (flow) { + flow.step("dispatch", (ctx) async { + final notifyTaskId = await ctx.enqueue( + "notify.send", + args: {"email": "alex@example.com"}, + enqueueOptions: const TaskEnqueueOptions(queue: "notifications"), + ); + final analyticsTaskId = await ctx.enqueue( + "analytics.track", + args: {"userId": "alex", "event": "account.created"}, + enqueueOptions: const TaskEnqueueOptions(queue: "analytics"), + ); + return {"notifyTaskId": notifyTaskId, "trackTaskId": analyticsTaskId}; + }); + }, +); + +class NotifyTask extends TaskHandler { + @override + String get name => "notify.send"; + + @override + TaskOptions get options => const TaskOptions(queue: "notifications"); + + @override + Future call(TaskContext context, Map args) async => + "notified:${args['email']}"; +} + +class AnalyticsTask extends TaskHandler { + @override + String get name => "analytics.track"; + + @override + TaskOptions get options => const TaskOptions(queue: "analytics"); + + @override + Future call(TaskContext context, Map args) async => + "tracked:${args['event']}"; +} + +Future main() async { + final client = await StemClient.inMemory(); + final app = await client.createWorkflowApp( + flows: [onboardingFlow], + workerConfig: const StemWorkerConfig(queue: "workflow"), + ); + await app.start(); + + final notifications = await client.createWorker( + workerConfig: StemWorkerConfig( + queue: "notifications-worker", + consumerName: "notifications-worker", + subscription: RoutingSubscription.singleQueue("notifications"), + ), + tasks: [NotifyTask()], + ); + final analytics = await client.createWorker( + workerConfig: StemWorkerConfig( + queue: "analytics-worker", + consumerName: "analytics-worker", + subscription: RoutingSubscription.singleQueue("analytics"), + ), + tasks: [AnalyticsTask()], + ); + + await notifications.start(); + await analytics.start(); + + final result = await onboardingFlow.startAndWait(app); + final taskIds = result?.value ?? const {}; + print(await app.waitForTask(taskIds['notifyTaskId']!)); + print(await app.waitForTask(taskIds['trackTaskId']!)); + + await notifications.shutdown(); + await analytics.shutdown(); + await app.close(); + await client.close(); +} +``` + ### 5) CLI at a glance ```bash @@ -236,6 +336,81 @@ stem wf --help ``` +### General worker management (multi-worker setup) + +```dart +import "package:stem/stem.dart"; + +class EmailTask extends TaskHandler { + @override + String get name => "notify.send"; + + @override + TaskOptions get options => const TaskOptions(queue: "notify"); + + @override + Future call(TaskContext context, Map args) async { + print("notify queue: ${args['to']}"); + } +} + +class ReportTask extends TaskHandler { + @override + String get name => "reports.aggregate"; + + @override + TaskOptions get options => const TaskOptions(queue: "reports"); + + @override + Future call(TaskContext context, Map args) async { + print("reports queue: ${args['reportId']}"); + } +} + +Future main() async { + final client = await StemClient.inMemory(); + + final notifyWorker = await client.createWorker( + workerConfig: StemWorkerConfig( + queue: "notify-worker", + consumerName: "notify-worker", + subscription: RoutingSubscription.singleQueue("notify"), + ), + tasks: [EmailTask()], + ); + + final reportsWorker = await client.createWorker( + workerConfig: StemWorkerConfig( + queue: "reports-worker", + consumerName: "reports-worker", + subscription: RoutingSubscription.singleQueue("reports"), + ), + tasks: [ReportTask()], + ); + + await notifyWorker.start(); + await reportsWorker.start(); + + await client.enqueue( + "notify.send", + args: {"to": "ops@example.com"}, + ); + await client.enqueue( + "reports.aggregate", + args: {"reportId": "r-2026-q1"}, + ); + + await Future.delayed(const Duration(milliseconds: 400)); + + await notifyWorker.shutdown(); + await reportsWorker.shutdown(); + await client.close(); +} +``` + +- Full example that combines a workflow dispatching to dedicated workers: + [multiple_workers.dart](example/workflows/multiple_workers.dart) + ## Want depth? This README is intentionally example-focused. @@ -249,6 +424,7 @@ see the full docs at https://kingwill101.github.io/stem. - Guided onboarding: [Guided onboarding](.site/docs/getting-started/) (install → infra → ops → production). - Examples (each has its own README): - [workflows](example/workflows/) - end-to-end workflow samples (in-memory, sleep/event, SQLite, Redis). See `versioned_rewind.dart` for auto-versioned step rewinds. +- [multiple_workers.dart](example/workflows/multiple_workers.dart) - workflow dispatching tasks to `notifications` and `analytics` workers. - [cancellation_policy](example/workflows/cancellation_policy.dart) - demonstrates auto-cancelling long workflows using `WorkflowCancellationPolicy`. - [rate_limit_delay](example/rate_limit_delay) - delayed enqueue, priority clamping, Redis rate limiter. - [dlq_sandbox](example/dlq_sandbox) - dead-letter inspection and replay via CLI. diff --git a/packages/stem/example/workflows/multiple_workers.dart b/packages/stem/example/workflows/multiple_workers.dart new file mode 100644 index 00000000..63a18d0c --- /dev/null +++ b/packages/stem/example/workflows/multiple_workers.dart @@ -0,0 +1,112 @@ +// Demonstrates one workflow routing tasks to multiple dedicated worker queues. +// Run with: dart run example/workflows/multiple_workers.dart + +import 'package:stem/stem.dart'; + +const String _workflowQueue = 'workflow'; +const String _notificationsQueue = 'notifications'; +const String _analyticsQueue = 'analytics'; + +final accountOnboardingFlow = Flow>( + name: 'workflow.multi_workers', + build: (flow) { + flow.step('dispatch-to-workers', (ctx) async { + final notifyTaskId = await ctx.enqueue( + 'notify.send', + args: const {'email': 'alex@example.com'}, + enqueueOptions: const TaskEnqueueOptions(queue: _notificationsQueue), + ); + final trackTaskId = await ctx.enqueue( + 'analytics.track', + args: const {'userId': 'alex', 'event': 'account.created'}, + enqueueOptions: const TaskEnqueueOptions(queue: _analyticsQueue), + ); + + return { + 'notifyTaskId': notifyTaskId, + 'trackTaskId': trackTaskId, + }; + }); + }, +); + +class NotifyTask extends TaskHandler { + @override + String get name => 'notify.send'; + + @override + TaskOptions get options => const TaskOptions(queue: _notificationsQueue); + + @override + Future call(TaskContext context, Map args) async { + final email = args['email'] as String? ?? 'unknown'; + print('[notifications worker] send notification -> $email'); + return 'notified:$email'; + } +} + +class AnalyticsTask extends TaskHandler { + @override + String get name => 'analytics.track'; + + @override + TaskOptions get options => const TaskOptions(queue: _analyticsQueue); + + @override + Future call(TaskContext context, Map args) async { + final userId = args['userId'] as String? ?? 'unknown'; + final event = args['event'] as String? ?? 'unknown'; + print('[analytics worker] track event "$event" for user "$userId"'); + return 'tracked:$event:$userId'; + } +} + +Future main() async { + final client = await StemClient.inMemory(); + final workflowApp = await client.createWorkflowApp( + flows: [accountOnboardingFlow], + workerConfig: const StemWorkerConfig(queue: _workflowQueue), + ); + await workflowApp.start(); + + final notificationsWorker = await client.createWorker( + workerConfig: StemWorkerConfig( + queue: 'notifications-worker', + consumerName: 'notifications-worker', + subscription: RoutingSubscription.singleQueue(_notificationsQueue), + ), + tasks: [NotifyTask()], + ); + final analyticsWorker = await client.createWorker( + workerConfig: StemWorkerConfig( + queue: 'analytics-worker', + consumerName: 'analytics-worker', + subscription: RoutingSubscription.singleQueue(_analyticsQueue), + ), + tasks: [AnalyticsTask()], + ); + + Future.wait([notificationsWorker.start(), analyticsWorker.start()]); + + final workflowResult = await accountOnboardingFlow.startAndWait(workflowApp); + final taskIds = workflowResult?.value ?? const {}; + final notifyResult = await workflowApp.waitForTask( + taskIds['notifyTaskId']!, + timeout: const Duration(seconds: 5), + ); + final trackResult = await workflowApp.waitForTask( + taskIds['trackTaskId']!, + timeout: const Duration(seconds: 5), + ); + + print('workflow ${workflowResult?.runId} complete'); + print('notifier: ${notifyResult?.value}'); + print('analytics: ${trackResult?.value}'); + + await Future.wait([ + notificationsWorker.shutdown(), + analyticsWorker.shutdown(), + workflowApp.close(), + client.close(), + ]); +} diff --git a/packages/stem/lib/src/bootstrap/stem_app.dart b/packages/stem/lib/src/bootstrap/stem_app.dart index ded9fb38..cdec188c 100644 --- a/packages/stem/lib/src/bootstrap/stem_app.dart +++ b/packages/stem/lib/src/bootstrap/stem_app.dart @@ -31,6 +31,7 @@ class StemApp implements StemTaskApp { required this.backend, required this.stem, required this.worker, + required this.allowWorkerAutoStart, required List Function()> disposers, }) : _disposers = disposers { canvas = _ManagedCanvas( @@ -38,7 +39,7 @@ class StemApp implements StemTaskApp { backend: backend, registry: registry, encoderRegistry: stem.payloadEncoders, - onBeforeDispatch: _ensureStarted, + onBeforeDispatch: _maybeAutoStart, ); } @@ -60,6 +61,9 @@ class StemApp implements StemTaskApp { /// Worker managed by the helper. final Worker worker; + /// Whether shortcut operations may lazily start the managed worker. + final bool allowWorkerAutoStart; + /// Canvas facade used for chains, groups, and chords. late final Canvas canvas; @@ -68,7 +72,15 @@ class StemApp implements StemTaskApp { bool _started = false; Future? _startFuture; - Future _ensureStarted() => _started ? Future.value() : start(); + /// Whether the managed worker has been started. + bool get isStarted => _started; + + Future _maybeAutoStart() { + if (_started || !allowWorkerAutoStart) { + return Future.value(); + } + return start(); + } /// Registers an additional task handler with the underlying registry. void register(TaskHandler handler) => registry.register(handler); @@ -105,7 +117,7 @@ class StemApp implements StemTaskApp { Map meta = const {}, TaskEnqueueOptions? enqueueOptions, }) async { - await _ensureStarted(); + await _maybeAutoStart(); return stem.enqueue( name, args: args, @@ -128,7 +140,7 @@ class StemApp implements StemTaskApp { Map meta = const {}, TaskEnqueueOptions? enqueueOptions, }) async { - await _ensureStarted(); + await _maybeAutoStart(); return stem.enqueueValue( name, value, @@ -146,19 +158,19 @@ class StemApp implements StemTaskApp { TaskCall call, { TaskEnqueueOptions? enqueueOptions, }) async { - await _ensureStarted(); + await _maybeAutoStart(); return stem.enqueueCall(call, enqueueOptions: enqueueOptions); } @override Future getTaskStatus(String taskId) async { - await _ensureStarted(); + await _maybeAutoStart(); return stem.getTaskStatus(taskId); } @override Future getGroupStatus(String groupId) async { - await _ensureStarted(); + await _maybeAutoStart(); return stem.getGroupStatus(groupId); } @@ -171,7 +183,7 @@ class StemApp implements StemTaskApp { TResult Function(Map payload, int version)? decodeVersionedJson, }) async { - await _ensureStarted(); + await _maybeAutoStart(); return stem.waitForTask( taskId, timeout: timeout, @@ -245,6 +257,7 @@ class StemApp implements StemTaskApp { TaskPayloadEncoder resultEncoder = const JsonTaskPayloadEncoder(), TaskPayloadEncoder argsEncoder = const JsonTaskPayloadEncoder(), Iterable additionalEncoders = const [], + bool allowWorkerAutoStart = true, }) async { final effectiveModule = StemModule.combine( module: module, @@ -351,6 +364,7 @@ class StemApp implements StemTaskApp { backend: encodedBackend, stem: stem, worker: worker, + allowWorkerAutoStart: allowWorkerAutoStart, disposers: disposers, ); } @@ -365,6 +379,7 @@ class StemApp implements StemTaskApp { TaskPayloadEncoder resultEncoder = const JsonTaskPayloadEncoder(), TaskPayloadEncoder argsEncoder = const JsonTaskPayloadEncoder(), Iterable additionalEncoders = const [], + bool allowWorkerAutoStart = true, }) { return StemApp.create( module: module, @@ -377,6 +392,7 @@ class StemApp implements StemTaskApp { resultEncoder: resultEncoder, argsEncoder: argsEncoder, additionalEncoders: additionalEncoders, + allowWorkerAutoStart: allowWorkerAutoStart, ); } @@ -408,6 +424,7 @@ class StemApp implements StemTaskApp { TaskPayloadEncoder argsEncoder = const JsonTaskPayloadEncoder(), Iterable additionalEncoders = const [], StemStack? stack, + bool allowWorkerAutoStart = true, }) async { final needsUniqueLockStore = uniqueTasks && @@ -478,6 +495,7 @@ class StemApp implements StemTaskApp { resultEncoder: resultEncoder, argsEncoder: argsEncoder, additionalEncoders: additionalEncoders, + allowWorkerAutoStart: allowWorkerAutoStart, ); // Dispose auto-provisioned lock/revoke stores after worker shutdown and @@ -506,6 +524,7 @@ class StemApp implements StemTaskApp { Iterable modules = const [], Iterable> tasks = const [], StemWorkerConfig workerConfig = const StemWorkerConfig(), + bool allowWorkerAutoStart = true, }) async { final effectiveModule = StemModule.combine(module: module, modules: modules) ?? client.module; @@ -562,6 +581,7 @@ class StemApp implements StemTaskApp { backend: client.backend, stem: client.stem, worker: worker, + allowWorkerAutoStart: allowWorkerAutoStart, disposers: [ () async { await worker.shutdown(); diff --git a/packages/stem/lib/src/bootstrap/stem_client.dart b/packages/stem/lib/src/bootstrap/stem_client.dart index cb08d883..c94e0e83 100644 --- a/packages/stem/lib/src/bootstrap/stem_client.dart +++ b/packages/stem/lib/src/bootstrap/stem_client.dart @@ -380,6 +380,7 @@ abstract class StemClient implements TaskResultCaller { Duration pollInterval = const Duration(milliseconds: 500), Duration leaseExtension = const Duration(seconds: 30), WorkflowIntrospectionSink? introspectionSink, + bool allowWorkerAutoStart = true, }) { final effectiveModule = StemModule.combine(module: module, modules: modules) ?? this.module; @@ -397,6 +398,7 @@ abstract class StemClient implements TaskResultCaller { pollInterval: pollInterval, leaseExtension: leaseExtension, introspectionSink: introspectionSink, + allowWorkerAutoStart: allowWorkerAutoStart, ); } @@ -406,6 +408,7 @@ abstract class StemClient implements TaskResultCaller { Iterable modules = const [], Iterable> tasks = const [], StemWorkerConfig? workerConfig, + bool allowWorkerAutoStart = true, }) { final effectiveModule = StemModule.combine(module: module, modules: modules) ?? this.module; @@ -414,6 +417,7 @@ abstract class StemClient implements TaskResultCaller { module: effectiveModule, tasks: tasks, workerConfig: workerConfig ?? defaultWorkerConfig, + allowWorkerAutoStart: allowWorkerAutoStart, ); } diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index 18f2a391..f89d6593 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:stem/src/bootstrap/factories.dart'; import 'package:stem/src/bootstrap/stem_app.dart'; import 'package:stem/src/bootstrap/stem_client.dart'; @@ -47,6 +49,7 @@ class StemWorkflowApp required this.runtime, required this.store, required this.eventBus, + required this.allowWorkerAutoStart, required Future Function() disposeStore, required Future Function() disposeBus, }) : _disposeStore = disposeStore, @@ -64,10 +67,30 @@ class StemWorkflowApp /// Event bus used to deliver workflow events. final EventBus eventBus; + /// Whether shortcut operations may lazily start the managed worker. + final bool allowWorkerAutoStart; + final Future Function() _disposeStore; final Future Function() _disposeBus; - bool _started = false; + bool _runtimeStarted = false; + Future? _runtimeStartFuture; + + /// Whether both the runtime and managed worker have been started. + bool get isStarted => isRuntimeStarted && isWorkerStarted; + + /// Whether the workflow runtime has been started. + bool get isRuntimeStarted => _runtimeStarted; + + /// Whether the managed worker has been started. + bool get isWorkerStarted => app.isStarted; + + Future _ensureReadyForWorkflowStart() async { + await startRuntime(); + if (allowWorkerAutoStart) { + await startWorker(); + } + } /// Starts the workflow runtime and the underlying Stem worker. /// @@ -80,10 +103,36 @@ class StemWorkflowApp /// await app.start(); /// ``` Future start() async { - if (_started) return; - _started = true; - await runtime.start(); - await app.start(); + await startRuntime(); + await startWorker(); + } + + /// Starts the workflow runtime without starting the managed worker. + Future startRuntime() async { + if (_runtimeStarted) return; + final existing = _runtimeStartFuture; + if (existing != null) { + await existing; + return; + } + + final completer = Completer(); + _runtimeStartFuture = completer.future; + try { + await runtime.start(); + _runtimeStarted = true; + completer.complete(); + } catch (error, stackTrace) { + _runtimeStartFuture = null; + _runtimeStarted = false; + completer.completeError(error, stackTrace); + rethrow; + } + } + + /// Starts the managed worker used for workflow execution. + Future startWorker() { + return app.start(); } @override @@ -169,7 +218,8 @@ class StemWorkflowApp /// Schedules a workflow run. /// /// Lazily starts the runtime on the first invocation so simple examples do - /// not need to call [start] manually. + /// not need to call [start] manually. The managed worker is only auto-started + /// when [allowWorkerAutoStart] is `true`. /// /// Example: /// ```dart @@ -188,18 +238,8 @@ class StemWorkflowApp /// Optional policy that enforces automatic run cancellation. WorkflowCancellationPolicy? cancellationPolicy, - }) { - if (!_started) { - return start().then( - (_) => runtime.startWorkflow( - name, - params: params, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ), - ); - } + }) async { + await _ensureReadyForWorkflowStart(); return runtime.startWorkflow( name, params: params, @@ -217,19 +257,8 @@ class StemWorkflowApp Duration? ttl, WorkflowCancellationPolicy? cancellationPolicy, String? typeName, - }) { - if (!_started) { - return start().then( - (_) => runtime.startWorkflowJson( - name, - paramsJson, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - typeName: typeName, - ), - ); - } + }) async { + await _ensureReadyForWorkflowStart(); return runtime.startWorkflowJson( name, paramsJson, @@ -251,19 +280,8 @@ class StemWorkflowApp String? parentRunId, Duration? ttl, WorkflowCancellationPolicy? cancellationPolicy, - }) { - if (!_started) { - return start().then( - (_) => runtime.startWorkflowValue( - name, - value, - codec: codec, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ), - ); - } + }) async { + await _ensureReadyForWorkflowStart(); return runtime.startWorkflowValue( name, value, @@ -284,20 +302,8 @@ class StemWorkflowApp Duration? ttl, WorkflowCancellationPolicy? cancellationPolicy, String? typeName, - }) { - if (!_started) { - return start().then( - (_) => runtime.startWorkflowVersionedJson( - name, - paramsJson, - version: version, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - typeName: typeName, - ), - ); - } + }) async { + await _ensureReadyForWorkflowStart(); return runtime.startWorkflowVersionedJson( name, paramsJson, @@ -317,18 +323,8 @@ class StemWorkflowApp String? parentRunId, Duration? ttl, WorkflowCancellationPolicy? cancellationPolicy, - }) { - if (!_started) { - return start().then( - (_) => runtime.startWorkflowRef( - definition, - params, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ), - ); - } + }) async { + await _ensureReadyForWorkflowStart(); return runtime.startWorkflowRef( definition, params, @@ -671,7 +667,8 @@ class StemWorkflowApp await app.shutdown(); await _disposeBus(); await _disposeStore(); - _started = false; + _runtimeStarted = false; + _runtimeStartFuture = null; } /// Alias for [shutdown]. @@ -716,6 +713,7 @@ class StemWorkflowApp TaskPayloadEncoder resultEncoder = const JsonTaskPayloadEncoder(), TaskPayloadEncoder argsEncoder = const JsonTaskPayloadEncoder(), Iterable additionalEncoders = const [], + bool allowWorkerAutoStart = true, }) async { final effectiveModule = StemModule.combine(module: module, modules: modules) ?? stemApp?.module; @@ -740,11 +738,13 @@ class StemWorkflowApp resultEncoder: resultEncoder, argsEncoder: argsEncoder, additionalEncoders: additionalEncoders, + allowWorkerAutoStart: allowWorkerAutoStart, ); if (stemApp != null) { _validateReusableStemApp( appInstance, resolvedWorkerConfig, + allowWorkerAutoStart: allowWorkerAutoStart, ); } @@ -787,6 +787,7 @@ class StemWorkflowApp runtime: runtime, store: store, eventBus: eventBus, + allowWorkerAutoStart: allowWorkerAutoStart, disposeStore: () async => storeFactoryInstance.dispose(store), disposeBus: () async => busFactory.dispose(eventBus), ); @@ -824,6 +825,7 @@ class StemWorkflowApp TaskPayloadEncoder resultEncoder = const JsonTaskPayloadEncoder(), TaskPayloadEncoder argsEncoder = const JsonTaskPayloadEncoder(), Iterable additionalEncoders = const [], + bool allowWorkerAutoStart = true, }) { return StemWorkflowApp.create( module: module, @@ -847,6 +849,7 @@ class StemWorkflowApp resultEncoder: resultEncoder, argsEncoder: argsEncoder, additionalEncoders: additionalEncoders, + allowWorkerAutoStart: allowWorkerAutoStart, ); } @@ -885,6 +888,7 @@ class StemWorkflowApp TaskPayloadEncoder resultEncoder = const JsonTaskPayloadEncoder(), TaskPayloadEncoder argsEncoder = const JsonTaskPayloadEncoder(), Iterable additionalEncoders = const [], + bool allowWorkerAutoStart = true, }) async { final resolvedWorkerConfig = _resolveWorkflowWorkerConfig( workerConfig, @@ -916,6 +920,7 @@ class StemWorkflowApp resultEncoder: resultEncoder, argsEncoder: argsEncoder, additionalEncoders: additionalEncoders, + allowWorkerAutoStart: allowWorkerAutoStart, ); try { @@ -936,6 +941,7 @@ class StemWorkflowApp leaseExtension: leaseExtension, workflowRegistry: workflowRegistry, introspectionSink: introspectionSink, + allowWorkerAutoStart: allowWorkerAutoStart, ); } on Object catch (error, stackTrace) { // fromUrl owns the app instance; clean it up when workflow bootstrap @@ -971,6 +977,7 @@ class StemWorkflowApp Duration pollInterval = const Duration(milliseconds: 500), Duration leaseExtension = const Duration(seconds: 30), WorkflowIntrospectionSink? introspectionSink, + bool allowWorkerAutoStart = true, }) async { final resolvedWorkerConfig = _resolveWorkflowWorkerConfig( workerConfig, @@ -982,6 +989,7 @@ class StemWorkflowApp final appInstance = await StemApp.fromClient( client, workerConfig: resolvedWorkerConfig, + allowWorkerAutoStart: allowWorkerAutoStart, ); return StemWorkflowApp.create( module: module, @@ -999,6 +1007,7 @@ class StemWorkflowApp leaseExtension: leaseExtension, workflowRegistry: client.workflowRegistry, introspectionSink: introspectionSink, + allowWorkerAutoStart: allowWorkerAutoStart, ); } } @@ -1026,6 +1035,7 @@ extension StemAppWorkflowExtension on StemApp { Duration leaseExtension = const Duration(seconds: 30), WorkflowRegistry? workflowRegistry, WorkflowIntrospectionSink? introspectionSink, + bool allowWorkerAutoStart = true, }) { return StemWorkflowApp.create( module: @@ -1044,6 +1054,7 @@ extension StemAppWorkflowExtension on StemApp { leaseExtension: leaseExtension, workflowRegistry: workflowRegistry, introspectionSink: introspectionSink, + allowWorkerAutoStart: allowWorkerAutoStart, ); } } @@ -1051,7 +1062,19 @@ extension StemAppWorkflowExtension on StemApp { void _validateReusableStemApp( StemApp app, StemWorkerConfig workerConfig, + { + required bool allowWorkerAutoStart, + } ) { + if (app.allowWorkerAutoStart != allowWorkerAutoStart) { + throw StateError( + 'StemWorkflowApp.create(stemApp: ...) requires the reused StemApp ' + 'to use the same allowWorkerAutoStart setting. Create the StemApp with ' + 'allowWorkerAutoStart: $allowWorkerAutoStart or omit stemApp so the ' + 'workflow app can create a matching shortcut wrapper.', + ); + } + final requiredQueues = workerConfig.subscription?.resolveQueues( workerConfig.queue, diff --git a/packages/stem/test/bootstrap/shortcut_allow_auto_start_test.dart b/packages/stem/test/bootstrap/shortcut_allow_auto_start_test.dart new file mode 100644 index 00000000..ce95793f --- /dev/null +++ b/packages/stem/test/bootstrap/shortcut_allow_auto_start_test.dart @@ -0,0 +1,86 @@ +import 'package:stem/stem.dart'; +import 'package:test/test.dart'; + +void main() { + group('shortcut allowWorkerAutoStart', () { + test('StemApp can enqueue without starting the worker', () async { + final app = await StemApp.inMemory( + allowWorkerAutoStart: false, + tasks: [ + FunctionTaskHandler( + name: 'shortcut.echo', + entrypoint: (context, args) async => 'done', + ), + ], + ); + + try { + final taskId = await app.enqueue('shortcut.echo'); + expect(app.isStarted, isFalse); + + final pending = await app.waitForTask( + taskId, + timeout: const Duration(milliseconds: 10), + ); + expect(pending, isNotNull); + expect(pending!.timedOut, isTrue); + expect(pending.status.state, TaskState.queued); + + await app.start(); + expect(app.isStarted, isTrue); + + final completed = await app.waitForTask( + taskId, + timeout: const Duration(seconds: 1), + ); + expect(completed?.isSucceeded, isTrue); + expect(completed?.value, 'done'); + } finally { + await app.shutdown(); + } + }); + + test('StemWorkflowApp can create runs without starting the worker', () async { + final flow = Flow( + name: 'shortcut.workflow', + build: (builder) { + builder.step('done', (context) async => 'workflow-done'); + }, + ); + + final app = await StemWorkflowApp.inMemory( + flows: [flow], + allowWorkerAutoStart: false, + ); + + try { + final runId = await flow.start(app); + expect(app.isRuntimeStarted, isTrue); + expect(app.isWorkerStarted, isFalse); + + final pending = await app.waitForCompletion( + runId, + timeout: const Duration(milliseconds: 10), + ); + expect(pending, isNotNull); + expect(pending!.timedOut, isTrue); + expect(pending.status, WorkflowStatus.running); + + await app.startWorker(); + expect(app.isRuntimeStarted, isTrue); + expect(app.isWorkerStarted, isTrue); + expect(app.isStarted, isTrue); + + final completed = await flow.waitFor( + app, + runId, + timeout: const Duration(seconds: 1), + ); + expect(completed?.isCompleted, isTrue); + expect(completed?.value, 'workflow-done'); + } finally { + await app.shutdown(); + } + }); + }); +} From 87ffab48223efa3ee407d3b88a276b1e8fdbdfbb Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Sat, 28 Mar 2026 01:35:30 -0500 Subject: [PATCH 301/302] fix(bootstrap): preserve workflow app ownership --- .../stem/lib/src/bootstrap/workflow_app.dart | 19 ++++-- .../workflow_module_bootstrap_test.dart | 59 +++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index f89d6593..abbe7344 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -50,6 +50,7 @@ class StemWorkflowApp required this.store, required this.eventBus, required this.allowWorkerAutoStart, + required this.ownsStemApp, required Future Function() disposeStore, required Future Function() disposeBus, }) : _disposeStore = disposeStore, @@ -70,6 +71,9 @@ class StemWorkflowApp /// Whether shortcut operations may lazily start the managed worker. final bool allowWorkerAutoStart; + /// Whether this wrapper owns the provided [app] and may shut it down. + final bool ownsStemApp; + final Future Function() _disposeStore; final Future Function() _disposeBus; @@ -664,7 +668,9 @@ class StemWorkflowApp /// ``` Future shutdown() async { await runtime.dispose(); - await app.shutdown(); + if (ownsStemApp) { + await app.shutdown(); + } await _disposeBus(); await _disposeStore(); _runtimeStarted = false; @@ -714,6 +720,7 @@ class StemWorkflowApp TaskPayloadEncoder argsEncoder = const JsonTaskPayloadEncoder(), Iterable additionalEncoders = const [], bool allowWorkerAutoStart = true, + bool ownsStemApp = false, }) async { final effectiveModule = StemModule.combine(module: module, modules: modules) ?? stemApp?.module; @@ -788,6 +795,7 @@ class StemWorkflowApp store: store, eventBus: eventBus, allowWorkerAutoStart: allowWorkerAutoStart, + ownsStemApp: stemApp == null || ownsStemApp, disposeStore: () async => storeFactoryInstance.dispose(store), disposeBus: () async => busFactory.dispose(eventBus), ); @@ -942,6 +950,7 @@ class StemWorkflowApp workflowRegistry: workflowRegistry, introspectionSink: introspectionSink, allowWorkerAutoStart: allowWorkerAutoStart, + ownsStemApp: true, ); } on Object catch (error, stackTrace) { // fromUrl owns the app instance; clean it up when workflow bootstrap @@ -979,9 +988,11 @@ class StemWorkflowApp WorkflowIntrospectionSink? introspectionSink, bool allowWorkerAutoStart = true, }) async { + final effectiveModule = + StemModule.combine(module: module, modules: modules) ?? client.module; final resolvedWorkerConfig = _resolveWorkflowWorkerConfig( workerConfig, - module: StemModule.combine(module: module, modules: modules), + module: effectiveModule, tasks: tasks, continuationQueue: continuationQueue, executionQueue: executionQueue, @@ -992,8 +1003,7 @@ class StemWorkflowApp allowWorkerAutoStart: allowWorkerAutoStart, ); return StemWorkflowApp.create( - module: module, - modules: modules, + module: effectiveModule, workflows: workflows, flows: flows, scripts: scripts, @@ -1008,6 +1018,7 @@ class StemWorkflowApp workflowRegistry: client.workflowRegistry, introspectionSink: introspectionSink, allowWorkerAutoStart: allowWorkerAutoStart, + ownsStemApp: true, ); } } diff --git a/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart b/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart index 40543fcf..f861e6bf 100644 --- a/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart +++ b/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart @@ -131,6 +131,33 @@ void main() { }, ); + test( + 'StemWorkflowApp.fromClient falls back to client module for ' + 'subscription inference', + () async { + final queuedTask = FunctionTaskHandler( + name: 'workflow.module.from-client-task', + options: const TaskOptions(queue: 'priority'), + entrypoint: (context, args) async => 'queued-ok', + runInIsolate: false, + ); + final client = await StemClient.inMemory( + module: StemModule(tasks: [queuedTask]), + ); + + final workflowApp = await StemWorkflowApp.fromClient(client: client); + try { + expect( + workflowApp.app.worker.subscription.queues, + unorderedEquals(['workflow', 'priority']), + ); + } finally { + await workflowApp.shutdown(); + await client.close(); + } + }, + ); + test( 'explicit workflow subscription overrides inferred module queues', () async { @@ -240,5 +267,37 @@ void main() { await workflowApp.shutdown(); } }); + + test('shutdown preserves a borrowed StemApp', () async { + final hostTask = FunctionTaskHandler( + name: 'workflow.module.host-task', + entrypoint: (context, args) async => 'host-ok', + runInIsolate: false, + ); + final flow = Flow( + name: 'workflow.module.borrowed-app', + build: (builder) { + builder.step('hello', (ctx) async => 'flow-ok'); + }, + ); + final hostApp = await StemApp.inMemory(tasks: [hostTask]); + final hostTaskDef = TaskDefinition.noArgs(name: hostTask.name); + + await hostApp.start(); + final workflowApp = await hostApp.createWorkflowApp(flows: [flow]); + try { + await workflowApp.shutdown(); + + expect(hostApp.isStarted, isTrue); + + final result = await hostTaskDef.enqueueAndWait( + hostApp, + timeout: const Duration(seconds: 2), + ); + expect(result?.value, 'host-ok'); + } finally { + await hostApp.shutdown(); + } + }); }); } From b4d588bf7ac48778e5a516aec27dddd0bd7dabd9 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Sat, 28 Mar 2026 02:40:06 -0500 Subject: [PATCH 302/302] fix(tests): unblock workflow and postgres CI --- .../test/bootstrap/workflow_module_bootstrap_test.dart | 9 ++++++++- .../test/support/postgres_test_harness.dart | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart b/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart index f861e6bf..e4ffdc2d 100644 --- a/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart +++ b/packages/stem/test/bootstrap/workflow_module_bootstrap_test.dart @@ -280,7 +280,14 @@ void main() { builder.step('hello', (ctx) async => 'flow-ok'); }, ); - final hostApp = await StemApp.inMemory(tasks: [hostTask]); + final hostApp = await StemApp.inMemory( + tasks: [hostTask], + workerConfig: StemWorkerConfig( + subscription: RoutingSubscription( + queues: ['default', 'workflow'], + ), + ), + ); final hostTaskDef = TaskDefinition.noArgs(name: hostTask.name); await hostApp.start(); diff --git a/packages/stem_postgres/test/support/postgres_test_harness.dart b/packages/stem_postgres/test/support/postgres_test_harness.dart index a2b4af25..0d485dba 100644 --- a/packages/stem_postgres/test/support/postgres_test_harness.dart +++ b/packages/stem_postgres/test/support/postgres_test_harness.dart @@ -45,6 +45,7 @@ Future createStemPostgresTestHarness({ final config = setUpOrmed( dataSource: dataSource, runMigrations: _runTestMigrations, + migrateBaseDatabase: false, adapterFactory: (dbName) { final schemaUrl = _withSearchPath(connectionString, dbName); return PostgresDriverAdapter.custom(