fix(wac): return the checkpointed value from step(), not the live object (#10367)

* fix(wac): return the checkpointed value from step(), not the live object

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(wac): regenerate system prompts and narrow the round-trip claim

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style(wac): condense the round-trip comments and fix the fallback note

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(sdk): type step() as the JSON round trip of its body's result

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(sdk): apply the JSON round trip to task() and the standalone paths

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sdk): encode bigint, keep unknown as unknown, align dropped-key results

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(wac): null out results whose key JSON.stringify would drop

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(wac): normalize only the top-level result, keeping nested keys as they were

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(wac): normalize a child task's result so a deployed job cannot fail to parse

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(sdk): pin non-finite number behavior in Jsonified and its tests

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sdk): admit undefined for keys whose value JSON.stringify may omit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sdk): make a key JSON.stringify may omit optional, not just nullable

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sdk): treat a class-valued property as dropped, like any other function

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-07-28 09:46:23 +02:00
committed by GitHub
parent b0c7e09173
commit 044ce39e5f
17 changed files with 695 additions and 65 deletions
+86 -21
View File
@@ -1,11 +1,47 @@
"""Tests for the Workflow-as-Code SDK."""
import asyncio
import json
import pytest
from datetime import datetime, timezone
from wmill.client import WorkflowCtx, _StepSuspend, TaskError, workflow, task, step, sleep, parallel, wait_for_approval, _run_workflow, _run_workflow_async
class _StubInlineClient:
"""Stands in for the httpx client the inline fast path POSTs with.
Decodes each request body, so a test sees exactly the JSON that reaches
``/jobs/wac/inline_checkpoint`` — and therefore what a replay reads back.
"""
def __init__(self):
self.posted = []
async def post(self, url, content=None):
self.posted.append(json.loads(content))
class _Response:
def raise_for_status(self):
pass
return _Response()
async def aclose(self):
pass
def _set_inline_fast_path_env(monkeypatch):
for var, val in (
("WM_JOB_ID", "job-1"),
("WM_WORKSPACE", "admins"),
("BASE_INTERNAL_URL", "http://localhost:8000"),
("WM_TOKEN", "tok"),
):
monkeypatch.setenv(var, val)
@task
async def extract_data(url: str):
pass # body unused in workflow context
@@ -974,31 +1010,14 @@ class TestRaisingInlineStepIsCheckpointed:
the same ``TaskError`` a replay rebuilds from the marker — raising the
original ``ValueError`` here would make ``except ValueError:`` catch on
this run and miss on the next one."""
for var, val in (
("WM_JOB_ID", "job-1"),
("WM_WORKSPACE", "admins"),
("BASE_INTERNAL_URL", "http://localhost:8000"),
("WM_TOKEN", "tok"),
):
monkeypatch.setenv(var, val)
_set_inline_fast_path_env(monkeypatch)
posted = []
class _StubResponse:
def raise_for_status(self):
pass
class _StubClient:
async def post(self, url, json=None):
posted.append(json)
return _StubResponse()
async def aclose(self):
pass
stub = _StubInlineClient()
posted = stub.posted
async def run():
ctx = WorkflowCtx({})
ctx._inline_http_client = _StubClient()
ctx._inline_http_client = stub
with pytest.raises(TaskError, match="boom") as live:
await ctx._run_inline_step("risky", self._boom)
# ...and the replay of that very checkpoint raises the same thing.
@@ -1031,6 +1050,52 @@ class TestRaisingInlineStepIsCheckpointed:
assert r["result"] == 10
class TestInlineStepRoundParity:
"""The round that runs a ``step()`` body must see what a replay sees.
The fast path returns the value it checkpointed, not the in-memory one:
a workflow branching on a datetime attribute or a tuple would otherwise
take one path on the round that ran the body and another on every replay,
which can change which tasks get dispatched, not just crash later.
"""
CASES = [
("dt", lambda: datetime(2026, 1, 1, tzinfo=timezone.utc), "2026-01-01 00:00:00+00:00"),
("pair", lambda: (1, 2), [1, 2]),
("intkeys", lambda: {1: "a"}, {"1": "a"}),
]
def test_outside_a_workflow_returns_the_same_shape(self):
"""No checkpoint, no replay — but a local run must not hand back a shape
a deployed one never produces, or testing a workflow locally proves
nothing. The async task path is the sharp edge: the wrapper is sync, so
the value has to be round-tripped after the await, not before."""
@task
async def make_pair():
return (1, datetime(2026, 1, 1, tzinfo=timezone.utc))
assert asyncio.run(step("pair", lambda: (1, 2))) == [1, 2]
assert asyncio.run(make_pair()) == [1, "2026-01-01 00:00:00+00:00"]
def test_live_round_matches_checkpoint_and_replay(self, monkeypatch):
_set_inline_fast_path_env(monkeypatch)
async def run():
for key, fn, expected in self.CASES:
stub = _StubInlineClient()
ctx = WorkflowCtx({})
ctx._inline_http_client = stub
live = await ctx._run_inline_step(key, fn)
checkpointed = stub.posted[0]["result"]
assert checkpointed == expected
assert live == expected and type(live) is type(expected)
replayed = WorkflowCtx({"completed_steps": {key: checkpointed}})
assert await replayed._run_inline_step(key, fn) == live
asyncio.run(run())
# =====================================================================
# TASK OPTIONS TESTS
# =====================================================================