Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions TEST-SCENARIOS.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def hook(mapping_file, order=1):
"flow": "sys-flows",
"flowVersion": "1.0.0",
"domain": "core",
"version": "1.0.0",
"version": "1.0.1",
"tags": [
"integration-test",
"schedule-after-auto",
Expand Down Expand Up @@ -105,6 +105,9 @@ def hook(mapping_file, order=1):
"labels": label("Gate Timeout (8s)"),
"timer": timer("GateTimeoutTimer.csx"),
"onExecutionTasks": [hook("TimeoutMapping.csx")],
# Surfaced on the state function's kind:"scheduled" entry (runtime resolves it
# from the job's source state); the test reads it back.
"annotations": {"ui/countdown": "gate-timeout"},
},
],
},
Expand Down
9 changes: 6 additions & 3 deletions core/Workflows/schedule-after-auto/schedule-after-auto.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"flow": "sys-flows",
"flowVersion": "1.0.0",
"domain": "core",
"version": "1.0.0",
"version": "1.0.1",
"tags": [
"integration-test",
"schedule-after-auto",
Expand Down Expand Up @@ -128,7 +128,10 @@
"code": "dXNpbmcgU3lzdGVtLkNvbGxlY3Rpb25zLkdlbmVyaWM7CnVzaW5nIFN5c3RlbS5EeW5hbWljOwp1c2luZyBTeXN0ZW0uVGhyZWFkaW5nLlRhc2tzOwp1c2luZyBCQlQuV29ya2Zsb3cuRGVmaW5pdGlvbnM7CnVzaW5nIEJCVC5Xb3JrZmxvdy5TY3JpcHRpbmc7CgovLy8gPHN1bW1hcnk+Ci8vLyBvbkV4ZWN1dGUgb2YgdGhlIGdhdGUncyBzY2hlZHVsZWQgdHJhbnNpdGlvbi4gSXRzIG9ubHkgam9iIGlzIHRvIGxlYXZlIHByb29mIGluIGluc3RhbmNlIGRhdGEKLy8vIHRoYXQgdGhlIHRpbWVyIHJlYWxseSBmaXJlZCDigJQgdGhlIHN0YXRlIGFsb25lIHdvdWxkIG5vdCBkaXN0aW5ndWlzaCAidGhlIHRpbWVyIGZpcmVkIiBmcm9tCi8vLyAic29tZXRoaW5nIGVsc2UgbW92ZWQgdGhlIGluc3RhbmNlIi4KLy8vIDwvc3VtbWFyeT4KcHVibGljIGNsYXNzIFRpbWVvdXRNYXBwaW5nIDogU2NyaXB0QmFzZSwgSU1hcHBpbmcKewogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IElucHV0SGFuZGxlcihXb3JrZmxvd1Rhc2sgdGFzaywgU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgewogICAgICAgIHJldHVybiBUYXNrLkZyb21SZXN1bHQobmV3IFNjcmlwdFJlc3BvbnNlKCkpOwogICAgfQoKICAgIHB1YmxpYyBUYXNrPFNjcmlwdFJlc3BvbnNlPiBPdXRwdXRIYW5kbGVyKFNjcmlwdENvbnRleHQgY29udGV4dCkKICAgIHsKICAgICAgICB2YXIgZGF0YSA9IGNvbnRleHQuSW5zdGFuY2UuRGF0YSBhcyBJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD47CgogICAgICAgIHZhciBjdXJyZW50ID0gMDsKICAgICAgICBpZiAoZGF0YSAhPSBudWxsICYmIGRhdGEuVHJ5R2V0VmFsdWUoInRpbWVvdXRGaXJlZCIsIG91dCB2YXIgcmF3KSAmJiByYXcgIT0gbnVsbCkKICAgICAgICB7CiAgICAgICAgICAgIGludC5UcnlQYXJzZShyYXcuVG9TdHJpbmcoKSwgb3V0IGN1cnJlbnQpOwogICAgICAgIH0KCiAgICAgICAgZHluYW1pYyByZXN1bHQgPSBuZXcgRXhwYW5kb09iamVjdCgpOwogICAgICAgIHZhciB0YXJnZXQgPSAoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+KXJlc3VsdDsKICAgICAgICB0YXJnZXRbInRpbWVvdXRGaXJlZCJdID0gY3VycmVudCArIDE7CgogICAgICAgIExvZ0luZm9ybWF0aW9uKCQiVGltZW91dE1hcHBpbmc6IHRpbWVvdXRGaXJlZCB7Y3VycmVudH0gLT4ge2N1cnJlbnQgKyAxfSIpOwogICAgICAgIHJldHVybiBUYXNrLkZyb21SZXN1bHQobmV3IFNjcmlwdFJlc3BvbnNlIHsgRGF0YSA9IHJlc3VsdCB9KTsKICAgIH0KfQo="
}
}
]
],
"annotations": {
"ui/countdown": "gate-timeout"
}
}
]
},
Expand Down Expand Up @@ -228,4 +231,4 @@
]
}
}
}
}
5 changes: 4 additions & 1 deletion core/Workflows/timeout-lab/timeout-lab-parent.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"flow": "sys-flows",
"flowVersion": "1.0.0",
"domain": "core",
"version": "1.0.0",
"version": "1.0.1",
"tags": [
"integration-test",
"timeout-lab",
Expand Down Expand Up @@ -100,6 +100,9 @@
"timer": {
"reset": "never",
"duration": "PT20S"
},
"annotations": {
"ui/countdown": "parent-override"
}
}
}
Expand Down
69 changes: 64 additions & 5 deletions core/Workflows/timeout-lab/timeout-lab-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
"flow": "sys-flows",
"flowVersion": "1.0.0",
"domain": "core",
"version": "1.0.0",
"version": "1.0.1",
"tags": [
"integration-test",
"timeout-lab",
"workflow-timeout",
"state-function"
],
"attributes": {
"_comment": "A workflow-level timeout short enough to fire inside a test run. Nothing else in this repo could exercise one: the only two authored timeouts are PT15M. The instance parks in root-waiting doing nothing, so the deadline is the only thing that moves it — which is what lets a test assert both halves of the contract in one run: the state function's `timeout` block while it is pending, and the instance actually landing on `target` when it fires. NOTE the duration is an absolute budget from START, not an idle window: timer.reset is required by the schema and read nowhere in the runtime.",
"_comment": "A workflow-level timeout short enough to fire inside a test run. Nothing else in this repo could exercise one: the only two authored timeouts are PT15M. The instance parks in root-waiting doing nothing, so the deadline is the only thing that moves it — which is what lets a test assert both halves of the contract in one run: the state function's `timeout` block while it is pending, and the instance actually landing on `target` when it fires. NOTE the duration is an absolute budget from START, not an idle window: timer.reset is required by the schema and read nowhere in the runtime. ANNOTATIONS (1.0.1): every entry the state body lists carries a distinct `ui/source` annotation — state (root-finish), shared (root-note), cancel, exit, updateData — and the timeout carries `ui/countdown`, so one read asserts that each kind and the `timeout` block pass their definition's annotations through. None of these transitions is ever called; the timeout still does the moving.",
"type": "F",
"timeout": {
"key": "root-abandoned",
Expand All @@ -20,6 +20,9 @@
"timer": {
"reset": "never",
"duration": "PT20S"
},
"annotations": {
"ui/countdown": "root-deadline"
}
},
"labels": [
Expand Down Expand Up @@ -71,7 +74,10 @@
"language": "en-US",
"label": "Finish before the deadline"
}
]
],
"annotations": {
"ui/source": "state"
}
}
]
},
Expand Down Expand Up @@ -137,7 +143,60 @@
"language": "en-US",
"label": "Cancel Timeout Lab Root"
}
]
}
],
"annotations": {
"ui/source": "cancel"
}
},
"exit": {
"key": "exit-timeout-lab-root",
"target": "root-completed",
"triggerType": 0,
"versionStrategy": "Major",
"labels": [
{
"language": "en-US",
"label": "Exit Timeout Lab Root"
}
],
"annotations": {
"ui/source": "exit"
}
},
"updateData": {
"key": "update-timeout-lab-root",
"target": "$self",
"triggerType": 0,
"versionStrategy": "Major",
"labels": [
{
"language": "en-US",
"label": "Update Timeout Lab Root Data"
}
],
"annotations": {
"ui/source": "updateData"
}
},
"sharedTransitions": [
{
"key": "root-note",
"target": "$self",
"triggerType": 0,
"versionStrategy": "Minor",
"labels": [
{
"language": "en-US",
"label": "Add a note (shared)"
}
],
"availableIn": [
"root-waiting"
],
"annotations": {
"ui/source": "shared"
}
}
]
}
}
7 changes: 5 additions & 2 deletions tests/Core.IntegrationTests/Tests/ScheduleAfterAuto/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,13 @@ MockLab gerekmez — akış yalnız script task (type 7) kullanır, HTTP task yo
| Test | İddia |
| --- | --- |
| `AutoWinner_SuppressesTimerArming_AndTheTimerNeverFires` | `mode: "auto"` ile başlatılan instance `advanced`'e zincirlenir; zincir boyunca (≥2 poll) `gate-timeout` için **hiç** `kind: "scheduled"` girdisi görülmez; `autoAdvances == 1`; timer süresi (8 sn) + 5 sn geçtikten sonra da instance `advanced`'de, `timeoutFired == 0` ve armed girdi yok |
| `NoAutoWinner_ArmsTheScheduledTransition_AndItFires` | `mode: "park"` ile başlatılan instance `gate`'te bekler; state function `gate-timeout` girdisini makul bir `executeAtUtc` ile gösterir; timer ateşlenir, instance `gate-timedout`'a geçer, `timeoutFired == 1`, `autoAdvances == 0` |
| `NoAutoWinner_ArmsTheScheduledTransition_AndItFires` | `mode: "park"` ile başlatılan instance `gate`'te bekler; state function `gate-timeout` girdisini makul bir `executeAtUtc` ile **ve tanımdaki `annotations` ile** (`ui/countdown: gate-timeout`, akış 1.0.1) gösterir; timer ateşlenir, instance `gate-timedout`'a geçer, `timeoutFired == 1`, `autoAdvances == 0` |

Doğrulama durumu: **2/2 yeşil** (2026-09-03, lokal runtime, vnext `feature/schedule-after-auto`
@ `702a03b6`), iki koşu üst üste, ~24 sn.
@ `702a03b6`), iki koşu üst üste, ~24 sn. **Annotations (2026-09-24):** 2/2 yeşil, lokal runtime
`claude/annotations-state-function-06d98e`. Scheduled girdileri önceden yalnız job satırından
kuruluyor ve transition'ın `annotations`'ını düşürüyordu; runtime artık onları job'un `SourceState`'i
üzerinden tanımdan çözüyor (postgres: `gate-timeout` job'u `SourceState=gate`).

### Bilinen kısıtlar

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ await WaitUntilAsync(
async () =>
{
probes++;
var (present, executeAt) = await ScheduledEntryAsync(instanceId);
var (present, executeAt, _) = await ScheduledEntryAsync(instanceId);
if (present) everSeenAt ??= executeAt ?? "(entry without executeAtUtc)";

var (state, status) = await GetInstanceStateAsync(Workflow, instanceId);
Expand Down Expand Up @@ -108,11 +108,13 @@ public async Task NoAutoWinner_ArmsTheScheduledTransition_AndItFires()
await WaitUntilSettledAsync(Workflow, instanceId);

string? executeAtRaw = null;
string? countdownAnnotation = null;
await WaitUntilAsync(
async () =>
{
var (present, executeAt) = await ScheduledEntryAsync(instanceId);
var (present, executeAt, countdown) = await ScheduledEntryAsync(instanceId);
executeAtRaw = executeAt;
countdownAnnotation = countdown;
return present;
},
$"no '{ScheduledTransition}' scheduled entry was exposed by the state function — " +
Expand All @@ -121,6 +123,10 @@ await WaitUntilAsync(

Assert.NotNull(executeAtRaw);

// The scheduled entry carries the transition definition's annotations, like every other
// transitions[] kind. It used to be built from the job row alone and dropped them.
Assert.Equal("gate-timeout", countdownAnnotation);

var executeAt = DateTimeOffset.Parse(executeAtRaw!, CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal);

Expand Down Expand Up @@ -167,28 +173,33 @@ private async Task<string> StartAsyncModeAsync(string mode)
}

/// <summary>
/// Whether the state function currently exposes the gate's scheduled entry, and its
/// <c>executeAtUtc</c>.
/// Whether the state function currently exposes the gate's scheduled entry, its
/// <c>executeAtUtc</c>, and its <c>annotations["ui/countdown"]</c>.
/// <para>
/// Presence and <c>executeAtUtc</c> are reported separately on purpose:
/// <c>WorkflowTestBase.GetScheduledExecuteAtAsync</c> returns null both when no entry is armed
/// and when an entry is armed without that field, and this scenario asserts on the absence of
/// the entry itself.
/// </para>
/// </summary>
private async Task<(bool Present, string? ExecuteAt)> ScheduledEntryAsync(string instanceId)
private async Task<(bool Present, string? ExecuteAt, string? Countdown)> ScheduledEntryAsync(string instanceId)
{
var response = await Api.CallInstanceFunctionAsync(Workflow, instanceId, "state", headers: Headers());
if (!response.Body.TryGetProperty("transitions", out var transitions)) return (false, null);
if (!response.Body.TryGetProperty("transitions", out var transitions)) return (false, null, null);

foreach (var transition in transitions.EnumerateArray())
{
if (!transition.TryGetProperty("kind", out var kind) || kind.GetString() != "scheduled") continue;
if (!transition.TryGetProperty("name", out var name) || name.GetString() != ScheduledTransition) continue;

return (true, transition.TryGetProperty("executeAtUtc", out var at) ? at.GetString() : null);
var countdown = transition.TryGetProperty("annotations", out var annotations)
&& annotations.ValueKind == JsonValueKind.Object
&& annotations.TryGetProperty("ui/countdown", out var value)
? value.GetString()
: null;
return (true, transition.TryGetProperty("executeAtUtc", out var at) ? at.GetString() : null, countdown);
}

return (false, null);
return (false, null, null);
}
}
13 changes: 13 additions & 0 deletions tests/Core.IntegrationTests/Tests/TimeoutLab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,19 @@ Requires the flows to be published (`wf domain use core && wf sync`) against a l
4. A child running under its parent's override reports **the override's** `key`/`target`, not its own
(absent) definition, and is pulled to that target.
5. A parent does not inherit its child's deadline: the block describes the polled instance only.
6. **Annotations (flows 1.0.1).** Every entry the state body lists carries its definition's
`annotations` — `root-finish` (`stateTransition`), `root-note` (`sharedTransition`), and the
well-known `cancel`, `exit` and `updateData` — each with a distinct `ui/source` value, and the
`timeout` block carries `timeout.annotations` (`ui/countdown: root-deadline`). The child's block
carries the **override's** annotations (`parent-override`): the stamp the parent writes carries
them, and an override replaces the timeout as a whole. None of the added transitions is ever
called; the timeout is still the only thing that moves the instance. Scheduled entries are pinned
by `schedule-after-auto`.

Status: **4/4 green** (2026-09-24, local runtime `claude/annotations-state-function-06d98e`,
`VNEXT_BASE_URL=http://localhost:4201`). Evidence beyond the summary: the live state body lists all
five kinds with their annotations and `timeout.annotations`; postgres shows the instances bound to
`1.0.1` and the child's `subflow.timeout_override` stamp carrying `"annotations":{"ui/countdown":"parent-override"}`.

## What it caught on its first run

Expand Down
47 changes: 47 additions & 0 deletions tests/Core.IntegrationTests/Tests/TimeoutLab/TimeoutLabTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ await WaitUntilAsync(
Assert.Equal("child-abandoned", timeout!.Value.GetProperty("key").GetString());
Assert.Equal(ChildTimedOutState, timeout.Value.GetProperty("target").GetString());

// The override's annotations travel with it — the stamp the parent writes carries them,
// and they replace the child's (the child declares no timeout, so it has none of its own).
Assert.Equal("parent-override", Annotation(timeout.Value, "ui/countdown"));

// ── and the runtime moves it to exactly that target ──────────────────────
await WaitUntilAsync(
async () =>
Expand All @@ -145,6 +149,49 @@ await WaitUntilAsync(
Deadline + TimeSpan.FromSeconds(40));
}

/// <summary>
/// Every entry the state body lists carries its definition's <c>annotations</c>: the state,
/// shared and the three well-known workflow-level transitions, and the <c>timeout</c> block. Each
/// fixture entry carries a distinct <c>ui/source</c> value, so a dropped or crossed annotation
/// names itself. (Scheduled entries are pinned by <c>schedule-after-auto</c>.)
/// </summary>
[Fact]
public async Task RootFlow_StateBodyCarriesTheAnnotationsOfEveryListedEntry()
{
var instanceId = await StartAsync(RootWorkflow, new { });

await WaitForInstanceStateAsync(RootWorkflow, instanceId, RootWaitingState);

var response = await Api.CallInstanceFunctionAsync(RootWorkflow, instanceId, "state", headers: Headers());
var body = response.Body;

var bySource = new Dictionary<string, (string Kind, string? Source)>(StringComparer.Ordinal);
foreach (var transition in body.GetProperty("transitions").EnumerateArray())
{
var name = transition.GetProperty("name").GetString()!;
bySource[name] = (transition.GetProperty("kind").GetString()!, Annotation(transition, "ui/source"));
}

var described = string.Join(", ", bySource.Select(kv => $"{kv.Key}={kv.Value.Kind}/{kv.Value.Source ?? "∅"}"));
Assert.Equal(("stateTransition", "state"), bySource.GetValueOrDefault("root-finish"));
Assert.Equal(("sharedTransition", "shared"), bySource.GetValueOrDefault("root-note"));
Assert.Equal(("cancel", "cancel"), bySource.GetValueOrDefault("cancel-timeout-lab-root"));
Assert.Equal(("exit", "exit"), bySource.GetValueOrDefault("exit-timeout-lab-root"));
Assert.True(bySource.GetValueOrDefault("update-timeout-lab-root") == ("updateData", "updateData"),
$"updateData entry missing or without its annotation — transitions: {described}");

Assert.True(body.TryGetProperty("timeout", out var timeout) && timeout.ValueKind == JsonValueKind.Object,
$"no `timeout` block — {await DescribeAsync(RootWorkflow, instanceId)}");
Assert.Equal("root-deadline", Annotation(timeout, "ui/countdown"));
}

private static string? Annotation(JsonElement element, string key) =>
element.TryGetProperty("annotations", out var annotations)
&& annotations.ValueKind == JsonValueKind.Object
&& annotations.TryGetProperty(key, out var value)
? value.GetString()
: null;

/// <summary>
/// The <c>timeout</c> block describes the polled instance and nothing else: a parent whose child
/// carries a deadline must not inherit it, and must not be given one it does not have.
Expand Down
Loading