Skip to content

[feat]: introduce runtime configuration SPI #354

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jul 23, 2025
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
102 changes: 102 additions & 0 deletions Workflow/Sources/RuntimeConfiguration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* Copyright Square Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/// System for managing configuration options for Workflow runtime behaviors.
/// - important: These interfaces are subject to breaking changes without corresponding semantic
/// versioning changes.
@_spi(WorkflowRuntimeConfig)
public enum Runtime {
@TaskLocal
static var _currentConfiguration: Configuration?

static var _bootstrapConfiguration = BootstrappableConfiguration()

/// Bootstrap the workflow runtime with the given configuration.
/// This can only be called once per process and must be called from the main thread.
///
/// - Parameter configuration: The runtime configuration to use.
@MainActor
public static func bootstrap(
Copy link
Collaborator

Choose a reason for hiding this comment

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

not at all a blocker, but IMO the one-time bootstrapping requirement is overkill vs having a static var that can be changed at any time

_ configureBlock: (inout Configuration) -> Void
) {
MainActor.preconditionIsolated(
"The Workflow runtime must be bootstrapped from the main actor."
)
guard !_isBootstrapped else {
fatalError("The Workflow runtime can only be bootstrapped once.")
}

var config = _bootstrapConfiguration.currentConfiguration
configureBlock(&config)
_bootstrapConfiguration._bootstrapConfig = config
}

static var configuration: Configuration {
_currentConfiguration ?? _bootstrapConfiguration.currentConfiguration
}

/// Allows temporary customization of the runtime configuration during the execution of the `operation`.
///
/// - Parameters:
/// - override: An option block to reconfigure the current configuration value.
/// - operation: The operation to perform with the customized configuration.
public static func withConfiguration<T>(
override: ((inout Configuration) -> Void)? = nil,
operation: () -> T
) -> T {
var configSnapshot = configuration
override?(&configSnapshot)

return Runtime
.$_currentConfiguration
.withValue(
configSnapshot,
operation: operation
)
}

// MARK: -

private static var _isBootstrapped: Bool {
_bootstrapConfiguration._bootstrapConfig != nil
}

/// The current runtime configuration that may have been set via `bootstrap()`.
private static var _currentBootstrapConfiguration: Configuration {
_bootstrapConfiguration.currentConfiguration
}
}

extension Runtime {
/// Configuration options for the Workflow runtime.
public struct Configuration: Equatable {
/// The default runtime configuration.
static let `default` = Configuration()

/// Note: this doesn't control anything yet, but is here as a placeholder
public var renderOnlyIfStateChanged: Bool = false
}

struct BootstrappableConfiguration {
var _bootstrapConfig: Configuration?
let _defaultConfig: Configuration = .default

/// The current runtime configuration that may have been set via `Runtime.bootstrap()`.
var currentConfiguration: Configuration {
_bootstrapConfig ?? _defaultConfig
}
}
}
10 changes: 7 additions & 3 deletions Workflow/Sources/WorkflowHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ public final class WorkflowHost<WorkflowType: Workflow> {

self.context = HostContext(
observer: observer,
debugger: debugger
debugger: debugger,
runtimeConfig: Runtime.configuration
)

self.rootNode = WorkflowNode(
Expand Down Expand Up @@ -130,16 +131,19 @@ public final class WorkflowHost<WorkflowType: Workflow> {

/// A context object to expose certain root-level information to each node
/// in the Workflow tree.
final class HostContext {
struct HostContext {
let observer: WorkflowObserver?
let debugger: WorkflowDebugger?
let runtimeConfig: Runtime.Configuration

init(
observer: WorkflowObserver?,
debugger: WorkflowDebugger?
debugger: WorkflowDebugger?,
runtimeConfig: Runtime.Configuration
) {
self.observer = observer
self.debugger = debugger
self.runtimeConfig = runtimeConfig
}
}

Expand Down
89 changes: 89 additions & 0 deletions Workflow/Tests/RuntimeConfigTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright Square Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import Testing

@_spi(WorkflowRuntimeConfig) @testable import Workflow

@MainActor
struct RuntimeConfigTests {
@Test
private func runtime_config_inits_to_default() {
let cfg = Runtime.Configuration()
#expect(cfg == Runtime.Configuration.default)
}

@Test
private func test_global_config_defaults_to_default() {
#expect(Runtime.configuration == .default)
}

@Test
private func runtime_config_prefers_bootstrap_value() {
#expect(Runtime.configuration.renderOnlyIfStateChanged == false)

defer {
// reset global state...
Runtime.resetConfig()
}
Runtime.bootstrap { cfg in
cfg.renderOnlyIfStateChanged = true
}

#expect(Runtime.configuration.renderOnlyIfStateChanged == true)
}

@Test
private func test_config_respects_task_local_overrides() {
var customConfig = Runtime.configuration
customConfig.renderOnlyIfStateChanged = true

Runtime.$_currentConfiguration.withValue(customConfig) {
#expect(Runtime.configuration.renderOnlyIfStateChanged == true)
}
}

@Test
private func test_withConfiguration() {
#expect(Runtime.configuration.renderOnlyIfStateChanged == false)

var override = Runtime.configuration
override.renderOnlyIfStateChanged = true

let newValue = Runtime.$_currentConfiguration.withValue(override) {
Runtime.withConfiguration {
Runtime.configuration.renderOnlyIfStateChanged
}
}
#expect(newValue == true)
}

@Test
private func test_withConfigurationOverride() {
let newValue = Runtime.withConfiguration(
override: { cfg in
#expect(Runtime.configuration.renderOnlyIfStateChanged == false)
#expect(cfg.renderOnlyIfStateChanged == false)
cfg.renderOnlyIfStateChanged = true
},
operation: {
Runtime.configuration.renderOnlyIfStateChanged
}
)

#expect(newValue == true)
}
}
16 changes: 13 additions & 3 deletions Workflow/Tests/TestUtilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import Foundation

@testable import Workflow
@_spi(WorkflowRuntimeConfig) @testable import Workflow

/// Renders to a model that contains a callback, which in turn sends an output event.
struct StateTransitioningWorkflow: Workflow {
Expand Down Expand Up @@ -62,11 +62,13 @@ struct StateTransitioningWorkflow: Workflow {
extension HostContext {
static func testing(
observer: WorkflowObserver? = nil,
debugger: WorkflowDebugger? = nil
debugger: WorkflowDebugger? = nil,
runtimeConfig: Runtime.Configuration = Runtime.configuration
) -> HostContext {
HostContext(
observer: observer,
debugger: debugger
debugger: debugger,
runtimeConfig: runtimeConfig
)
}
}
Expand Down Expand Up @@ -95,3 +97,11 @@ extension ApplyContext {
wrappedConcreteContext?.storage
}
}

// MARK: - Runtime.Config

extension Runtime {
static func resetConfig() {
Runtime._bootstrapConfiguration = .init()
}
}
28 changes: 27 additions & 1 deletion Workflow/Tests/WorkflowHostTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
* limitations under the License.
*/

import Workflow
import XCTest
@_spi(WorkflowRuntimeConfig) @testable import Workflow

final class WorkflowHostTests: XCTestCase {
func test_updatedInputCausesRenderPass() {
Expand Down Expand Up @@ -87,6 +87,32 @@ final class WorkflowHost_EventEmissionTests: XCTestCase {
}
}

// MARK: Runtime Configuration

extension WorkflowHostTests {
func test_inherits_default_runtime_config() {
let host = WorkflowHost(
workflow: TestWorkflow(step: .first)
)

XCTAssertEqual(host.context.runtimeConfig, .default)
}

func test_inherits_custom_runtime_config() {
var customConfig = Runtime.configuration
XCTAssertFalse(customConfig.renderOnlyIfStateChanged)

customConfig.renderOnlyIfStateChanged = true
let host = Runtime.$_currentConfiguration.withValue(customConfig) {
WorkflowHost(
workflow: TestWorkflow(step: .first)
)
}

XCTAssertEqual(host.context.runtimeConfig.renderOnlyIfStateChanged, true)
}
}

// MARK: Utility Types

extension WorkflowHost_EventEmissionTests {
Expand Down
Loading