Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,15 @@ internal static PersistedScheduledCommand ToPersistable(this ScheduledCommand in
return result;
}

private static DateTime EnsureUtc(DateTime dateTime)
{
if (dateTime.Kind == DateTimeKind.Local)
return dateTime.ToUniversalTime();
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EnsureUtc currently calls DateTime.SpecifyKind(dateTime, DateTimeKind.Utc) for inputs that are already DateTimeKind.Utc. Returning the original value when dateTime.Kind == DateTimeKind.Utc would make the intent clearer and avoid an unnecessary copy.

Suggested change
return dateTime.ToUniversalTime();
return dateTime.ToUniversalTime();
if (dateTime.Kind == DateTimeKind.Utc)
return dateTime;

Copilot uses AI. Check for mistakes.
if (dateTime.Kind == DateTimeKind.Utc)
return dateTime;
return DateTime.SpecifyKind(dateTime, DateTimeKind.Utc);
}
Comment on lines +140 to +147
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new EnsureUtc behavior (converting DateTimeKind.Local via ToUniversalTime) isn’t covered by an automated regression test, so it could regress back to SpecifyKind without being detected—especially since most CI environments run with TimeZoneInfo.Local set to UTC. Consider adding a test (e.g., in the EF/PostgreSQL persistence tests) that forces a non-UTC local zone for the process (on Linux, setting TZ + calling TimeZoneInfo.ClearCachedData()), then verifies that a persisted SleepUntil coming back as Kind=Local is converted to the expected UTC instant.

Copilot uses AI. Check for mistakes.

internal static WorkflowInstance ToWorkflowInstance(this PersistedWorkflow instance)
{
WorkflowInstance result = new WorkflowInstance();
Expand All @@ -148,9 +157,9 @@ internal static WorkflowInstance ToWorkflowInstance(this PersistedWorkflow insta
result.Version = instance.Version;
result.WorkflowDefinitionId = instance.WorkflowDefinitionId;
result.Status = instance.Status;
result.CreateTime = DateTime.SpecifyKind(instance.CreateTime, DateTimeKind.Utc);
result.CreateTime = EnsureUtc(instance.CreateTime);
if (instance.CompleteTime.HasValue)
result.CompleteTime = DateTime.SpecifyKind(instance.CompleteTime.Value, DateTimeKind.Utc);
result.CompleteTime = EnsureUtc(instance.CompleteTime.Value);

result.ExecutionPointers = new ExecutionPointerCollection(instance.ExecutionPointers.Count + 8);

Expand All @@ -163,15 +172,15 @@ internal static WorkflowInstance ToWorkflowInstance(this PersistedWorkflow insta
pointer.Active = ep.Active;

if (ep.SleepUntil.HasValue)
pointer.SleepUntil = DateTime.SpecifyKind(ep.SleepUntil.Value, DateTimeKind.Utc);
pointer.SleepUntil = EnsureUtc(ep.SleepUntil.Value);

pointer.PersistenceData = JsonConvert.DeserializeObject(ep.PersistenceData ?? string.Empty, SerializerSettings);

if (ep.StartTime.HasValue)
pointer.StartTime = DateTime.SpecifyKind(ep.StartTime.Value, DateTimeKind.Utc);
pointer.StartTime = EnsureUtc(ep.StartTime.Value);

if (ep.EndTime.HasValue)
pointer.EndTime = DateTime.SpecifyKind(ep.EndTime.Value, DateTimeKind.Utc);
pointer.EndTime = EnsureUtc(ep.EndTime.Value);

pointer.StepName = ep.StepName;

Expand Down Expand Up @@ -212,10 +221,11 @@ internal static EventSubscription ToEventSubscription(this PersistedSubscription
result.StepId = instance.StepId;
result.ExecutionPointerId = instance.ExecutionPointerId;
result.WorkflowId = instance.WorkflowId;
result.SubscribeAsOf = DateTime.SpecifyKind(instance.SubscribeAsOf, DateTimeKind.Utc);
result.SubscribeAsOf = EnsureUtc(instance.SubscribeAsOf);
result.SubscriptionData = JsonConvert.DeserializeObject(instance.SubscriptionData, SerializerSettings);
result.ExternalToken = instance.ExternalToken;
result.ExternalTokenExpiry = instance.ExternalTokenExpiry;
if (instance.ExternalTokenExpiry.HasValue)
result.ExternalTokenExpiry = EnsureUtc(instance.ExternalTokenExpiry.Value);
result.ExternalWorkerId = instance.ExternalWorkerId;

return result;
Expand All @@ -227,7 +237,7 @@ internal static Event ToEvent(this PersistedEvent instance)
result.Id = instance.EventId.ToString();
result.EventKey = instance.EventKey;
result.EventName = instance.EventName;
result.EventTime = DateTime.SpecifyKind(instance.EventTime, DateTimeKind.Utc);
result.EventTime = EnsureUtc(instance.EventTime);
result.IsProcessed = instance.IsProcessed;
result.EventData = JsonConvert.DeserializeObject(instance.EventData, SerializerSettings);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

// General Information about an assembly is controlled through the following
Expand All @@ -16,3 +17,5 @@

// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("fe54ad67-817a-4cc6-a9ef-c9f7a5122ca4")]

[assembly: InternalsVisibleTo("WorkflowCore.Tests.Sqlite")]
93 changes: 93 additions & 0 deletions test/WorkflowCore.Tests.Sqlite/ExtensionMethodsFixture.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using System;
using FluentAssertions;
using WorkflowCore.Models;
using WorkflowCore.Persistence.EntityFramework;
using WorkflowCore.Persistence.EntityFramework.Models;
using Xunit;

namespace WorkflowCore.Tests.Sqlite
{
public class ExtensionMethodsFixture
{
[Fact]
public void ToWorkflowInstance_CreateTime_Local_Kind_Should_Convert_To_Utc()
{
var localTime = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Local);
var expectedUtc = localTime.ToUniversalTime();

var persisted = BuildPersistedWorkflow(createTime: localTime);

var instance = persisted.ToWorkflowInstance();

instance.CreateTime.Should().Be(expectedUtc);
instance.CreateTime.Kind.Should().Be(DateTimeKind.Utc);
}

[Fact]
public void ToWorkflowInstance_CreateTime_Utc_Kind_Should_Return_Same_Value()
{
var utcTime = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);

var persisted = BuildPersistedWorkflow(createTime: utcTime);

var instance = persisted.ToWorkflowInstance();

instance.CreateTime.Should().Be(utcTime);
instance.CreateTime.Kind.Should().Be(DateTimeKind.Utc);
}

[Fact]
public void ToWorkflowInstance_CreateTime_Unspecified_Kind_Should_Treat_As_Utc()
{
var unspecifiedTime = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Unspecified);

var persisted = BuildPersistedWorkflow(createTime: unspecifiedTime);

var instance = persisted.ToWorkflowInstance();

instance.CreateTime.Should().Be(DateTime.SpecifyKind(unspecifiedTime, DateTimeKind.Utc));
instance.CreateTime.Kind.Should().Be(DateTimeKind.Utc);
}

[Fact]
public void ToWorkflowInstance_SleepUntil_Local_Kind_Should_Convert_To_Utc()
{
var localTime = new DateTime(2026, 3, 26, 12, 30, 31, DateTimeKind.Local);
var expectedUtc = localTime.ToUniversalTime();

var persisted = BuildPersistedWorkflow();
persisted.ExecutionPointers.Add(new PersistedExecutionPointer
{
Id = "ep1",
StepId = 0,
Active = true,
SleepUntil = localTime,
PersistenceData = "null",
ContextItem = "null",
Scope = "",
Children = "",
EventData = "null",
Outcome = "null"
});

var instance = persisted.ToWorkflowInstance();

var pointer = instance.ExecutionPointers.FindById("ep1");
pointer.SleepUntil.Should().Be(expectedUtc);
pointer.SleepUntil.Value.Kind.Should().Be(DateTimeKind.Utc);
}

private static PersistedWorkflow BuildPersistedWorkflow(DateTime? createTime = null)
{
return new PersistedWorkflow
{
InstanceId = Guid.NewGuid(),
WorkflowDefinitionId = "test",
Version = 1,
Status = WorkflowStatus.Runnable,
Data = "{}",
CreateTime = createTime ?? new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)
};
}
}
}