From 8565327029589a8444307857195117fccbafe87d Mon Sep 17 00:00:00 2001 From: Sean Kane Date: Tue, 16 Jun 2026 16:51:09 -0600 Subject: [PATCH 1/9] feat: add signal-with-start-from-workflow features tests --- .../both_workflows_visible/feature.py | 90 +++++++++++ features/signalwithstart/happy_path/README.md | 19 +++ .../signalwithstart/happy_path/feature.py | 86 +++++++++++ .../id_conflict_fail/feature.py | 105 +++++++++++++ .../id_conflict_terminate_existing/feature.py | 105 +++++++++++++ .../id_conflict_use_existing/feature.py | 87 +++++++++++ .../id_reuse_allow_duplicate/feature.py | 89 +++++++++++ .../feature.py | 143 ++++++++++++++++++ .../id_reuse_reject_duplicate/feature.py | 106 +++++++++++++ .../signal_existing_workflow/feature.py | 89 +++++++++++ .../signal_terminated_workflow/feature.py | 95 ++++++++++++ .../signalwithstart/start_delay/feature.py | 82 ++++++++++ pyproject.toml | 3 + uv.lock | 14 +- 14 files changed, 1102 insertions(+), 11 deletions(-) create mode 100644 features/signalwithstart/both_workflows_visible/feature.py create mode 100644 features/signalwithstart/happy_path/README.md create mode 100644 features/signalwithstart/happy_path/feature.py create mode 100644 features/signalwithstart/id_conflict_fail/feature.py create mode 100644 features/signalwithstart/id_conflict_terminate_existing/feature.py create mode 100644 features/signalwithstart/id_conflict_use_existing/feature.py create mode 100644 features/signalwithstart/id_reuse_allow_duplicate/feature.py create mode 100644 features/signalwithstart/id_reuse_allow_duplicate_failed_only/feature.py create mode 100644 features/signalwithstart/id_reuse_reject_duplicate/feature.py create mode 100644 features/signalwithstart/signal_existing_workflow/feature.py create mode 100644 features/signalwithstart/signal_terminated_workflow/feature.py create mode 100644 features/signalwithstart/start_delay/feature.py diff --git a/features/signalwithstart/both_workflows_visible/feature.py b/features/signalwithstart/both_workflows_visible/feature.py new file mode 100644 index 00000000..27e8b951 --- /dev/null +++ b/features/signalwithstart/both_workflows_visible/feature.py @@ -0,0 +1,90 @@ +import uuid +from dataclasses import dataclass +from datetime import timedelta +from typing import Optional + +from temporalio import workflow +from temporalio.client import WorkflowHandle + +from harness.python.feature import Runner, register_feature + +WORKFLOW_INPUT = "workflow-input" +SIGNAL_VALUE = "signal-input" +MEMO_KEY = "memo-key" +MEMO_VALUE = "memo-value" + + +@dataclass +class SwsResult: + workflow_id: str + run_id: str + + +@workflow.defn +class TargetWorkflow: + def __init__(self) -> None: + self._signal_value: Optional[str] = None + + @workflow.run + async def run(self, _input: str) -> str: + await workflow.wait_condition(lambda: self._signal_value is not None) + assert self._signal_value is not None + return self._signal_value + + @workflow.signal + def my_signal(self, value: str) -> None: + self._signal_value = value + + +@workflow.defn +class CallerWorkflow: + @workflow.run + async def run(self, target_id: str) -> SwsResult: + handle = await workflow.signal_with_start_workflow( + TargetWorkflow.run, + WORKFLOW_INPUT, + id=target_id, + task_queue=workflow.info().task_queue, + signal=TargetWorkflow.my_signal, + signal_args=SIGNAL_VALUE, + memo={MEMO_KEY: MEMO_VALUE}, + ) + return SwsResult(workflow_id=handle.id, run_id=handle.run_id) + + +async def start(runner: Runner) -> WorkflowHandle: + target_id = f"signalwithstart-both-visible-target-{uuid.uuid4()}" + return await runner.client.start_workflow( + CallerWorkflow.run, + target_id, + id=f"signalwithstart-both-visible-caller-{uuid.uuid4()}", + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + ) + + +async def check_result(runner: Runner, handle: WorkflowHandle) -> None: + # Caller completes and returns the started target's identity. + result: SwsResult = await handle.result() + assert result.run_id, "expected a non-empty target run id" + + # Target completes after receiving the signal and returns the signal value. + target = runner.client.get_workflow_handle( + result.workflow_id, run_id=result.run_id + ) + target_result = await target.result() + assert ( + target_result == SIGNAL_VALUE + ), f"expected target to return {SIGNAL_VALUE!r}, got {target_result!r}" + + # The memo passed in the signal-with-start request is visible on the target. + desc = await target.describe() + memo = await desc.memo() + assert memo.get(MEMO_KEY) == MEMO_VALUE, f"expected memo {MEMO_KEY}={MEMO_VALUE}, got {memo}" + + +register_feature( + workflows=[CallerWorkflow, TargetWorkflow], + start=start, + check_result=check_result, +) diff --git a/features/signalwithstart/happy_path/README.md b/features/signalwithstart/happy_path/README.md new file mode 100644 index 00000000..932d92e3 --- /dev/null +++ b/features/signalwithstart/happy_path/README.md @@ -0,0 +1,19 @@ +# Signal-with-start from a workflow: happy path + +A workflow calls `workflow.signal_with_start_workflow(...)` (routed through the +`__temporal_system` Nexus endpoint) to start a brand-new target workflow and +deliver a signal to it in one operation. The caller returns the started target's +run id; the target completes after receiving the signal and returns the signal +value. + +Verifies: the operation starts a new execution (non-empty run id) and the signal +is delivered (target returns the signal value). + +## Server requirements + +This feature requires a server with namespace dynamic config: + +- `history.enableSignalWithStartFromWorkflow=true` +- `history.enableChasm=true` (default true) +- Nexus enabled (`system.enableNexus=true`) with the built-in `__temporal_system` + endpoint available. diff --git a/features/signalwithstart/happy_path/feature.py b/features/signalwithstart/happy_path/feature.py new file mode 100644 index 00000000..b462273d --- /dev/null +++ b/features/signalwithstart/happy_path/feature.py @@ -0,0 +1,86 @@ +import uuid +from dataclasses import dataclass +from datetime import timedelta +from typing import Optional + +from temporalio import workflow +from temporalio.client import WorkflowHandle + +from harness.python.feature import Runner, register_feature + +SIGNAL_VALUE = "test-signal-value" + + +@dataclass +class SwsResult: + """What the caller workflow returns: the identity of the workflow that the + signal-with-start operation started (or signaled).""" + + workflow_id: str + run_id: str + + +@workflow.defn +class TargetWorkflow: + def __init__(self) -> None: + self._signal_value: Optional[str] = None + + @workflow.run + async def run(self) -> str: + await workflow.wait_condition(lambda: self._signal_value is not None) + assert self._signal_value is not None + return self._signal_value + + @workflow.signal + def my_signal(self, value: str) -> None: + self._signal_value = value + + +@workflow.defn +class CallerWorkflow: + @workflow.run + async def run(self, target_id: str) -> SwsResult: + # Signal-with-start the target from inside this workflow. With no + # existing workflow for target_id this starts a brand-new execution and + # delivers the signal to it. + handle = await workflow.signal_with_start_workflow( + TargetWorkflow.run, + id=target_id, + task_queue=workflow.info().task_queue, + signal=TargetWorkflow.my_signal, + signal_args=SIGNAL_VALUE, + ) + return SwsResult(workflow_id=handle.id, run_id=handle.run_id) + + +async def start(runner: Runner) -> WorkflowHandle: + target_id = f"signalwithstart-happy-path-target-{uuid.uuid4()}" + return await runner.client.start_workflow( + CallerWorkflow.run, + target_id, + id=f"signalwithstart-happy-path-caller-{uuid.uuid4()}", + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + ) + + +async def check_result(runner: Runner, handle: WorkflowHandle) -> None: + result: SwsResult = await handle.result() + assert result.run_id, "expected a non-empty run id for the started target workflow" + + # The signal-with-start created a new target execution; it completes after + # receiving the signal and returns the signal value. + target = runner.client.get_workflow_handle( + result.workflow_id, run_id=result.run_id + ) + target_result = await target.result() + assert ( + target_result == SIGNAL_VALUE + ), f"expected target to return {SIGNAL_VALUE!r}, got {target_result!r}" + + +register_feature( + workflows=[CallerWorkflow, TargetWorkflow], + start=start, + check_result=check_result, +) diff --git a/features/signalwithstart/id_conflict_fail/feature.py b/features/signalwithstart/id_conflict_fail/feature.py new file mode 100644 index 00000000..d3d2af95 --- /dev/null +++ b/features/signalwithstart/id_conflict_fail/feature.py @@ -0,0 +1,105 @@ +import uuid +from datetime import timedelta +from typing import Optional + +from temporalio import workflow +from temporalio.api.enums.v1 import EventType +from temporalio.client import WorkflowHandle +from temporalio.common import WorkflowIDConflictPolicy + +from harness.python.feature import Runner, register_feature + +SIGNAL_VALUE = "test-signal-value" + +_setup: dict = {} + + +@workflow.defn +class TargetWorkflow: + def __init__(self) -> None: + self._signal_value: Optional[str] = None + + @workflow.run + async def run(self) -> str: + await workflow.wait_condition(lambda: self._signal_value is not None) + assert self._signal_value is not None + return self._signal_value + + @workflow.signal + def my_signal(self, value: str) -> None: + self._signal_value = value + + +@workflow.defn +class CallerWorkflow: + @workflow.run + async def run(self, target_id: str) -> str: + # WORKFLOW_ID_CONFLICT_POLICY_FAIL is rejected by the server when + # scheduling the Nexus operation (signal-with-required-start is not a + # supported operation). This surfaces as a workflow task failure rather + # than a catchable error, so this run never completes; the client asserts + # on the workflow task failure in history. + handle = await workflow.signal_with_start_workflow( + TargetWorkflow.run, + id=target_id, + task_queue=workflow.info().task_queue, + signal=TargetWorkflow.my_signal, + signal_args=SIGNAL_VALUE, + id_conflict_policy=WorkflowIDConflictPolicy.FAIL, + ) + return handle.run_id + + +async def start(runner: Runner) -> WorkflowHandle: + target_id = f"signalwithstart-id-conflict-fail-target-{uuid.uuid4()}" + _setup["target_id"] = target_id + + # Start a running target to mirror the server test setup. + target = await runner.client.start_workflow( + TargetWorkflow.run, + id=target_id, + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + ) + _setup["target_run_id"] = target.result_run_id + + return await runner.client.start_workflow( + CallerWorkflow.run, + target_id, + id=f"signalwithstart-id-conflict-fail-caller-{uuid.uuid4()}", + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + ) + + +async def check_result(runner: Runner, handle: WorkflowHandle) -> None: + # The caller never completes; wait for the workflow task failure that rejects + # the unsupported conflict policy. + event = await runner.wait_for_event( + handle, + lambda e: e.event_type == EventType.EVENT_TYPE_WORKFLOW_TASK_FAILED, + timeout=30.0, + ) + message = event.workflow_task_failed_event_attributes.failure.message + assert ( + "not supported" in message.lower() + ), f"expected 'not supported' rejection, got: {message}" + + # Cleanup the caller (stuck failing its workflow task) and the target. + for wf_id, run_id in ( + (handle.id, None), + (_setup["target_id"], _setup["target_run_id"]), + ): + try: + await runner.client.get_workflow_handle(wf_id, run_id=run_id).terminate( + "cleanup" + ) + except Exception: + pass + + +register_feature( + workflows=[CallerWorkflow, TargetWorkflow], + start=start, + check_result=check_result, +) diff --git a/features/signalwithstart/id_conflict_terminate_existing/feature.py b/features/signalwithstart/id_conflict_terminate_existing/feature.py new file mode 100644 index 00000000..2c1d519c --- /dev/null +++ b/features/signalwithstart/id_conflict_terminate_existing/feature.py @@ -0,0 +1,105 @@ +import uuid +from dataclasses import dataclass +from datetime import timedelta +from typing import Optional + +from temporalio import workflow +from temporalio.client import WorkflowExecutionStatus, WorkflowHandle +from temporalio.common import WorkflowIDConflictPolicy + +from harness.python.feature import Runner, register_feature + +SIGNAL_VALUE = "test-signal-value" + +_setup: dict = {} + + +@dataclass +class SwsResult: + workflow_id: str + run_id: str + + +@workflow.defn +class TargetWorkflow: + def __init__(self) -> None: + self._signal_value: Optional[str] = None + + @workflow.run + async def run(self) -> str: + await workflow.wait_condition(lambda: self._signal_value is not None) + assert self._signal_value is not None + return self._signal_value + + @workflow.signal + def my_signal(self, value: str) -> None: + self._signal_value = value + + +@workflow.defn +class CallerWorkflow: + @workflow.run + async def run(self, target_id: str) -> SwsResult: + handle = await workflow.signal_with_start_workflow( + TargetWorkflow.run, + id=target_id, + task_queue=workflow.info().task_queue, + signal=TargetWorkflow.my_signal, + signal_args=SIGNAL_VALUE, + id_conflict_policy=WorkflowIDConflictPolicy.TERMINATE_EXISTING, + ) + return SwsResult(workflow_id=handle.id, run_id=handle.run_id) + + +async def start(runner: Runner) -> WorkflowHandle: + target_id = f"signalwithstart-id-conflict-terminate-target-{uuid.uuid4()}" + + # Start the target and leave it running. TERMINATE_EXISTING should terminate + # this run and start a new one. + target = await runner.client.start_workflow( + TargetWorkflow.run, + id=target_id, + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + ) + _setup["original_run_id"] = target.result_run_id + + return await runner.client.start_workflow( + CallerWorkflow.run, + target_id, + id=f"signalwithstart-id-conflict-terminate-caller-{uuid.uuid4()}", + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + ) + + +async def check_result(runner: Runner, handle: WorkflowHandle) -> None: + result: SwsResult = await handle.result() + assert result.run_id, "expected a non-empty run id" + assert ( + result.run_id != _setup["original_run_id"] + ), "expected a new run id with TERMINATE_EXISTING" + + # The original run must have been terminated. + original = runner.client.get_workflow_handle( + result.workflow_id, run_id=_setup["original_run_id"] + ) + desc = await original.describe() + assert ( + desc.status == WorkflowExecutionStatus.TERMINATED + ), f"expected original run TERMINATED, got {desc.status}" + + # Cleanup the freshly-started run. + try: + await runner.client.get_workflow_handle( + result.workflow_id, run_id=result.run_id + ).terminate("cleanup") + except Exception: + pass + + +register_feature( + workflows=[CallerWorkflow, TargetWorkflow], + start=start, + check_result=check_result, +) diff --git a/features/signalwithstart/id_conflict_use_existing/feature.py b/features/signalwithstart/id_conflict_use_existing/feature.py new file mode 100644 index 00000000..0495ff41 --- /dev/null +++ b/features/signalwithstart/id_conflict_use_existing/feature.py @@ -0,0 +1,87 @@ +import uuid +from dataclasses import dataclass +from datetime import timedelta +from typing import Optional + +from temporalio import workflow +from temporalio.client import WorkflowHandle +from temporalio.common import WorkflowIDConflictPolicy + +from harness.python.feature import Runner, register_feature + +SIGNAL_VALUE = "test-signal-value" + +_setup: dict = {} + + +@dataclass +class SwsResult: + workflow_id: str + run_id: str + + +@workflow.defn +class TargetWorkflow: + def __init__(self) -> None: + self._signal_value: Optional[str] = None + + @workflow.run + async def run(self) -> str: + await workflow.wait_condition(lambda: self._signal_value is not None) + assert self._signal_value is not None + return self._signal_value + + @workflow.signal + def my_signal(self, value: str) -> None: + self._signal_value = value + + +@workflow.defn +class CallerWorkflow: + @workflow.run + async def run(self, target_id: str) -> SwsResult: + handle = await workflow.signal_with_start_workflow( + TargetWorkflow.run, + id=target_id, + task_queue=workflow.info().task_queue, + signal=TargetWorkflow.my_signal, + signal_args=SIGNAL_VALUE, + id_conflict_policy=WorkflowIDConflictPolicy.USE_EXISTING, + ) + return SwsResult(workflow_id=handle.id, run_id=handle.run_id) + + +async def start(runner: Runner) -> WorkflowHandle: + target_id = f"signalwithstart-id-conflict-use-existing-target-{uuid.uuid4()}" + + # Start the target and leave it running. USE_EXISTING should signal this run + # and return its run id without starting a new one. + target = await runner.client.start_workflow( + TargetWorkflow.run, + id=target_id, + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + ) + _setup["original_run_id"] = target.result_run_id + + return await runner.client.start_workflow( + CallerWorkflow.run, + target_id, + id=f"signalwithstart-id-conflict-use-existing-caller-{uuid.uuid4()}", + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + ) + + +async def check_result(runner: Runner, handle: WorkflowHandle) -> None: + result: SwsResult = await handle.result() + assert ( + result.run_id == _setup["original_run_id"] + ), f"expected existing run id {_setup['original_run_id']}, got {result.run_id}" + + +register_feature( + workflows=[CallerWorkflow, TargetWorkflow], + start=start, + check_result=check_result, +) diff --git a/features/signalwithstart/id_reuse_allow_duplicate/feature.py b/features/signalwithstart/id_reuse_allow_duplicate/feature.py new file mode 100644 index 00000000..f5355e9b --- /dev/null +++ b/features/signalwithstart/id_reuse_allow_duplicate/feature.py @@ -0,0 +1,89 @@ +import uuid +from dataclasses import dataclass +from datetime import timedelta +from typing import Optional + +from temporalio import workflow +from temporalio.client import WorkflowHandle +from temporalio.common import WorkflowIDReusePolicy + +from harness.python.feature import Runner, register_feature + +SIGNAL_VALUE = "test-signal-value" + +_setup: dict = {} + + +@dataclass +class SwsResult: + workflow_id: str + run_id: str + + +@workflow.defn +class TargetWorkflow: + def __init__(self) -> None: + self._signal_value: Optional[str] = None + + @workflow.run + async def run(self) -> str: + await workflow.wait_condition(lambda: self._signal_value is not None) + assert self._signal_value is not None + return self._signal_value + + @workflow.signal + def my_signal(self, value: str) -> None: + self._signal_value = value + + +@workflow.defn +class CallerWorkflow: + @workflow.run + async def run(self, target_id: str) -> SwsResult: + handle = await workflow.signal_with_start_workflow( + TargetWorkflow.run, + id=target_id, + task_queue=workflow.info().task_queue, + signal=TargetWorkflow.my_signal, + signal_args=SIGNAL_VALUE, + id_reuse_policy=WorkflowIDReusePolicy.ALLOW_DUPLICATE, + ) + return SwsResult(workflow_id=handle.id, run_id=handle.run_id) + + +async def start(runner: Runner) -> WorkflowHandle: + target_id = f"signalwithstart-id-reuse-allow-dup-target-{uuid.uuid4()}" + + # Start and complete the target so a closed run exists for target_id. + target = await runner.client.start_workflow( + TargetWorkflow.run, + id=target_id, + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + ) + _setup["original_run_id"] = target.result_run_id + await target.signal(TargetWorkflow.my_signal, "setup") + await target.result() + + return await runner.client.start_workflow( + CallerWorkflow.run, + target_id, + id=f"signalwithstart-id-reuse-allow-dup-caller-{uuid.uuid4()}", + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + ) + + +async def check_result(runner: Runner, handle: WorkflowHandle) -> None: + result: SwsResult = await handle.result() + assert result.run_id, "expected a non-empty run id" + assert ( + result.run_id != _setup["original_run_id"] + ), "expected a new run id with ALLOW_DUPLICATE after the prior run completed" + + +register_feature( + workflows=[CallerWorkflow, TargetWorkflow], + start=start, + check_result=check_result, +) diff --git a/features/signalwithstart/id_reuse_allow_duplicate_failed_only/feature.py b/features/signalwithstart/id_reuse_allow_duplicate_failed_only/feature.py new file mode 100644 index 00000000..01e987f1 --- /dev/null +++ b/features/signalwithstart/id_reuse_allow_duplicate_failed_only/feature.py @@ -0,0 +1,143 @@ +import uuid +from dataclasses import dataclass +from datetime import timedelta +from typing import Optional + +from temporalio import workflow +from temporalio.client import WorkflowHandle +from temporalio.common import WorkflowIDReusePolicy + +from harness.python.feature import Runner, register_feature + +SIGNAL_VALUE = "test-signal-value" + + +@dataclass +class SwsResult: + workflow_id: str + run_id: str + error: Optional[str] = None + + +def _error_chain(err: BaseException) -> str: + """Flatten an exception and its causes; the useful detail lives on the cause + of the NexusOperationError, not its top-level message.""" + parts = [] + cur: Optional[BaseException] = err + for _ in range(8): + if cur is None: + break + parts.append(f"{type(cur).__name__}: {cur}") + cur = getattr(cur, "cause", None) or cur.__cause__ + return " | ".join(parts) + + +@workflow.defn +class TargetWorkflow: + def __init__(self) -> None: + self._signal_value: Optional[str] = None + + @workflow.run + async def run(self) -> str: + await workflow.wait_condition(lambda: self._signal_value is not None) + assert self._signal_value is not None + return self._signal_value + + @workflow.signal + def my_signal(self, value: str) -> None: + self._signal_value = value + + +@workflow.defn +class CallerWorkflow: + @workflow.run + async def run(self, target_id: str) -> SwsResult: + try: + handle = await workflow.signal_with_start_workflow( + TargetWorkflow.run, + id=target_id, + task_queue=workflow.info().task_queue, + signal=TargetWorkflow.my_signal, + signal_args=SIGNAL_VALUE, + id_reuse_policy=WorkflowIDReusePolicy.ALLOW_DUPLICATE_FAILED_ONLY, + ) + return SwsResult(workflow_id=handle.id, run_id=handle.run_id) + except Exception as err: + return SwsResult(workflow_id=target_id, run_id="", error=_error_chain(err)) + + +async def _run_caller(runner: Runner, target_id: str) -> SwsResult: + handle = await runner.client.start_workflow( + CallerWorkflow.run, + target_id, + id=f"signalwithstart-allow-dup-failed-only-caller-{uuid.uuid4()}", + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + ) + return await handle.result() + + +async def start(runner: Runner) -> WorkflowHandle: + # Sub-case 1: target completed successfully -> ALLOW_DUPLICATE_FAILED_ONLY + # should reject the operation. start() drives this and returns its caller + # handle; the remaining sub-case runs in check_result. + target_id = f"signalwithstart-allow-dup-failed-only-completed-{uuid.uuid4()}" + target = await runner.client.start_workflow( + TargetWorkflow.run, + id=target_id, + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + ) + await target.signal(TargetWorkflow.my_signal, "setup") + await target.result() + + return await runner.client.start_workflow( + CallerWorkflow.run, + target_id, + id=f"signalwithstart-allow-dup-failed-only-caller-{uuid.uuid4()}", + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + ) + + +async def check_result(runner: Runner, handle: WorkflowHandle) -> None: + # Sub-case 1: completed target -> operation fails. + completed_result: SwsResult = await handle.result() + assert ( + completed_result.error + ), "expected failure for ALLOW_DUPLICATE_FAILED_ONLY against a completed run" + + # Sub-case 2: terminated target -> a new run should start. + target_id = f"signalwithstart-allow-dup-failed-only-terminated-{uuid.uuid4()}" + target = await runner.client.start_workflow( + TargetWorkflow.run, + id=target_id, + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + ) + original_run_id = target.result_run_id + await target.terminate("setup") + + terminated_result = await _run_caller(runner, target_id) + assert ( + not terminated_result.error + ), f"expected success after terminated run, got error: {terminated_result.error}" + assert terminated_result.run_id, "expected a non-empty new run id" + assert ( + terminated_result.run_id != original_run_id + ), "expected a new run id after the previous run was terminated" + + # Cleanup the freshly-started run. + try: + await runner.client.get_workflow_handle( + target_id, run_id=terminated_result.run_id + ).terminate("cleanup") + except Exception: + pass + + +register_feature( + workflows=[CallerWorkflow, TargetWorkflow], + start=start, + check_result=check_result, +) diff --git a/features/signalwithstart/id_reuse_reject_duplicate/feature.py b/features/signalwithstart/id_reuse_reject_duplicate/feature.py new file mode 100644 index 00000000..6b2f83e2 --- /dev/null +++ b/features/signalwithstart/id_reuse_reject_duplicate/feature.py @@ -0,0 +1,106 @@ +import uuid +from dataclasses import dataclass +from datetime import timedelta +from typing import Optional + +from temporalio import workflow +from temporalio.client import WorkflowHandle +from temporalio.common import WorkflowIDReusePolicy + +from harness.python.feature import Runner, register_feature + +SIGNAL_VALUE = "test-signal-value" + + +@dataclass +class SwsResult: + workflow_id: str + run_id: str + error: Optional[str] = None + + +def _error_chain(err: BaseException) -> str: + """Flatten an exception and its causes into a single message. The useful + detail (e.g. "already started") lives on the cause of the NexusOperationError, + not its top-level message.""" + parts = [] + cur: Optional[BaseException] = err + for _ in range(8): + if cur is None: + break + parts.append(f"{type(cur).__name__}: {cur}") + cur = getattr(cur, "cause", None) or cur.__cause__ + return " | ".join(parts) + + +@workflow.defn +class TargetWorkflow: + def __init__(self) -> None: + self._signal_value: Optional[str] = None + + @workflow.run + async def run(self) -> str: + await workflow.wait_condition(lambda: self._signal_value is not None) + assert self._signal_value is not None + return self._signal_value + + @workflow.signal + def my_signal(self, value: str) -> None: + self._signal_value = value + + +@workflow.defn +class CallerWorkflow: + @workflow.run + async def run(self, target_id: str) -> SwsResult: + # With REJECT_DUPLICATE and a previously-completed run, the operation is + # expected to fail. Capture the failure so the client can assert on it. + try: + handle = await workflow.signal_with_start_workflow( + TargetWorkflow.run, + id=target_id, + task_queue=workflow.info().task_queue, + signal=TargetWorkflow.my_signal, + signal_args=SIGNAL_VALUE, + id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE, + ) + return SwsResult(workflow_id=handle.id, run_id=handle.run_id) + except Exception as err: + return SwsResult(workflow_id=target_id, run_id="", error=_error_chain(err)) + + +async def start(runner: Runner) -> WorkflowHandle: + target_id = f"signalwithstart-id-reuse-reject-dup-target-{uuid.uuid4()}" + + # Start and complete the target so a closed run exists for target_id. + target = await runner.client.start_workflow( + TargetWorkflow.run, + id=target_id, + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + ) + await target.signal(TargetWorkflow.my_signal, "setup") + await target.result() + + return await runner.client.start_workflow( + CallerWorkflow.run, + target_id, + id=f"signalwithstart-id-reuse-reject-dup-caller-{uuid.uuid4()}", + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + ) + + +async def check_result(runner: Runner, handle: WorkflowHandle) -> None: + result: SwsResult = await handle.result() + assert result.error, "expected signal-with-start to fail with REJECT_DUPLICATE" + assert ( + "duplicate" in result.error.lower() or "already" in result.error.lower() + ), f"unexpected failure message: {result.error}" + + +register_feature( + workflows=[CallerWorkflow, TargetWorkflow], + start=start, + check_result=check_result, +) diff --git a/features/signalwithstart/signal_existing_workflow/feature.py b/features/signalwithstart/signal_existing_workflow/feature.py new file mode 100644 index 00000000..e2d4c2e1 --- /dev/null +++ b/features/signalwithstart/signal_existing_workflow/feature.py @@ -0,0 +1,89 @@ +import uuid +from dataclasses import dataclass +from datetime import timedelta +from typing import Optional + +from temporalio import workflow +from temporalio.client import WorkflowHandle +from temporalio.common import WorkflowIDReusePolicy + +from harness.python.feature import Runner, register_feature + +SIGNAL_VALUE = "test-signal-value" + +# Cross-function state shared between start() and check_result() (both run on the +# host, sequentially, in this process). +_setup: dict = {} + + +@dataclass +class SwsResult: + workflow_id: str + run_id: str + + +@workflow.defn +class TargetWorkflow: + def __init__(self) -> None: + self._signal_value: Optional[str] = None + + @workflow.run + async def run(self) -> str: + await workflow.wait_condition(lambda: self._signal_value is not None) + assert self._signal_value is not None + return self._signal_value + + @workflow.signal + def my_signal(self, value: str) -> None: + self._signal_value = value + + +@workflow.defn +class CallerWorkflow: + @workflow.run + async def run(self, target_id: str) -> SwsResult: + handle = await workflow.signal_with_start_workflow( + TargetWorkflow.run, + id=target_id, + task_queue=workflow.info().task_queue, + signal=TargetWorkflow.my_signal, + signal_args=SIGNAL_VALUE, + id_reuse_policy=WorkflowIDReusePolicy.ALLOW_DUPLICATE, + ) + return SwsResult(workflow_id=handle.id, run_id=handle.run_id) + + +async def start(runner: Runner) -> WorkflowHandle: + target_id = f"signalwithstart-signal-existing-target-{uuid.uuid4()}" + + # Start the target and leave it running; signal-with-start should signal this + # existing run rather than start a new one. + target = await runner.client.start_workflow( + TargetWorkflow.run, + id=target_id, + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + ) + _setup["original_run_id"] = target.result_run_id + + return await runner.client.start_workflow( + CallerWorkflow.run, + target_id, + id=f"signalwithstart-signal-existing-caller-{uuid.uuid4()}", + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + ) + + +async def check_result(runner: Runner, handle: WorkflowHandle) -> None: + result: SwsResult = await handle.result() + assert ( + result.run_id == _setup["original_run_id"] + ), f"expected existing run id {_setup['original_run_id']}, got {result.run_id}" + + +register_feature( + workflows=[CallerWorkflow, TargetWorkflow], + start=start, + check_result=check_result, +) diff --git a/features/signalwithstart/signal_terminated_workflow/feature.py b/features/signalwithstart/signal_terminated_workflow/feature.py new file mode 100644 index 00000000..bc91bb92 --- /dev/null +++ b/features/signalwithstart/signal_terminated_workflow/feature.py @@ -0,0 +1,95 @@ +import uuid +from dataclasses import dataclass +from datetime import timedelta +from typing import Optional + +from temporalio import workflow +from temporalio.client import WorkflowHandle + +from harness.python.feature import Runner, register_feature + +SIGNAL_VALUE = "test-signal-value" + +_setup: dict = {} + + +@dataclass +class SwsResult: + workflow_id: str + run_id: str + + +@workflow.defn +class TargetWorkflow: + def __init__(self) -> None: + self._signal_value: Optional[str] = None + + @workflow.run + async def run(self) -> str: + await workflow.wait_condition(lambda: self._signal_value is not None) + assert self._signal_value is not None + return self._signal_value + + @workflow.signal + def my_signal(self, value: str) -> None: + self._signal_value = value + + +@workflow.defn +class CallerWorkflow: + @workflow.run + async def run(self, target_id: str) -> SwsResult: + handle = await workflow.signal_with_start_workflow( + TargetWorkflow.run, + id=target_id, + task_queue=workflow.info().task_queue, + signal=TargetWorkflow.my_signal, + signal_args=SIGNAL_VALUE, + ) + return SwsResult(workflow_id=handle.id, run_id=handle.run_id) + + +async def start(runner: Runner) -> WorkflowHandle: + target_id = f"signalwithstart-signal-terminated-target-{uuid.uuid4()}" + + # Start the target, then terminate it. Signal-with-start should start a fresh + # run because the previous one is closed. + target = await runner.client.start_workflow( + TargetWorkflow.run, + id=target_id, + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + ) + _setup["original_run_id"] = target.result_run_id + await target.terminate("setup") + + return await runner.client.start_workflow( + CallerWorkflow.run, + target_id, + id=f"signalwithstart-signal-terminated-caller-{uuid.uuid4()}", + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + ) + + +async def check_result(runner: Runner, handle: WorkflowHandle) -> None: + result: SwsResult = await handle.result() + assert result.run_id, "expected a non-empty run id" + assert ( + result.run_id != _setup["original_run_id"] + ), "expected a new run id after the previous run was terminated" + + # Cleanup the freshly-started run. + try: + await runner.client.get_workflow_handle( + result.workflow_id, run_id=result.run_id + ).terminate("cleanup") + except Exception: + pass + + +register_feature( + workflows=[CallerWorkflow, TargetWorkflow], + start=start, + check_result=check_result, +) diff --git a/features/signalwithstart/start_delay/feature.py b/features/signalwithstart/start_delay/feature.py new file mode 100644 index 00000000..ab158946 --- /dev/null +++ b/features/signalwithstart/start_delay/feature.py @@ -0,0 +1,82 @@ +import uuid +from dataclasses import dataclass +from datetime import timedelta +from typing import Optional + +from temporalio import workflow +from temporalio.client import WorkflowHandle + +from harness.python.feature import Runner, register_feature + +SIGNAL_VALUE = "test-signal-value" +START_DELAY = timedelta(seconds=2) + + +@dataclass +class SwsResult: + workflow_id: str + run_id: str + + +@workflow.defn +class TargetWorkflow: + def __init__(self) -> None: + self._signal_value: Optional[str] = None + + @workflow.run + async def run(self) -> str: + await workflow.wait_condition(lambda: self._signal_value is not None) + assert self._signal_value is not None + return self._signal_value + + @workflow.signal + def my_signal(self, value: str) -> None: + self._signal_value = value + + +@workflow.defn +class CallerWorkflow: + @workflow.run + async def run(self, target_id: str) -> SwsResult: + handle = await workflow.signal_with_start_workflow( + TargetWorkflow.run, + id=target_id, + task_queue=workflow.info().task_queue, + signal=TargetWorkflow.my_signal, + signal_args=SIGNAL_VALUE, + start_delay=START_DELAY, + ) + return SwsResult(workflow_id=handle.id, run_id=handle.run_id) + + +async def start(runner: Runner) -> WorkflowHandle: + target_id = f"signalwithstart-start-delay-target-{uuid.uuid4()}" + return await runner.client.start_workflow( + CallerWorkflow.run, + target_id, + id=f"signalwithstart-start-delay-caller-{uuid.uuid4()}", + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + ) + + +async def check_result(runner: Runner, handle: WorkflowHandle) -> None: + result: SwsResult = await handle.result() + assert result.run_id, "expected a non-empty target run id with start delay" + + # After the start delay elapses the target begins, receives the buffered + # signal, and completes returning the signal value. + target = runner.client.get_workflow_handle( + result.workflow_id, run_id=result.run_id + ) + target_result = await target.result() + assert ( + target_result == SIGNAL_VALUE + ), f"expected target to return {SIGNAL_VALUE!r}, got {target_result!r}" + + +register_feature( + workflows=[CallerWorkflow, TargetWorkflow], + start=start, + check_result=check_result, +) diff --git a/pyproject.toml b/pyproject.toml index 39818262..6e7580b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,9 @@ requires-python = ">=3.10" license = "MIT" dependencies = ["temporalio"] +[tool.uv.sources] +temporalio = { git = "https://github.com/temporalio/sdk-python.git", branch = "main" } + [dependency-groups] dev = [ "mypy>=1.0", diff --git a/uv.lock b/uv.lock index f3573a26..1cc1e4a7 100644 --- a/uv.lock +++ b/uv.lock @@ -17,7 +17,7 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "temporalio" }] +requires-dist = [{ name = "temporalio", git = "https://github.com/temporalio/sdk-python.git?branch=main" }] [package.metadata.requires-dev] dev = [ @@ -249,8 +249,8 @@ wheels = [ [[package]] name = "temporalio" -version = "1.27.2" -source = { registry = "https://pypi.org/simple" } +version = "1.28.0" +source = { git = "https://github.com/temporalio/sdk-python.git?branch=main#3c211b332eafe2cad136cc9bd4d69ea7e16562bf" } dependencies = [ { name = "nexus-rpc" }, { name = "protobuf" }, @@ -258,14 +258,6 @@ dependencies = [ { name = "types-protobuf" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/62/2bc1a9ad29382a3a99f088907ef2024a94420cfef340be1b33026c632828/temporalio-1.27.2.tar.gz", hash = "sha256:633bf2379492f3db1e887d1e64fdac00d9c2ddc3e9382b831d5af68256912e92", size = 2503041, upload-time = "2026-05-14T02:17:57.565Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/85/9da14f9fbdfae95435d29353bb1c55891581ad6b23c86ca56e72d83035ed/temporalio-1.27.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:860f706380faafec8f183f9194d0883c8033a4211c5d19c2c962c45b06cf99e9", size = 14602829, upload-time = "2026-05-14T02:17:45.624Z" }, - { url = "https://files.pythonhosted.org/packages/24/51/b7437991e71eea082dc53222da11f064974917cd59063ba57e13e5895fbc/temporalio-1.27.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a8dc0c680e351f3132809861888d8326dbd5030dd4e570663597e7d4768d9502", size = 13997680, upload-time = "2026-05-14T02:17:53.968Z" }, - { url = "https://files.pythonhosted.org/packages/8c/5d/358065040e6f0cedbf669acd333622999eec737ff868ca7829d727b77746/temporalio-1.27.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:805f3de4d193dec52e040e41dbfc9ab44be0206d2e81142ceefaf7b7208058d1", size = 14252199, upload-time = "2026-05-14T02:17:36.972Z" }, - { url = "https://files.pythonhosted.org/packages/72/8a/85d2eab07c3e23fc1124203e76857c69ab9b22d8ccebad0835e294edb754/temporalio-1.27.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bc996cb501b8a918f50037ccee6facb05bb70984acada4c2a3e01f5e7957a38", size = 14779945, upload-time = "2026-05-14T02:18:05.513Z" }, - { url = "https://files.pythonhosted.org/packages/67/81/c9b08609e2a92ecf62c97c59cabfa0608337c8d5cc9941eed5d9a7778840/temporalio-1.27.2-cp310-abi3-win_amd64.whl", hash = "sha256:62a84ae9a60c17932971e4ca3b0f3cd6f32f173b8183e759989376503fb95af6", size = 14981897, upload-time = "2026-05-14T02:17:27.333Z" }, -] [[package]] name = "tomli" From aa2e0ae3949861b691f17195f1ddb6f17bb66cbc Mon Sep 17 00:00:00 2001 From: Sean Kane Date: Tue, 16 Jun 2026 17:02:26 -0600 Subject: [PATCH 2/9] enable via dynamic config --- dockerfiles/dynamicconfig/docker.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/dockerfiles/dynamicconfig/docker.yaml b/dockerfiles/dynamicconfig/docker.yaml index 4cc5e363..2c411e7b 100644 --- a/dockerfiles/dynamicconfig/docker.yaml +++ b/dockerfiles/dynamicconfig/docker.yaml @@ -22,3 +22,9 @@ frontend.enableUpdateWorkflowExecutionAsyncAccepted: frontend.workerVersioningWorkflowAPIs: - value: true constraints: {} +history.enableSignalWithStartFromWorkflow: + - value: true + constraints: {} +history.enableChasm: + - value: true + constraints: {} From 7ec8cb507b086e1a6341947aa8a2cb4f8cdae3cd Mon Sep 17 00:00:00 2001 From: Sean Kane Date: Wed, 17 Jun 2026 15:08:23 -0600 Subject: [PATCH 3/9] adding readmes and fixing feature tests --- cmd/run.go | 7 +++++++ .../both_workflows_visible/README.md | 15 +++++++++++++++ .../both_workflows_visible/feature.py | 2 +- features/signalwithstart/happy_path/feature.py | 2 +- .../signalwithstart/id_conflict_fail/README.md | 15 +++++++++++++++ .../signalwithstart/id_conflict_fail/feature.py | 2 +- .../id_conflict_terminate_existing/README.md | 14 ++++++++++++++ .../id_conflict_terminate_existing/feature.py | 2 +- .../id_conflict_use_existing/README.md | 13 +++++++++++++ .../id_conflict_use_existing/feature.py | 2 +- .../id_reuse_allow_duplicate/README.md | 12 ++++++++++++ .../id_reuse_allow_duplicate/feature.py | 2 +- .../README.md | 15 +++++++++++++++ .../feature.py | 2 +- .../id_reuse_reject_duplicate/README.md | 14 ++++++++++++++ .../id_reuse_reject_duplicate/feature.py | 2 +- .../signal_existing_workflow/README.md | 13 +++++++++++++ .../signal_existing_workflow/feature.py | 2 +- .../signal_terminated_workflow/README.md | 13 +++++++++++++ .../signal_terminated_workflow/feature.py | 2 +- features/signalwithstart/start_delay/README.md | 13 +++++++++++++ features/signalwithstart/start_delay/feature.py | 2 +- 22 files changed, 155 insertions(+), 11 deletions(-) create mode 100644 features/signalwithstart/both_workflows_visible/README.md create mode 100644 features/signalwithstart/id_conflict_fail/README.md create mode 100644 features/signalwithstart/id_conflict_terminate_existing/README.md create mode 100644 features/signalwithstart/id_conflict_use_existing/README.md create mode 100644 features/signalwithstart/id_reuse_allow_duplicate/README.md create mode 100644 features/signalwithstart/id_reuse_allow_duplicate_failed_only/README.md create mode 100644 features/signalwithstart/id_reuse_reject_duplicate/README.md create mode 100644 features/signalwithstart/signal_existing_workflow/README.md create mode 100644 features/signalwithstart/signal_terminated_workflow/README.md create mode 100644 features/signalwithstart/start_delay/README.md diff --git a/cmd/run.go b/cmd/run.go index 7bd3898a..67a97a2f 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -436,6 +436,13 @@ func (r *Runner) runBatch(ctx context.Context, batch runBatch) error { LogLevel: "error", ClientOptions: &client.Options{Namespace: config.Namespace}, ExtraArgs: dynamicConfigArgs, + // Pin a CLI build that registers the __temporal_system Nexus + // WorkflowService, required by the signalwithstart features (signal + // with start from a workflow). The default SDK-bundled dev server is + // too old. Remove this once the capability ships in a stock release. + CachedDownload: testsuite.CachedDownload{ + Version: "v1.7.1-system-nexus-operations", + }, }) if err != nil { return fmt.Errorf("failed starting devserver: %w", err) diff --git a/features/signalwithstart/both_workflows_visible/README.md b/features/signalwithstart/both_workflows_visible/README.md new file mode 100644 index 00000000..79522427 --- /dev/null +++ b/features/signalwithstart/both_workflows_visible/README.md @@ -0,0 +1,15 @@ +# Signal-with-start from a workflow: payloads, memo, and visibility + +A workflow calls `workflow.signal_with_start_workflow(...)` passing a workflow +input argument, a signal argument, and a memo. This exercises the full +payload-serialization path through the `__temporal_system` Nexus endpoint +(covering what the proto-binary server test verifies, since Python encodes these +payloads automatically). + +Verifies: both the caller and the started target complete; the target returns the +signal value; and the memo passed in the request is visible on the target. + +## Server requirements +Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, +`history.enableChasm=true`, and Nexus enabled (`system.enableNexus=true`) with the +built-in `__temporal_system` endpoint. diff --git a/features/signalwithstart/both_workflows_visible/feature.py b/features/signalwithstart/both_workflows_visible/feature.py index 27e8b951..69fb453e 100644 --- a/features/signalwithstart/both_workflows_visible/feature.py +++ b/features/signalwithstart/both_workflows_visible/feature.py @@ -49,7 +49,7 @@ async def run(self, target_id: str) -> SwsResult: signal_args=SIGNAL_VALUE, memo={MEMO_KEY: MEMO_VALUE}, ) - return SwsResult(workflow_id=handle.id, run_id=handle.run_id) + return SwsResult(workflow_id=handle.id, run_id=handle.run_id or "") async def start(runner: Runner) -> WorkflowHandle: diff --git a/features/signalwithstart/happy_path/feature.py b/features/signalwithstart/happy_path/feature.py index b462273d..aba6d64b 100644 --- a/features/signalwithstart/happy_path/feature.py +++ b/features/signalwithstart/happy_path/feature.py @@ -50,7 +50,7 @@ async def run(self, target_id: str) -> SwsResult: signal=TargetWorkflow.my_signal, signal_args=SIGNAL_VALUE, ) - return SwsResult(workflow_id=handle.id, run_id=handle.run_id) + return SwsResult(workflow_id=handle.id, run_id=handle.run_id or "") async def start(runner: Runner) -> WorkflowHandle: diff --git a/features/signalwithstart/id_conflict_fail/README.md b/features/signalwithstart/id_conflict_fail/README.md new file mode 100644 index 00000000..f75d653c --- /dev/null +++ b/features/signalwithstart/id_conflict_fail/README.md @@ -0,0 +1,15 @@ +# Signal-with-start from a workflow: FAIL conflict policy is rejected + +A workflow calls `workflow.signal_with_start_workflow(...)` with +`id_conflict_policy=FAIL`. This policy is not supported for signal-with-start, so +the server rejects the scheduled Nexus operation. The rejection surfaces as a +workflow task failure (not a catchable error), so the caller workflow never +completes. + +Verifies: the caller's history contains a workflow task failure whose message +indicates the conflict policy is not supported. + +## Server requirements +Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, +`history.enableChasm=true`, and Nexus enabled (`system.enableNexus=true`) with the +built-in `__temporal_system` endpoint. diff --git a/features/signalwithstart/id_conflict_fail/feature.py b/features/signalwithstart/id_conflict_fail/feature.py index d3d2af95..b44cb33d 100644 --- a/features/signalwithstart/id_conflict_fail/feature.py +++ b/features/signalwithstart/id_conflict_fail/feature.py @@ -47,7 +47,7 @@ async def run(self, target_id: str) -> str: signal_args=SIGNAL_VALUE, id_conflict_policy=WorkflowIDConflictPolicy.FAIL, ) - return handle.run_id + return handle.run_id or "" async def start(runner: Runner) -> WorkflowHandle: diff --git a/features/signalwithstart/id_conflict_terminate_existing/README.md b/features/signalwithstart/id_conflict_terminate_existing/README.md new file mode 100644 index 00000000..1bfac54a --- /dev/null +++ b/features/signalwithstart/id_conflict_terminate_existing/README.md @@ -0,0 +1,14 @@ +# Signal-with-start from a workflow: TERMINATE_EXISTING conflict policy + +A target workflow is already running. A workflow calls +`workflow.signal_with_start_workflow(...)` with +`id_conflict_policy=TERMINATE_EXISTING`. The running run is terminated and a new +run is started. + +Verifies: the returned run id differs from the original, and the original run's +status is TERMINATED. + +## Server requirements +Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, +`history.enableChasm=true`, and Nexus enabled (`system.enableNexus=true`) with the +built-in `__temporal_system` endpoint. diff --git a/features/signalwithstart/id_conflict_terminate_existing/feature.py b/features/signalwithstart/id_conflict_terminate_existing/feature.py index 2c1d519c..d6eadb15 100644 --- a/features/signalwithstart/id_conflict_terminate_existing/feature.py +++ b/features/signalwithstart/id_conflict_terminate_existing/feature.py @@ -48,7 +48,7 @@ async def run(self, target_id: str) -> SwsResult: signal_args=SIGNAL_VALUE, id_conflict_policy=WorkflowIDConflictPolicy.TERMINATE_EXISTING, ) - return SwsResult(workflow_id=handle.id, run_id=handle.run_id) + return SwsResult(workflow_id=handle.id, run_id=handle.run_id or "") async def start(runner: Runner) -> WorkflowHandle: diff --git a/features/signalwithstart/id_conflict_use_existing/README.md b/features/signalwithstart/id_conflict_use_existing/README.md new file mode 100644 index 00000000..9efa88d5 --- /dev/null +++ b/features/signalwithstart/id_conflict_use_existing/README.md @@ -0,0 +1,13 @@ +# Signal-with-start from a workflow: USE_EXISTING conflict policy + +A target workflow is already running. A workflow calls +`workflow.signal_with_start_workflow(...)` with +`id_conflict_policy=USE_EXISTING`. The existing run is signaled and its run id is +returned; no new run is started. + +Verifies: the returned run id equals the already-running target's run id. + +## Server requirements +Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, +`history.enableChasm=true`, and Nexus enabled (`system.enableNexus=true`) with the +built-in `__temporal_system` endpoint. diff --git a/features/signalwithstart/id_conflict_use_existing/feature.py b/features/signalwithstart/id_conflict_use_existing/feature.py index 0495ff41..99546783 100644 --- a/features/signalwithstart/id_conflict_use_existing/feature.py +++ b/features/signalwithstart/id_conflict_use_existing/feature.py @@ -48,7 +48,7 @@ async def run(self, target_id: str) -> SwsResult: signal_args=SIGNAL_VALUE, id_conflict_policy=WorkflowIDConflictPolicy.USE_EXISTING, ) - return SwsResult(workflow_id=handle.id, run_id=handle.run_id) + return SwsResult(workflow_id=handle.id, run_id=handle.run_id or "") async def start(runner: Runner) -> WorkflowHandle: diff --git a/features/signalwithstart/id_reuse_allow_duplicate/README.md b/features/signalwithstart/id_reuse_allow_duplicate/README.md new file mode 100644 index 00000000..628addf1 --- /dev/null +++ b/features/signalwithstart/id_reuse_allow_duplicate/README.md @@ -0,0 +1,12 @@ +# Signal-with-start from a workflow: ALLOW_DUPLICATE reuse policy + +A target workflow id has a previously-completed run. A workflow calls +`workflow.signal_with_start_workflow(...)` with `id_reuse_policy=ALLOW_DUPLICATE`. +Because duplicates are allowed for closed runs, a new execution is started. + +Verifies: the returned run id differs from the prior completed run's run id. + +## Server requirements +Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, +`history.enableChasm=true`, and Nexus enabled (`system.enableNexus=true`) with the +built-in `__temporal_system` endpoint. diff --git a/features/signalwithstart/id_reuse_allow_duplicate/feature.py b/features/signalwithstart/id_reuse_allow_duplicate/feature.py index f5355e9b..8534c923 100644 --- a/features/signalwithstart/id_reuse_allow_duplicate/feature.py +++ b/features/signalwithstart/id_reuse_allow_duplicate/feature.py @@ -48,7 +48,7 @@ async def run(self, target_id: str) -> SwsResult: signal_args=SIGNAL_VALUE, id_reuse_policy=WorkflowIDReusePolicy.ALLOW_DUPLICATE, ) - return SwsResult(workflow_id=handle.id, run_id=handle.run_id) + return SwsResult(workflow_id=handle.id, run_id=handle.run_id or "") async def start(runner: Runner) -> WorkflowHandle: diff --git a/features/signalwithstart/id_reuse_allow_duplicate_failed_only/README.md b/features/signalwithstart/id_reuse_allow_duplicate_failed_only/README.md new file mode 100644 index 00000000..e979da5c --- /dev/null +++ b/features/signalwithstart/id_reuse_allow_duplicate_failed_only/README.md @@ -0,0 +1,15 @@ +# Signal-with-start from a workflow: ALLOW_DUPLICATE_FAILED_ONLY reuse policy + +Covers both sub-cases of `id_reuse_policy=ALLOW_DUPLICATE_FAILED_ONLY` when called +from a workflow via `workflow.signal_with_start_workflow(...)`: + +1. Target completed successfully -> the operation is rejected. +2. Target was terminated (a failed/closed outcome) -> a new run is started. + +Verifies: case 1 fails; case 2 starts a new run with a different run id. + +## Server requirements +Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, +`history.enableChasm=true`, and Nexus enabled (`system.enableNexus=true`) with the +built-in `__temporal_system` endpoint. Also set +`system.workflowIdReuseMinimalInterval=0` to avoid reuse-interval throttling. diff --git a/features/signalwithstart/id_reuse_allow_duplicate_failed_only/feature.py b/features/signalwithstart/id_reuse_allow_duplicate_failed_only/feature.py index 01e987f1..f0f06232 100644 --- a/features/signalwithstart/id_reuse_allow_duplicate_failed_only/feature.py +++ b/features/signalwithstart/id_reuse_allow_duplicate_failed_only/feature.py @@ -61,7 +61,7 @@ async def run(self, target_id: str) -> SwsResult: signal_args=SIGNAL_VALUE, id_reuse_policy=WorkflowIDReusePolicy.ALLOW_DUPLICATE_FAILED_ONLY, ) - return SwsResult(workflow_id=handle.id, run_id=handle.run_id) + return SwsResult(workflow_id=handle.id, run_id=handle.run_id or "") except Exception as err: return SwsResult(workflow_id=target_id, run_id="", error=_error_chain(err)) diff --git a/features/signalwithstart/id_reuse_reject_duplicate/README.md b/features/signalwithstart/id_reuse_reject_duplicate/README.md new file mode 100644 index 00000000..57c79eee --- /dev/null +++ b/features/signalwithstart/id_reuse_reject_duplicate/README.md @@ -0,0 +1,14 @@ +# Signal-with-start from a workflow: REJECT_DUPLICATE reuse policy + +A target workflow id has a previously-completed run. A workflow calls +`workflow.signal_with_start_workflow(...)` with `id_reuse_policy=REJECT_DUPLICATE`. +The operation is rejected because a closed run with that id already exists. + +Verifies: the signal-with-start operation fails (the caller captures the failure, +whose cause message indicates a duplicate/already-started workflow). + +## Server requirements +Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, +`history.enableChasm=true`, and Nexus enabled (`system.enableNexus=true`) with the +built-in `__temporal_system` endpoint. Also set +`system.workflowIdReuseMinimalInterval=0` to avoid reuse-interval throttling. diff --git a/features/signalwithstart/id_reuse_reject_duplicate/feature.py b/features/signalwithstart/id_reuse_reject_duplicate/feature.py index 6b2f83e2..2a38e061 100644 --- a/features/signalwithstart/id_reuse_reject_duplicate/feature.py +++ b/features/signalwithstart/id_reuse_reject_duplicate/feature.py @@ -64,7 +64,7 @@ async def run(self, target_id: str) -> SwsResult: signal_args=SIGNAL_VALUE, id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE, ) - return SwsResult(workflow_id=handle.id, run_id=handle.run_id) + return SwsResult(workflow_id=handle.id, run_id=handle.run_id or "") except Exception as err: return SwsResult(workflow_id=target_id, run_id="", error=_error_chain(err)) diff --git a/features/signalwithstart/signal_existing_workflow/README.md b/features/signalwithstart/signal_existing_workflow/README.md new file mode 100644 index 00000000..856d3a4c --- /dev/null +++ b/features/signalwithstart/signal_existing_workflow/README.md @@ -0,0 +1,13 @@ +# Signal-with-start from a workflow: signal an existing run + +A target workflow is already running. A workflow then calls +`workflow.signal_with_start_workflow(...)` for the same workflow id. Because a run +already exists, the operation signals it rather than starting a new one. + +Verifies: the returned run id equals the already-running target's run id (no new +execution was started). + +## Server requirements +Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, +`history.enableChasm=true`, and Nexus enabled (`system.enableNexus=true`) with the +built-in `__temporal_system` endpoint. diff --git a/features/signalwithstart/signal_existing_workflow/feature.py b/features/signalwithstart/signal_existing_workflow/feature.py index e2d4c2e1..1918a7f3 100644 --- a/features/signalwithstart/signal_existing_workflow/feature.py +++ b/features/signalwithstart/signal_existing_workflow/feature.py @@ -50,7 +50,7 @@ async def run(self, target_id: str) -> SwsResult: signal_args=SIGNAL_VALUE, id_reuse_policy=WorkflowIDReusePolicy.ALLOW_DUPLICATE, ) - return SwsResult(workflow_id=handle.id, run_id=handle.run_id) + return SwsResult(workflow_id=handle.id, run_id=handle.run_id or "") async def start(runner: Runner) -> WorkflowHandle: diff --git a/features/signalwithstart/signal_terminated_workflow/README.md b/features/signalwithstart/signal_terminated_workflow/README.md new file mode 100644 index 00000000..9267cdce --- /dev/null +++ b/features/signalwithstart/signal_terminated_workflow/README.md @@ -0,0 +1,13 @@ +# Signal-with-start from a workflow: terminated target + +A target workflow is started and then terminated (closed). A workflow then calls +`workflow.signal_with_start_workflow(...)` for the same workflow id. Because the +previous run is closed, a fresh run is started. + +Verifies: the returned run id differs from the terminated run's run id (a new +execution was started). + +## Server requirements +Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, +`history.enableChasm=true`, and Nexus enabled (`system.enableNexus=true`) with the +built-in `__temporal_system` endpoint. diff --git a/features/signalwithstart/signal_terminated_workflow/feature.py b/features/signalwithstart/signal_terminated_workflow/feature.py index bc91bb92..27db178a 100644 --- a/features/signalwithstart/signal_terminated_workflow/feature.py +++ b/features/signalwithstart/signal_terminated_workflow/feature.py @@ -46,7 +46,7 @@ async def run(self, target_id: str) -> SwsResult: signal=TargetWorkflow.my_signal, signal_args=SIGNAL_VALUE, ) - return SwsResult(workflow_id=handle.id, run_id=handle.run_id) + return SwsResult(workflow_id=handle.id, run_id=handle.run_id or "") async def start(runner: Runner) -> WorkflowHandle: diff --git a/features/signalwithstart/start_delay/README.md b/features/signalwithstart/start_delay/README.md new file mode 100644 index 00000000..61e28d08 --- /dev/null +++ b/features/signalwithstart/start_delay/README.md @@ -0,0 +1,13 @@ +# Signal-with-start from a workflow: start delay + +A workflow calls `workflow.signal_with_start_workflow(...)` with a `start_delay`. +The target is scheduled to start after the delay; the signal is buffered and +delivered when the run begins. + +Verifies: a run id is returned, and after the delay the target starts, receives +the buffered signal, and completes returning the signal value. + +## Server requirements +Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, +`history.enableChasm=true`, and Nexus enabled (`system.enableNexus=true`) with the +built-in `__temporal_system` endpoint. diff --git a/features/signalwithstart/start_delay/feature.py b/features/signalwithstart/start_delay/feature.py index ab158946..adef17dc 100644 --- a/features/signalwithstart/start_delay/feature.py +++ b/features/signalwithstart/start_delay/feature.py @@ -46,7 +46,7 @@ async def run(self, target_id: str) -> SwsResult: signal_args=SIGNAL_VALUE, start_delay=START_DELAY, ) - return SwsResult(workflow_id=handle.id, run_id=handle.run_id) + return SwsResult(workflow_id=handle.id, run_id=handle.run_id or "") async def start(runner: Runner) -> WorkflowHandle: From 3fec3cf239a70b9cf6e8baca4be6f4c09ee1698d Mon Sep 17 00:00:00 2001 From: Sean Kane Date: Mon, 29 Jun 2026 14:54:20 -0600 Subject: [PATCH 4/9] revert --- pyproject.toml | 3 --- uv.lock | 14 +++++++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6e7580b0..39818262 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,9 +7,6 @@ requires-python = ">=3.10" license = "MIT" dependencies = ["temporalio"] -[tool.uv.sources] -temporalio = { git = "https://github.com/temporalio/sdk-python.git", branch = "main" } - [dependency-groups] dev = [ "mypy>=1.0", diff --git a/uv.lock b/uv.lock index 1cc1e4a7..f3573a26 100644 --- a/uv.lock +++ b/uv.lock @@ -17,7 +17,7 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "temporalio", git = "https://github.com/temporalio/sdk-python.git?branch=main" }] +requires-dist = [{ name = "temporalio" }] [package.metadata.requires-dev] dev = [ @@ -249,8 +249,8 @@ wheels = [ [[package]] name = "temporalio" -version = "1.28.0" -source = { git = "https://github.com/temporalio/sdk-python.git?branch=main#3c211b332eafe2cad136cc9bd4d69ea7e16562bf" } +version = "1.27.2" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nexus-rpc" }, { name = "protobuf" }, @@ -258,6 +258,14 @@ dependencies = [ { name = "types-protobuf" }, { name = "typing-extensions" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/ca/62/2bc1a9ad29382a3a99f088907ef2024a94420cfef340be1b33026c632828/temporalio-1.27.2.tar.gz", hash = "sha256:633bf2379492f3db1e887d1e64fdac00d9c2ddc3e9382b831d5af68256912e92", size = 2503041, upload-time = "2026-05-14T02:17:57.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/85/9da14f9fbdfae95435d29353bb1c55891581ad6b23c86ca56e72d83035ed/temporalio-1.27.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:860f706380faafec8f183f9194d0883c8033a4211c5d19c2c962c45b06cf99e9", size = 14602829, upload-time = "2026-05-14T02:17:45.624Z" }, + { url = "https://files.pythonhosted.org/packages/24/51/b7437991e71eea082dc53222da11f064974917cd59063ba57e13e5895fbc/temporalio-1.27.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a8dc0c680e351f3132809861888d8326dbd5030dd4e570663597e7d4768d9502", size = 13997680, upload-time = "2026-05-14T02:17:53.968Z" }, + { url = "https://files.pythonhosted.org/packages/8c/5d/358065040e6f0cedbf669acd333622999eec737ff868ca7829d727b77746/temporalio-1.27.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:805f3de4d193dec52e040e41dbfc9ab44be0206d2e81142ceefaf7b7208058d1", size = 14252199, upload-time = "2026-05-14T02:17:36.972Z" }, + { url = "https://files.pythonhosted.org/packages/72/8a/85d2eab07c3e23fc1124203e76857c69ab9b22d8ccebad0835e294edb754/temporalio-1.27.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bc996cb501b8a918f50037ccee6facb05bb70984acada4c2a3e01f5e7957a38", size = 14779945, upload-time = "2026-05-14T02:18:05.513Z" }, + { url = "https://files.pythonhosted.org/packages/67/81/c9b08609e2a92ecf62c97c59cabfa0608337c8d5cc9941eed5d9a7778840/temporalio-1.27.2-cp310-abi3-win_amd64.whl", hash = "sha256:62a84ae9a60c17932971e4ca3b0f3cd6f32f173b8183e759989376503fb95af6", size = 14981897, upload-time = "2026-05-14T02:17:27.333Z" }, +] [[package]] name = "tomli" From 9a305cc5f7999a4c670fd256326cb48ac040013f Mon Sep 17 00:00:00 2001 From: Sean Kane Date: Mon, 29 Jun 2026 14:58:31 -0600 Subject: [PATCH 5/9] formatting --- .../both_workflows_visible/feature.py | 14 +++++++------- features/signalwithstart/happy_path/feature.py | 10 ++++------ .../id_conflict_fail/feature.py | 6 +++--- .../id_conflict_terminate_existing/feature.py | 12 ++++++------ .../id_conflict_use_existing/feature.py | 6 +++--- .../id_reuse_allow_duplicate/feature.py | 6 +++--- .../feature.py | 18 +++++++++--------- .../id_reuse_reject_duplicate/feature.py | 6 +++--- .../signal_existing_workflow/feature.py | 6 +++--- .../signal_terminated_workflow/feature.py | 6 +++--- .../signalwithstart/start_delay/feature.py | 10 ++++------ 11 files changed, 48 insertions(+), 52 deletions(-) diff --git a/features/signalwithstart/both_workflows_visible/feature.py b/features/signalwithstart/both_workflows_visible/feature.py index 69fb453e..a513166b 100644 --- a/features/signalwithstart/both_workflows_visible/feature.py +++ b/features/signalwithstart/both_workflows_visible/feature.py @@ -69,18 +69,18 @@ async def check_result(runner: Runner, handle: WorkflowHandle) -> None: assert result.run_id, "expected a non-empty target run id" # Target completes after receiving the signal and returns the signal value. - target = runner.client.get_workflow_handle( - result.workflow_id, run_id=result.run_id - ) + target = runner.client.get_workflow_handle(result.workflow_id, run_id=result.run_id) target_result = await target.result() - assert ( - target_result == SIGNAL_VALUE - ), f"expected target to return {SIGNAL_VALUE!r}, got {target_result!r}" + assert target_result == SIGNAL_VALUE, ( + f"expected target to return {SIGNAL_VALUE!r}, got {target_result!r}" + ) # The memo passed in the signal-with-start request is visible on the target. desc = await target.describe() memo = await desc.memo() - assert memo.get(MEMO_KEY) == MEMO_VALUE, f"expected memo {MEMO_KEY}={MEMO_VALUE}, got {memo}" + assert memo.get(MEMO_KEY) == MEMO_VALUE, ( + f"expected memo {MEMO_KEY}={MEMO_VALUE}, got {memo}" + ) register_feature( diff --git a/features/signalwithstart/happy_path/feature.py b/features/signalwithstart/happy_path/feature.py index aba6d64b..e5199448 100644 --- a/features/signalwithstart/happy_path/feature.py +++ b/features/signalwithstart/happy_path/feature.py @@ -70,13 +70,11 @@ async def check_result(runner: Runner, handle: WorkflowHandle) -> None: # The signal-with-start created a new target execution; it completes after # receiving the signal and returns the signal value. - target = runner.client.get_workflow_handle( - result.workflow_id, run_id=result.run_id - ) + target = runner.client.get_workflow_handle(result.workflow_id, run_id=result.run_id) target_result = await target.result() - assert ( - target_result == SIGNAL_VALUE - ), f"expected target to return {SIGNAL_VALUE!r}, got {target_result!r}" + assert target_result == SIGNAL_VALUE, ( + f"expected target to return {SIGNAL_VALUE!r}, got {target_result!r}" + ) register_feature( diff --git a/features/signalwithstart/id_conflict_fail/feature.py b/features/signalwithstart/id_conflict_fail/feature.py index b44cb33d..6bb0ecf3 100644 --- a/features/signalwithstart/id_conflict_fail/feature.py +++ b/features/signalwithstart/id_conflict_fail/feature.py @@ -81,9 +81,9 @@ async def check_result(runner: Runner, handle: WorkflowHandle) -> None: timeout=30.0, ) message = event.workflow_task_failed_event_attributes.failure.message - assert ( - "not supported" in message.lower() - ), f"expected 'not supported' rejection, got: {message}" + assert "not supported" in message.lower(), ( + f"expected 'not supported' rejection, got: {message}" + ) # Cleanup the caller (stuck failing its workflow task) and the target. for wf_id, run_id in ( diff --git a/features/signalwithstart/id_conflict_terminate_existing/feature.py b/features/signalwithstart/id_conflict_terminate_existing/feature.py index d6eadb15..5c132b4d 100644 --- a/features/signalwithstart/id_conflict_terminate_existing/feature.py +++ b/features/signalwithstart/id_conflict_terminate_existing/feature.py @@ -76,18 +76,18 @@ async def start(runner: Runner) -> WorkflowHandle: async def check_result(runner: Runner, handle: WorkflowHandle) -> None: result: SwsResult = await handle.result() assert result.run_id, "expected a non-empty run id" - assert ( - result.run_id != _setup["original_run_id"] - ), "expected a new run id with TERMINATE_EXISTING" + assert result.run_id != _setup["original_run_id"], ( + "expected a new run id with TERMINATE_EXISTING" + ) # The original run must have been terminated. original = runner.client.get_workflow_handle( result.workflow_id, run_id=_setup["original_run_id"] ) desc = await original.describe() - assert ( - desc.status == WorkflowExecutionStatus.TERMINATED - ), f"expected original run TERMINATED, got {desc.status}" + assert desc.status == WorkflowExecutionStatus.TERMINATED, ( + f"expected original run TERMINATED, got {desc.status}" + ) # Cleanup the freshly-started run. try: diff --git a/features/signalwithstart/id_conflict_use_existing/feature.py b/features/signalwithstart/id_conflict_use_existing/feature.py index 99546783..2d73e0e2 100644 --- a/features/signalwithstart/id_conflict_use_existing/feature.py +++ b/features/signalwithstart/id_conflict_use_existing/feature.py @@ -75,9 +75,9 @@ async def start(runner: Runner) -> WorkflowHandle: async def check_result(runner: Runner, handle: WorkflowHandle) -> None: result: SwsResult = await handle.result() - assert ( - result.run_id == _setup["original_run_id"] - ), f"expected existing run id {_setup['original_run_id']}, got {result.run_id}" + assert result.run_id == _setup["original_run_id"], ( + f"expected existing run id {_setup['original_run_id']}, got {result.run_id}" + ) register_feature( diff --git a/features/signalwithstart/id_reuse_allow_duplicate/feature.py b/features/signalwithstart/id_reuse_allow_duplicate/feature.py index 8534c923..73b447b9 100644 --- a/features/signalwithstart/id_reuse_allow_duplicate/feature.py +++ b/features/signalwithstart/id_reuse_allow_duplicate/feature.py @@ -77,9 +77,9 @@ async def start(runner: Runner) -> WorkflowHandle: async def check_result(runner: Runner, handle: WorkflowHandle) -> None: result: SwsResult = await handle.result() assert result.run_id, "expected a non-empty run id" - assert ( - result.run_id != _setup["original_run_id"] - ), "expected a new run id with ALLOW_DUPLICATE after the prior run completed" + assert result.run_id != _setup["original_run_id"], ( + "expected a new run id with ALLOW_DUPLICATE after the prior run completed" + ) register_feature( diff --git a/features/signalwithstart/id_reuse_allow_duplicate_failed_only/feature.py b/features/signalwithstart/id_reuse_allow_duplicate_failed_only/feature.py index f0f06232..c524b33d 100644 --- a/features/signalwithstart/id_reuse_allow_duplicate_failed_only/feature.py +++ b/features/signalwithstart/id_reuse_allow_duplicate_failed_only/feature.py @@ -103,9 +103,9 @@ async def start(runner: Runner) -> WorkflowHandle: async def check_result(runner: Runner, handle: WorkflowHandle) -> None: # Sub-case 1: completed target -> operation fails. completed_result: SwsResult = await handle.result() - assert ( - completed_result.error - ), "expected failure for ALLOW_DUPLICATE_FAILED_ONLY against a completed run" + assert completed_result.error, ( + "expected failure for ALLOW_DUPLICATE_FAILED_ONLY against a completed run" + ) # Sub-case 2: terminated target -> a new run should start. target_id = f"signalwithstart-allow-dup-failed-only-terminated-{uuid.uuid4()}" @@ -119,13 +119,13 @@ async def check_result(runner: Runner, handle: WorkflowHandle) -> None: await target.terminate("setup") terminated_result = await _run_caller(runner, target_id) - assert ( - not terminated_result.error - ), f"expected success after terminated run, got error: {terminated_result.error}" + assert not terminated_result.error, ( + f"expected success after terminated run, got error: {terminated_result.error}" + ) assert terminated_result.run_id, "expected a non-empty new run id" - assert ( - terminated_result.run_id != original_run_id - ), "expected a new run id after the previous run was terminated" + assert terminated_result.run_id != original_run_id, ( + "expected a new run id after the previous run was terminated" + ) # Cleanup the freshly-started run. try: diff --git a/features/signalwithstart/id_reuse_reject_duplicate/feature.py b/features/signalwithstart/id_reuse_reject_duplicate/feature.py index 2a38e061..98372b6c 100644 --- a/features/signalwithstart/id_reuse_reject_duplicate/feature.py +++ b/features/signalwithstart/id_reuse_reject_duplicate/feature.py @@ -94,9 +94,9 @@ async def start(runner: Runner) -> WorkflowHandle: async def check_result(runner: Runner, handle: WorkflowHandle) -> None: result: SwsResult = await handle.result() assert result.error, "expected signal-with-start to fail with REJECT_DUPLICATE" - assert ( - "duplicate" in result.error.lower() or "already" in result.error.lower() - ), f"unexpected failure message: {result.error}" + assert "duplicate" in result.error.lower() or "already" in result.error.lower(), ( + f"unexpected failure message: {result.error}" + ) register_feature( diff --git a/features/signalwithstart/signal_existing_workflow/feature.py b/features/signalwithstart/signal_existing_workflow/feature.py index 1918a7f3..b280ea87 100644 --- a/features/signalwithstart/signal_existing_workflow/feature.py +++ b/features/signalwithstart/signal_existing_workflow/feature.py @@ -77,9 +77,9 @@ async def start(runner: Runner) -> WorkflowHandle: async def check_result(runner: Runner, handle: WorkflowHandle) -> None: result: SwsResult = await handle.result() - assert ( - result.run_id == _setup["original_run_id"] - ), f"expected existing run id {_setup['original_run_id']}, got {result.run_id}" + assert result.run_id == _setup["original_run_id"], ( + f"expected existing run id {_setup['original_run_id']}, got {result.run_id}" + ) register_feature( diff --git a/features/signalwithstart/signal_terminated_workflow/feature.py b/features/signalwithstart/signal_terminated_workflow/feature.py index 27db178a..5db81d7e 100644 --- a/features/signalwithstart/signal_terminated_workflow/feature.py +++ b/features/signalwithstart/signal_terminated_workflow/feature.py @@ -75,9 +75,9 @@ async def start(runner: Runner) -> WorkflowHandle: async def check_result(runner: Runner, handle: WorkflowHandle) -> None: result: SwsResult = await handle.result() assert result.run_id, "expected a non-empty run id" - assert ( - result.run_id != _setup["original_run_id"] - ), "expected a new run id after the previous run was terminated" + assert result.run_id != _setup["original_run_id"], ( + "expected a new run id after the previous run was terminated" + ) # Cleanup the freshly-started run. try: diff --git a/features/signalwithstart/start_delay/feature.py b/features/signalwithstart/start_delay/feature.py index adef17dc..61610a3c 100644 --- a/features/signalwithstart/start_delay/feature.py +++ b/features/signalwithstart/start_delay/feature.py @@ -66,13 +66,11 @@ async def check_result(runner: Runner, handle: WorkflowHandle) -> None: # After the start delay elapses the target begins, receives the buffered # signal, and completes returning the signal value. - target = runner.client.get_workflow_handle( - result.workflow_id, run_id=result.run_id - ) + target = runner.client.get_workflow_handle(result.workflow_id, run_id=result.run_id) target_result = await target.result() - assert ( - target_result == SIGNAL_VALUE - ), f"expected target to return {SIGNAL_VALUE!r}, got {target_result!r}" + assert target_result == SIGNAL_VALUE, ( + f"expected target to return {SIGNAL_VALUE!r}, got {target_result!r}" + ) register_feature( From f9b3df56e70ea10e15aa90dbb2659ac58f2a8abd Mon Sep 17 00:00:00 2001 From: Sean Kane Date: Wed, 1 Jul 2026 11:11:46 -0600 Subject: [PATCH 6/9] simplify READMEs --- features/signalwithstart/both_workflows_visible/README.md | 4 ++-- features/signalwithstart/happy_path/README.md | 3 +-- features/signalwithstart/id_conflict_fail/README.md | 4 ++-- .../signalwithstart/id_conflict_terminate_existing/README.md | 4 ++-- features/signalwithstart/id_conflict_use_existing/README.md | 4 ++-- features/signalwithstart/id_reuse_allow_duplicate/README.md | 4 ++-- .../id_reuse_allow_duplicate_failed_only/README.md | 4 ++-- features/signalwithstart/id_reuse_reject_duplicate/README.md | 4 ++-- features/signalwithstart/signal_existing_workflow/README.md | 4 ++-- features/signalwithstart/signal_terminated_workflow/README.md | 4 ++-- features/signalwithstart/start_delay/README.md | 4 ++-- 11 files changed, 21 insertions(+), 22 deletions(-) diff --git a/features/signalwithstart/both_workflows_visible/README.md b/features/signalwithstart/both_workflows_visible/README.md index 79522427..9a98393d 100644 --- a/features/signalwithstart/both_workflows_visible/README.md +++ b/features/signalwithstart/both_workflows_visible/README.md @@ -11,5 +11,5 @@ signal value; and the memo passed in the request is visible on the target. ## Server requirements Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, -`history.enableChasm=true`, and Nexus enabled (`system.enableNexus=true`) with the -built-in `__temporal_system` endpoint. +`history.enableChasm=true`, and the built-in `__temporal_system` Nexus +endpoint. diff --git a/features/signalwithstart/happy_path/README.md b/features/signalwithstart/happy_path/README.md index 932d92e3..c2d0bba6 100644 --- a/features/signalwithstart/happy_path/README.md +++ b/features/signalwithstart/happy_path/README.md @@ -15,5 +15,4 @@ This feature requires a server with namespace dynamic config: - `history.enableSignalWithStartFromWorkflow=true` - `history.enableChasm=true` (default true) -- Nexus enabled (`system.enableNexus=true`) with the built-in `__temporal_system` - endpoint available. +- The built-in `__temporal_system` Nexus endpoint available. diff --git a/features/signalwithstart/id_conflict_fail/README.md b/features/signalwithstart/id_conflict_fail/README.md index f75d653c..4e2f42a0 100644 --- a/features/signalwithstart/id_conflict_fail/README.md +++ b/features/signalwithstart/id_conflict_fail/README.md @@ -11,5 +11,5 @@ indicates the conflict policy is not supported. ## Server requirements Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, -`history.enableChasm=true`, and Nexus enabled (`system.enableNexus=true`) with the -built-in `__temporal_system` endpoint. +`history.enableChasm=true`, and the built-in `__temporal_system` Nexus +endpoint. diff --git a/features/signalwithstart/id_conflict_terminate_existing/README.md b/features/signalwithstart/id_conflict_terminate_existing/README.md index 1bfac54a..0135668e 100644 --- a/features/signalwithstart/id_conflict_terminate_existing/README.md +++ b/features/signalwithstart/id_conflict_terminate_existing/README.md @@ -10,5 +10,5 @@ status is TERMINATED. ## Server requirements Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, -`history.enableChasm=true`, and Nexus enabled (`system.enableNexus=true`) with the -built-in `__temporal_system` endpoint. +`history.enableChasm=true`, and the built-in `__temporal_system` Nexus +endpoint. diff --git a/features/signalwithstart/id_conflict_use_existing/README.md b/features/signalwithstart/id_conflict_use_existing/README.md index 9efa88d5..cfd52272 100644 --- a/features/signalwithstart/id_conflict_use_existing/README.md +++ b/features/signalwithstart/id_conflict_use_existing/README.md @@ -9,5 +9,5 @@ Verifies: the returned run id equals the already-running target's run id. ## Server requirements Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, -`history.enableChasm=true`, and Nexus enabled (`system.enableNexus=true`) with the -built-in `__temporal_system` endpoint. +`history.enableChasm=true`, and the built-in `__temporal_system` Nexus +endpoint. diff --git a/features/signalwithstart/id_reuse_allow_duplicate/README.md b/features/signalwithstart/id_reuse_allow_duplicate/README.md index 628addf1..249b3bcb 100644 --- a/features/signalwithstart/id_reuse_allow_duplicate/README.md +++ b/features/signalwithstart/id_reuse_allow_duplicate/README.md @@ -8,5 +8,5 @@ Verifies: the returned run id differs from the prior completed run's run id. ## Server requirements Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, -`history.enableChasm=true`, and Nexus enabled (`system.enableNexus=true`) with the -built-in `__temporal_system` endpoint. +`history.enableChasm=true`, and the built-in `__temporal_system` Nexus +endpoint. diff --git a/features/signalwithstart/id_reuse_allow_duplicate_failed_only/README.md b/features/signalwithstart/id_reuse_allow_duplicate_failed_only/README.md index e979da5c..57c7e391 100644 --- a/features/signalwithstart/id_reuse_allow_duplicate_failed_only/README.md +++ b/features/signalwithstart/id_reuse_allow_duplicate_failed_only/README.md @@ -10,6 +10,6 @@ Verifies: case 1 fails; case 2 starts a new run with a different run id. ## Server requirements Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, -`history.enableChasm=true`, and Nexus enabled (`system.enableNexus=true`) with the -built-in `__temporal_system` endpoint. Also set +`history.enableChasm=true`, and the built-in `__temporal_system` Nexus +endpoint. Also set `system.workflowIdReuseMinimalInterval=0` to avoid reuse-interval throttling. diff --git a/features/signalwithstart/id_reuse_reject_duplicate/README.md b/features/signalwithstart/id_reuse_reject_duplicate/README.md index 57c79eee..cc745e98 100644 --- a/features/signalwithstart/id_reuse_reject_duplicate/README.md +++ b/features/signalwithstart/id_reuse_reject_duplicate/README.md @@ -9,6 +9,6 @@ whose cause message indicates a duplicate/already-started workflow). ## Server requirements Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, -`history.enableChasm=true`, and Nexus enabled (`system.enableNexus=true`) with the -built-in `__temporal_system` endpoint. Also set +`history.enableChasm=true`, and the built-in `__temporal_system` Nexus +endpoint. Also set `system.workflowIdReuseMinimalInterval=0` to avoid reuse-interval throttling. diff --git a/features/signalwithstart/signal_existing_workflow/README.md b/features/signalwithstart/signal_existing_workflow/README.md index 856d3a4c..1c717c18 100644 --- a/features/signalwithstart/signal_existing_workflow/README.md +++ b/features/signalwithstart/signal_existing_workflow/README.md @@ -9,5 +9,5 @@ execution was started). ## Server requirements Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, -`history.enableChasm=true`, and Nexus enabled (`system.enableNexus=true`) with the -built-in `__temporal_system` endpoint. +`history.enableChasm=true`, and the built-in `__temporal_system` Nexus +endpoint. diff --git a/features/signalwithstart/signal_terminated_workflow/README.md b/features/signalwithstart/signal_terminated_workflow/README.md index 9267cdce..23c6a317 100644 --- a/features/signalwithstart/signal_terminated_workflow/README.md +++ b/features/signalwithstart/signal_terminated_workflow/README.md @@ -9,5 +9,5 @@ execution was started). ## Server requirements Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, -`history.enableChasm=true`, and Nexus enabled (`system.enableNexus=true`) with the -built-in `__temporal_system` endpoint. +`history.enableChasm=true`, and the built-in `__temporal_system` Nexus +endpoint. diff --git a/features/signalwithstart/start_delay/README.md b/features/signalwithstart/start_delay/README.md index 61e28d08..37efb424 100644 --- a/features/signalwithstart/start_delay/README.md +++ b/features/signalwithstart/start_delay/README.md @@ -9,5 +9,5 @@ the buffered signal, and completes returning the signal value. ## Server requirements Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, -`history.enableChasm=true`, and Nexus enabled (`system.enableNexus=true`) with the -built-in `__temporal_system` endpoint. +`history.enableChasm=true`, and the built-in `__temporal_system` Nexus +endpoint. From ad5945d0e8afd0b638fedbe2d4f7093f2330e759 Mon Sep 17 00:00:00 2001 From: Sean Kane Date: Mon, 13 Jul 2026 16:47:34 -0600 Subject: [PATCH 7/9] remove cli dep on special release --- cmd/run.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/cmd/run.go b/cmd/run.go index 67a97a2f..7bd3898a 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -436,13 +436,6 @@ func (r *Runner) runBatch(ctx context.Context, batch runBatch) error { LogLevel: "error", ClientOptions: &client.Options{Namespace: config.Namespace}, ExtraArgs: dynamicConfigArgs, - // Pin a CLI build that registers the __temporal_system Nexus - // WorkflowService, required by the signalwithstart features (signal - // with start from a workflow). The default SDK-bundled dev server is - // too old. Remove this once the capability ships in a stock release. - CachedDownload: testsuite.CachedDownload{ - Version: "v1.7.1-system-nexus-operations", - }, }) if err != nil { return fmt.Errorf("failed starting devserver: %w", err) From f1030f70f01a145476a41f4ad99ab0fad5683769 Mon Sep 17 00:00:00 2001 From: Sean Kane Date: Mon, 13 Jul 2026 17:09:18 -0600 Subject: [PATCH 8/9] trimming --- .../both_workflows_visible/README.md | 10 +-- features/signalwithstart/happy_path/README.md | 18 ---- .../signalwithstart/happy_path/feature.py | 84 ----------------- .../id_conflict_fail/README.md | 5 +- .../id_conflict_terminate_existing/README.md | 5 +- .../id_conflict_use_existing/README.md | 5 +- .../id_reuse_allow_duplicate/README.md | 5 +- .../README.md | 5 +- .../id_reuse_reject_duplicate/README.md | 5 +- .../signal_existing_workflow/README.md | 13 --- .../signal_existing_workflow/feature.py | 89 ------------------- .../signal_terminated_workflow/README.md | 5 +- .../signalwithstart/start_delay/README.md | 5 +- 13 files changed, 20 insertions(+), 234 deletions(-) delete mode 100644 features/signalwithstart/happy_path/README.md delete mode 100644 features/signalwithstart/happy_path/feature.py delete mode 100644 features/signalwithstart/signal_existing_workflow/README.md delete mode 100644 features/signalwithstart/signal_existing_workflow/feature.py diff --git a/features/signalwithstart/both_workflows_visible/README.md b/features/signalwithstart/both_workflows_visible/README.md index 9a98393d..a1b4cecd 100644 --- a/features/signalwithstart/both_workflows_visible/README.md +++ b/features/signalwithstart/both_workflows_visible/README.md @@ -2,14 +2,12 @@ A workflow calls `workflow.signal_with_start_workflow(...)` passing a workflow input argument, a signal argument, and a memo. This exercises the full -payload-serialization path through the `__temporal_system` Nexus endpoint -(covering what the proto-binary server test verifies, since Python encodes these -payloads automatically). +payload-serialization path (covering what the proto-binary server test verifies, +since Python encodes these payloads automatically). Verifies: both the caller and the started target complete; the target returns the signal value; and the memo passed in the request is visible on the target. ## Server requirements -Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, -`history.enableChasm=true`, and the built-in `__temporal_system` Nexus -endpoint. +Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true` and +`history.enableChasm=true`. diff --git a/features/signalwithstart/happy_path/README.md b/features/signalwithstart/happy_path/README.md deleted file mode 100644 index c2d0bba6..00000000 --- a/features/signalwithstart/happy_path/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# Signal-with-start from a workflow: happy path - -A workflow calls `workflow.signal_with_start_workflow(...)` (routed through the -`__temporal_system` Nexus endpoint) to start a brand-new target workflow and -deliver a signal to it in one operation. The caller returns the started target's -run id; the target completes after receiving the signal and returns the signal -value. - -Verifies: the operation starts a new execution (non-empty run id) and the signal -is delivered (target returns the signal value). - -## Server requirements - -This feature requires a server with namespace dynamic config: - -- `history.enableSignalWithStartFromWorkflow=true` -- `history.enableChasm=true` (default true) -- The built-in `__temporal_system` Nexus endpoint available. diff --git a/features/signalwithstart/happy_path/feature.py b/features/signalwithstart/happy_path/feature.py deleted file mode 100644 index e5199448..00000000 --- a/features/signalwithstart/happy_path/feature.py +++ /dev/null @@ -1,84 +0,0 @@ -import uuid -from dataclasses import dataclass -from datetime import timedelta -from typing import Optional - -from temporalio import workflow -from temporalio.client import WorkflowHandle - -from harness.python.feature import Runner, register_feature - -SIGNAL_VALUE = "test-signal-value" - - -@dataclass -class SwsResult: - """What the caller workflow returns: the identity of the workflow that the - signal-with-start operation started (or signaled).""" - - workflow_id: str - run_id: str - - -@workflow.defn -class TargetWorkflow: - def __init__(self) -> None: - self._signal_value: Optional[str] = None - - @workflow.run - async def run(self) -> str: - await workflow.wait_condition(lambda: self._signal_value is not None) - assert self._signal_value is not None - return self._signal_value - - @workflow.signal - def my_signal(self, value: str) -> None: - self._signal_value = value - - -@workflow.defn -class CallerWorkflow: - @workflow.run - async def run(self, target_id: str) -> SwsResult: - # Signal-with-start the target from inside this workflow. With no - # existing workflow for target_id this starts a brand-new execution and - # delivers the signal to it. - handle = await workflow.signal_with_start_workflow( - TargetWorkflow.run, - id=target_id, - task_queue=workflow.info().task_queue, - signal=TargetWorkflow.my_signal, - signal_args=SIGNAL_VALUE, - ) - return SwsResult(workflow_id=handle.id, run_id=handle.run_id or "") - - -async def start(runner: Runner) -> WorkflowHandle: - target_id = f"signalwithstart-happy-path-target-{uuid.uuid4()}" - return await runner.client.start_workflow( - CallerWorkflow.run, - target_id, - id=f"signalwithstart-happy-path-caller-{uuid.uuid4()}", - task_queue=runner.task_queue, - execution_timeout=timedelta(minutes=1), - ) - - -async def check_result(runner: Runner, handle: WorkflowHandle) -> None: - result: SwsResult = await handle.result() - assert result.run_id, "expected a non-empty run id for the started target workflow" - - # The signal-with-start created a new target execution; it completes after - # receiving the signal and returns the signal value. - target = runner.client.get_workflow_handle(result.workflow_id, run_id=result.run_id) - target_result = await target.result() - assert target_result == SIGNAL_VALUE, ( - f"expected target to return {SIGNAL_VALUE!r}, got {target_result!r}" - ) - - -register_feature( - workflows=[CallerWorkflow, TargetWorkflow], - start=start, - check_result=check_result, -) diff --git a/features/signalwithstart/id_conflict_fail/README.md b/features/signalwithstart/id_conflict_fail/README.md index 4e2f42a0..e2a960a5 100644 --- a/features/signalwithstart/id_conflict_fail/README.md +++ b/features/signalwithstart/id_conflict_fail/README.md @@ -10,6 +10,5 @@ Verifies: the caller's history contains a workflow task failure whose message indicates the conflict policy is not supported. ## Server requirements -Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, -`history.enableChasm=true`, and the built-in `__temporal_system` Nexus -endpoint. +Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true` and +`history.enableChasm=true`. diff --git a/features/signalwithstart/id_conflict_terminate_existing/README.md b/features/signalwithstart/id_conflict_terminate_existing/README.md index 0135668e..b6f41cc4 100644 --- a/features/signalwithstart/id_conflict_terminate_existing/README.md +++ b/features/signalwithstart/id_conflict_terminate_existing/README.md @@ -9,6 +9,5 @@ Verifies: the returned run id differs from the original, and the original run's status is TERMINATED. ## Server requirements -Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, -`history.enableChasm=true`, and the built-in `__temporal_system` Nexus -endpoint. +Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true` and +`history.enableChasm=true`. diff --git a/features/signalwithstart/id_conflict_use_existing/README.md b/features/signalwithstart/id_conflict_use_existing/README.md index cfd52272..9e3c5994 100644 --- a/features/signalwithstart/id_conflict_use_existing/README.md +++ b/features/signalwithstart/id_conflict_use_existing/README.md @@ -8,6 +8,5 @@ returned; no new run is started. Verifies: the returned run id equals the already-running target's run id. ## Server requirements -Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, -`history.enableChasm=true`, and the built-in `__temporal_system` Nexus -endpoint. +Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true` and +`history.enableChasm=true`. diff --git a/features/signalwithstart/id_reuse_allow_duplicate/README.md b/features/signalwithstart/id_reuse_allow_duplicate/README.md index 249b3bcb..dc112f0f 100644 --- a/features/signalwithstart/id_reuse_allow_duplicate/README.md +++ b/features/signalwithstart/id_reuse_allow_duplicate/README.md @@ -7,6 +7,5 @@ Because duplicates are allowed for closed runs, a new execution is started. Verifies: the returned run id differs from the prior completed run's run id. ## Server requirements -Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, -`history.enableChasm=true`, and the built-in `__temporal_system` Nexus -endpoint. +Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true` and +`history.enableChasm=true`. diff --git a/features/signalwithstart/id_reuse_allow_duplicate_failed_only/README.md b/features/signalwithstart/id_reuse_allow_duplicate_failed_only/README.md index 57c7e391..462fecea 100644 --- a/features/signalwithstart/id_reuse_allow_duplicate_failed_only/README.md +++ b/features/signalwithstart/id_reuse_allow_duplicate_failed_only/README.md @@ -9,7 +9,6 @@ from a workflow via `workflow.signal_with_start_workflow(...)`: Verifies: case 1 fails; case 2 starts a new run with a different run id. ## Server requirements -Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, -`history.enableChasm=true`, and the built-in `__temporal_system` Nexus -endpoint. Also set +Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true` and +`history.enableChasm=true`. Also set `system.workflowIdReuseMinimalInterval=0` to avoid reuse-interval throttling. diff --git a/features/signalwithstart/id_reuse_reject_duplicate/README.md b/features/signalwithstart/id_reuse_reject_duplicate/README.md index cc745e98..4c93d49e 100644 --- a/features/signalwithstart/id_reuse_reject_duplicate/README.md +++ b/features/signalwithstart/id_reuse_reject_duplicate/README.md @@ -8,7 +8,6 @@ Verifies: the signal-with-start operation fails (the caller captures the failure whose cause message indicates a duplicate/already-started workflow). ## Server requirements -Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, -`history.enableChasm=true`, and the built-in `__temporal_system` Nexus -endpoint. Also set +Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true` and +`history.enableChasm=true`. Also set `system.workflowIdReuseMinimalInterval=0` to avoid reuse-interval throttling. diff --git a/features/signalwithstart/signal_existing_workflow/README.md b/features/signalwithstart/signal_existing_workflow/README.md deleted file mode 100644 index 1c717c18..00000000 --- a/features/signalwithstart/signal_existing_workflow/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Signal-with-start from a workflow: signal an existing run - -A target workflow is already running. A workflow then calls -`workflow.signal_with_start_workflow(...)` for the same workflow id. Because a run -already exists, the operation signals it rather than starting a new one. - -Verifies: the returned run id equals the already-running target's run id (no new -execution was started). - -## Server requirements -Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, -`history.enableChasm=true`, and the built-in `__temporal_system` Nexus -endpoint. diff --git a/features/signalwithstart/signal_existing_workflow/feature.py b/features/signalwithstart/signal_existing_workflow/feature.py deleted file mode 100644 index b280ea87..00000000 --- a/features/signalwithstart/signal_existing_workflow/feature.py +++ /dev/null @@ -1,89 +0,0 @@ -import uuid -from dataclasses import dataclass -from datetime import timedelta -from typing import Optional - -from temporalio import workflow -from temporalio.client import WorkflowHandle -from temporalio.common import WorkflowIDReusePolicy - -from harness.python.feature import Runner, register_feature - -SIGNAL_VALUE = "test-signal-value" - -# Cross-function state shared between start() and check_result() (both run on the -# host, sequentially, in this process). -_setup: dict = {} - - -@dataclass -class SwsResult: - workflow_id: str - run_id: str - - -@workflow.defn -class TargetWorkflow: - def __init__(self) -> None: - self._signal_value: Optional[str] = None - - @workflow.run - async def run(self) -> str: - await workflow.wait_condition(lambda: self._signal_value is not None) - assert self._signal_value is not None - return self._signal_value - - @workflow.signal - def my_signal(self, value: str) -> None: - self._signal_value = value - - -@workflow.defn -class CallerWorkflow: - @workflow.run - async def run(self, target_id: str) -> SwsResult: - handle = await workflow.signal_with_start_workflow( - TargetWorkflow.run, - id=target_id, - task_queue=workflow.info().task_queue, - signal=TargetWorkflow.my_signal, - signal_args=SIGNAL_VALUE, - id_reuse_policy=WorkflowIDReusePolicy.ALLOW_DUPLICATE, - ) - return SwsResult(workflow_id=handle.id, run_id=handle.run_id or "") - - -async def start(runner: Runner) -> WorkflowHandle: - target_id = f"signalwithstart-signal-existing-target-{uuid.uuid4()}" - - # Start the target and leave it running; signal-with-start should signal this - # existing run rather than start a new one. - target = await runner.client.start_workflow( - TargetWorkflow.run, - id=target_id, - task_queue=runner.task_queue, - execution_timeout=timedelta(minutes=1), - ) - _setup["original_run_id"] = target.result_run_id - - return await runner.client.start_workflow( - CallerWorkflow.run, - target_id, - id=f"signalwithstart-signal-existing-caller-{uuid.uuid4()}", - task_queue=runner.task_queue, - execution_timeout=timedelta(minutes=1), - ) - - -async def check_result(runner: Runner, handle: WorkflowHandle) -> None: - result: SwsResult = await handle.result() - assert result.run_id == _setup["original_run_id"], ( - f"expected existing run id {_setup['original_run_id']}, got {result.run_id}" - ) - - -register_feature( - workflows=[CallerWorkflow, TargetWorkflow], - start=start, - check_result=check_result, -) diff --git a/features/signalwithstart/signal_terminated_workflow/README.md b/features/signalwithstart/signal_terminated_workflow/README.md index 23c6a317..de4514d0 100644 --- a/features/signalwithstart/signal_terminated_workflow/README.md +++ b/features/signalwithstart/signal_terminated_workflow/README.md @@ -8,6 +8,5 @@ Verifies: the returned run id differs from the terminated run's run id (a new execution was started). ## Server requirements -Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, -`history.enableChasm=true`, and the built-in `__temporal_system` Nexus -endpoint. +Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true` and +`history.enableChasm=true`. diff --git a/features/signalwithstart/start_delay/README.md b/features/signalwithstart/start_delay/README.md index 37efb424..cfa7e293 100644 --- a/features/signalwithstart/start_delay/README.md +++ b/features/signalwithstart/start_delay/README.md @@ -8,6 +8,5 @@ Verifies: a run id is returned, and after the delay the target starts, receives the buffered signal, and completes returning the signal value. ## Server requirements -Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true`, -`history.enableChasm=true`, and the built-in `__temporal_system` Nexus -endpoint. +Namespace dynamic config `history.enableSignalWithStartFromWorkflow=true` and +`history.enableChasm=true`. From 0aaec9d08136d56be8a7e51b0d91c3b5d92d6a50 Mon Sep 17 00:00:00 2001 From: Sean Kane Date: Tue, 14 Jul 2026 12:21:02 -0600 Subject: [PATCH 9/9] bump go version in Dockerfile --- dockerfiles/go.Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dockerfiles/go.Dockerfile b/dockerfiles/go.Dockerfile index ac697207..4e40bd9e 100644 --- a/dockerfiles/go.Dockerfile +++ b/dockerfiles/go.Dockerfile @@ -1,5 +1,5 @@ # Build in a full featured container -FROM golang:1.24 as build +FROM golang:1.25 AS build WORKDIR /app