diff --git a/src/Runner.Common/JobServerQueue.cs b/src/Runner.Common/JobServerQueue.cs index 74c12bea28b..cfac6075017 100644 --- a/src/Runner.Common/JobServerQueue.cs +++ b/src/Runner.Common/JobServerQueue.cs @@ -837,6 +837,15 @@ private List MergeTimelineRecords(List timelineR timelineRecord.Variables[variable.Key] = variable.Value.Clone(); } } + + // Merge background step metadata + if (rec.IsBackground) + { + timelineRecord.IsBackground = rec.IsBackground; + } + timelineRecord.BackgroundControlType = rec.BackgroundControlType ?? timelineRecord.BackgroundControlType; + timelineRecord.BackgroundControlStepIds = rec.BackgroundControlStepIds ?? timelineRecord.BackgroundControlStepIds; + timelineRecord.ParallelGroupId = rec.ParallelGroupId ?? timelineRecord.ParallelGroupId; } else { diff --git a/src/Runner.Worker/ActionCommandManager.cs b/src/Runner.Worker/ActionCommandManager.cs index 77d6a4c5f84..eb01beb5834 100644 --- a/src/Runner.Worker/ActionCommandManager.cs +++ b/src/Runner.Worker/ActionCommandManager.cs @@ -178,7 +178,10 @@ private void ValidateStopToken(IExecutionContext context, string stopToken) Message = $"Invoked ::stopCommand:: with token: [{stopToken}]", Type = JobTelemetryType.ActionCommand }; - context.Global.JobTelemetry.Add(telemetry); + lock (context.Global.CollectionLock) + { + context.Global.JobTelemetry.Add(telemetry); + } } if (isTokenInvalid && !allowUnsecureStopCommandTokens) @@ -326,7 +329,10 @@ public void ProcessCommand(IExecutionContext context, string line, ActionCommand Type = JobTelemetryType.ActionCommand, Message = "DeprecatedCommand: set-output" }; - context.Global.JobTelemetry.Add(telemetry); + lock (context.Global.CollectionLock) + { + context.Global.JobTelemetry.Add(telemetry); + } } if (!command.Properties.TryGetValue(SetOutputCommandProperties.Name, out string outputName) || string.IsNullOrEmpty(outputName)) @@ -372,7 +378,10 @@ public void ProcessCommand(IExecutionContext context, string line, ActionCommand Type = JobTelemetryType.ActionCommand, Message = "DeprecatedCommand: save-state" }; - context.Global.JobTelemetry.Add(telemetry); + lock (context.Global.CollectionLock) + { + context.Global.JobTelemetry.Add(telemetry); + } } if (!command.Properties.TryGetValue(SaveStateCommandProperties.Name, out string stateName) || string.IsNullOrEmpty(stateName)) diff --git a/src/Runner.Worker/ActionManager.cs b/src/Runner.Worker/ActionManager.cs index 133129a0177..1fa5d5d0e62 100644 --- a/src/Runner.Worker/ActionManager.cs +++ b/src/Runner.Worker/ActionManager.cs @@ -1068,11 +1068,14 @@ private async Task DownloadRepositoryActionAsync(IExecutionContext executionCont } executionContext.Debug($"Created symlink from cached directory '{cacheDirectory}' to '{destDirectory}'"); - executionContext.Global.JobTelemetry.Add(new JobTelemetry() + lock (executionContext.Global.CollectionLock) { - Type = JobTelemetryType.General, - Message = $"Action archive cache usage: {downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha} use cache {useActionArchiveCache} has cache {hasActionArchiveCache} via symlink" - }); + executionContext.Global.JobTelemetry.Add(new JobTelemetry() + { + Type = JobTelemetryType.General, + Message = $"Action archive cache usage: {downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha} use cache {useActionArchiveCache} has cache {hasActionArchiveCache} via symlink" + }); + } Trace.Info("Finished getting action repository."); return; @@ -1108,11 +1111,14 @@ private async Task DownloadRepositoryActionAsync(IExecutionContext executionCont } } - executionContext.Global.JobTelemetry.Add(new JobTelemetry() + lock (executionContext.Global.CollectionLock) { - Type = JobTelemetryType.General, - Message = $"Action archive cache usage: {downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha} use cache {useActionArchiveCache} has cache {hasActionArchiveCache}" - }); + executionContext.Global.JobTelemetry.Add(new JobTelemetry() + { + Type = JobTelemetryType.General, + Message = $"Action archive cache usage: {downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha} use cache {useActionArchiveCache} has cache {hasActionArchiveCache}" + }); + } if (!useActionArchiveCache) { diff --git a/src/Runner.Worker/ActionManifestManagerWrapper.cs b/src/Runner.Worker/ActionManifestManagerWrapper.cs index 6d893fd8252..cf90294209e 100644 --- a/src/Runner.Worker/ActionManifestManagerWrapper.cs +++ b/src/Runner.Worker/ActionManifestManagerWrapper.cs @@ -446,7 +446,10 @@ private void RecordMismatch(IExecutionContext context, string methodName) { context.Global.HasActionManifestMismatch = true; var telemetry = new JobTelemetry { Type = JobTelemetryType.General, Message = $"ActionManifestMismatch: {methodName}" }; - context.Global.JobTelemetry.Add(telemetry); + lock (context.Global.CollectionLock) + { + context.Global.JobTelemetry.Add(telemetry); + } } } @@ -456,7 +459,10 @@ private void RecordComparisonError(IExecutionContext context, string errorDetail { context.Global.HasActionManifestMismatch = true; var telemetry = new JobTelemetry { Type = JobTelemetryType.General, Message = $"ActionManifestComparisonError: {errorDetails}" }; - context.Global.JobTelemetry.Add(telemetry); + lock (context.Global.CollectionLock) + { + context.Global.JobTelemetry.Add(telemetry); + } } } diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index b753c152b07..dd7c4502970 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -85,6 +85,12 @@ public interface IExecutionContext : IRunnerService IExecutionContext CreateChild(Guid recordId, string displayName, string refName, string scopeName, string contextName, ActionRunStage stage, Dictionary intraActionState = null, int? recordOrder = null, IPagingLogger logger = null, bool isEmbedded = false, List embeddedIssueCollector = null, CancellationTokenSource cancellationTokenSource = null, Guid embeddedId = default(Guid), string siblingScopeName = null, TimeSpan? timeout = null); IExecutionContext CreateEmbeddedChild(string scopeName, string contextName, Guid embeddedId, ActionRunStage stage, Dictionary intraActionState = null, string siblingScopeName = null); + // Background step deferral properties + Dictionary DeferredOutputs { get; set; } + Dictionary DeferredEnvironmentVariables { get; set; } + List DeferredPrependPath { get; set; } + bool DeferOutcomeConclusion { get; set; } + // logging long Write(string tag, string message); void QueueAttachFile(string type, string name, string filePath); @@ -100,11 +106,18 @@ public interface IExecutionContext : IRunnerService void SetGitHubContext(string name, string value); void SetOutput(string name, string value, out string reference); void SetTimeout(TimeSpan? timeout); + + // Background step deferral flush methods + void FlushDeferredOutputs(); + void FlushDeferredEnvironment(); + void FlushDeferredOutcomeConclusion(); + void AddIssue(Issue issue, ExecutionContextLogOptions logOptions); void Progress(int percentage, string currentOperation = null); void UpdateDetailTimelineRecord(TimelineRecord record); void UpdateTimelineRecordDisplayName(string displayName); + void SetBackgroundStepMetadata(bool isBackground = false, string backgroundControlType = null, string[] backgroundControlStepIds = null, string parallelGroupId = null); // matchers void Add(OnMatcherChanged handler); @@ -279,6 +292,12 @@ public JobContext JobContext public List StepEnvironmentOverrides { get; } = new List(); + // Background step deferral properties + public Dictionary DeferredOutputs { get; set; } + public Dictionary DeferredEnvironmentVariables { get; set; } + public List DeferredPrependPath { get; set; } + public bool DeferOutcomeConclusion { get; set; } + public override void Initialize(IHostContext hostContext) { base.Initialize(hostContext); @@ -516,6 +535,11 @@ public TaskResult Complete(TaskResult? result = null, string currentOperation = Annotations = new List() }; + // Populate background step metadata from timeline record fields + stepResult.IsBackground = _record.IsBackground; + stepResult.BackgroundControlType = _record.BackgroundControlType; + stepResult.BackgroundControlStepIds = _record.BackgroundControlStepIds; + _record.Issues?.ForEach(issue => { var annotation = issue.ToAnnotation(); @@ -541,7 +565,10 @@ public TaskResult Complete(TaskResult? result = null, string currentOperation = var annotation = issue.ToAnnotation(); if (annotation != null) { - Global.JobAnnotations.Add(annotation.Value); + lock (Global.CollectionLock) + { + Global.JobAnnotations.Add(annotation.Value); + } if (annotation.Value.IsInfrastructureIssue && string.IsNullOrEmpty(Global.InfrastructureFailureCategory)) { Global.InfrastructureFailureCategory = issue.Category; @@ -559,11 +586,22 @@ public TaskResult Complete(TaskResult? result = null, string currentOperation = _logger.End(); - UpdateGlobalStepsContext(); + if (!DeferOutcomeConclusion) + { + UpdateGlobalStepsContext(); + } return Result.Value; } + public void FlushDeferredOutcomeConclusion() + { + if (DeferOutcomeConclusion) + { + UpdateGlobalStepsContext(); + } + } + public void UpdateGlobalStepsContext() { // Skip if generated context name. Generated context names start with "__". After 3.2 the server will never send an empty context name. @@ -639,6 +677,40 @@ public void SetOutput(string name, string value, out string reference) Global.StepsContext.SetOutput(ScopeName, ContextName, name, value, out reference); } + public void FlushDeferredOutputs() + { + if (DeferredOutputs == null || DeferredOutputs.Count == 0) + { + return; + } + + foreach (var kvp in DeferredOutputs) + { + Global.StepsContext.SetOutput(ScopeName, ContextName, kvp.Key, kvp.Value, out _); + } + } + + public void FlushDeferredEnvironment() + { + if (DeferredEnvironmentVariables != null) + { + foreach (var kvp in DeferredEnvironmentVariables) + { + Global.EnvironmentVariables[kvp.Key] = kvp.Value; + SetEnvContext(kvp.Key, kvp.Value); + } + } + + if (DeferredPrependPath != null) + { + foreach (var path in DeferredPrependPath) + { + Global.PrependPath.RemoveAll(x => string.Equals(x, path, StringComparison.CurrentCulture)); + Global.PrependPath.Add(path); + } + } + } + public void SetTimeout(TimeSpan? timeout) { if (timeout != null) @@ -812,6 +884,15 @@ public void UpdateTimelineRecordDisplayName(string displayName) _jobServerQueue.QueueTimelineRecordUpdate(_mainTimelineId, _record); } + public void SetBackgroundStepMetadata(bool isBackground = false, string backgroundControlType = null, string[] backgroundControlStepIds = null, string parallelGroupId = null) + { + _record.IsBackground = isBackground; + _record.BackgroundControlType = backgroundControlType; + _record.BackgroundControlStepIds = backgroundControlStepIds; + _record.ParallelGroupId = parallelGroupId; + _jobServerQueue.QueueTimelineRecordUpdate(_mainTimelineId, _record); + } + public void InitializeJob(Pipelines.AgentJobRequestMessage message, CancellationToken token) { // Validation @@ -1196,7 +1277,10 @@ public void PublishStepTelemetry() } Trace.Info($"Publish step telemetry for current step {StringUtil.ConvertToJson(StepTelemetry)}."); - Global.StepsTelemetry.Add(StepTelemetry); + lock (Global.CollectionLock) + { + Global.StepsTelemetry.Add(StepTelemetry); + } _stepTelemetryPublished = true; } } @@ -1335,7 +1419,10 @@ public void ApplyContinueOnError(TemplateToken continueOnErrorToken) Trace.Info($"Updated step result (continue on error)"); } - UpdateGlobalStepsContext(); + if (!DeferOutcomeConclusion) + { + UpdateGlobalStepsContext(); + } } internal IPipelineTemplateEvaluator ToPipelineTemplateEvaluatorInternal(bool allowServiceContainerCommand, ObjectTemplating.ITraceWriter traceWriter = null) diff --git a/src/Runner.Worker/FileCommandManager.cs b/src/Runner.Worker/FileCommandManager.cs index 0021aa527a3..89b32ddeff8 100644 --- a/src/Runner.Worker/FileCommandManager.cs +++ b/src/Runner.Worker/FileCommandManager.cs @@ -122,8 +122,18 @@ public void ProcessCommand(IExecutionContext context, string filePath, Container { continue; } - context.Global.PrependPath.RemoveAll(x => string.Equals(x, line, StringComparison.CurrentCulture)); - context.Global.PrependPath.Add(line); + if (context.DeferredPrependPath != null) + { + // Background step: buffer path additions until wait/wait-all + context.DeferredPrependPath.RemoveAll(x => string.Equals(x, line, StringComparison.CurrentCulture)); + context.DeferredPrependPath.Add(line); + context.Debug($"Deferred prepend path '{line}' for background step"); + } + else + { + context.Global.PrependPath.RemoveAll(x => string.Equals(x, line, StringComparison.CurrentCulture)); + context.Global.PrependPath.Add(line); + } } } } @@ -172,8 +182,17 @@ private static void SetEnvironmentVariable( string name, string value) { - context.Global.EnvironmentVariables[name] = value; - context.SetEnvContext(name, value); + if (context.DeferredEnvironmentVariables != null) + { + // Background step: buffer env changes until wait/wait-all + context.DeferredEnvironmentVariables[name] = value; + context.Debug($"Deferred env '{name}' for background step"); + } + else + { + context.Global.EnvironmentVariables[name] = value; + context.SetEnvContext(name, value); + } context.Debug($"{name}='{value}'"); } @@ -302,7 +321,16 @@ public void ProcessCommand(IExecutionContext context, string filePath, Container var pairs = new EnvFileKeyValuePairs(context, filePath); foreach (var pair in pairs) { - context.SetOutput(pair.Key, pair.Value, out var reference); + if (context.DeferredOutputs != null) + { + // Background step: buffer outputs until wait/wait-all + context.DeferredOutputs[pair.Key] = pair.Value; + context.Debug($"Deferred output '{pair.Key}' for background step"); + } + else + { + context.SetOutput(pair.Key, pair.Value, out var reference); + } context.Debug($"Set output {pair.Key} = {pair.Value}"); } } diff --git a/src/Runner.Worker/GlobalContext.cs b/src/Runner.Worker/GlobalContext.cs index 04abe003633..c110ceccb82 100644 --- a/src/Runner.Worker/GlobalContext.cs +++ b/src/Runner.Worker/GlobalContext.cs @@ -12,6 +12,9 @@ namespace GitHub.Runner.Worker { public sealed class GlobalContext { + // Lock for thread-safe access to shared collections during concurrent background step execution + public readonly object CollectionLock = new object(); + public ContainerInfo Container { get; set; } public List Endpoints { get; set; } public IDictionary EnvironmentVariables { get; set; } diff --git a/src/Runner.Worker/Handlers/HandlerFactory.cs b/src/Runner.Worker/Handlers/HandlerFactory.cs index 8044f091da2..814b47a8ff5 100644 --- a/src/Runner.Worker/Handlers/HandlerFactory.cs +++ b/src/Runner.Worker/Handlers/HandlerFactory.cs @@ -115,17 +115,26 @@ public IHandler Create( if (string.Equals(finalNodeVersion, Constants.Runner.NodeMigration.Node24, StringComparison.OrdinalIgnoreCase)) { // Action was upgraded from node20 to node24 - executionContext.Global.UpgradedToNode24Actions?.Add(actionName); + lock (executionContext.Global.CollectionLock) + { + executionContext.Global.UpgradedToNode24Actions?.Add(actionName); + } } else if (ShouldTrackAsArm32Node20(deprecateArm32, nodeVersion, finalNodeVersion, platformWarningMessage)) { // Action is on node20 because ARM32 can't run node24 - executionContext.Global.Arm32Node20Actions?.Add(actionName); + lock (executionContext.Global.CollectionLock) + { + executionContext.Global.Arm32Node20Actions?.Add(actionName); + } } else if (warnOnNode20) { // Action is still running on node20 (general case) - executionContext.Global.DeprecatedNode20Actions?.Add(actionName); + lock (executionContext.Global.CollectionLock) + { + executionContext.Global.DeprecatedNode20Actions?.Add(actionName); + } } } @@ -159,7 +168,10 @@ public IHandler Create( if (!string.IsNullOrEmpty(actionName) && ShouldTrackAsArm32Node20(deprecateArm32, preferredVersion, finalNodeVersion, platformWarningMessage)) { - executionContext.Global.Arm32Node20Actions?.Add(actionName); + lock (executionContext.Global.CollectionLock) + { + executionContext.Global.Arm32Node20Actions?.Add(actionName); + } } } diff --git a/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs b/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs index 7714b02fd06..cc2c62465d4 100644 --- a/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs +++ b/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs @@ -207,7 +207,10 @@ private void RecordMismatch(string methodName) { _context.Global.HasTemplateEvaluatorMismatch = true; var telemetry = new JobTelemetry { Type = JobTelemetryType.General, Message = $"TemplateEvaluatorMismatch: {methodName}" }; - _context.Global.JobTelemetry.Add(telemetry); + lock (_context.Global.CollectionLock) + { + _context.Global.JobTelemetry.Add(telemetry); + } } } @@ -217,7 +220,10 @@ private void RecordComparisonError(string errorDetails) { _context.Global.HasTemplateEvaluatorMismatch = true; var telemetry = new JobTelemetry { Type = JobTelemetryType.General, Message = $"TemplateEvaluatorComparisonError: {errorDetails}" }; - _context.Global.JobTelemetry.Add(telemetry); + lock (_context.Global.CollectionLock) + { + _context.Global.JobTelemetry.Add(telemetry); + } } } diff --git a/src/Runner.Worker/StepsContext.cs b/src/Runner.Worker/StepsContext.cs index 6f16956e51e..c7639970c2f 100644 --- a/src/Runner.Worker/StepsContext.cs +++ b/src/Runner.Worker/StepsContext.cs @@ -18,6 +18,7 @@ public sealed class StepsContext { private static readonly Regex _propertyRegex = new("^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.Compiled); private readonly DictionaryContextData _contextData = new(); + private readonly object _lock = new(); /// /// Clears memory for a composite action's isolated "steps" context, after the action @@ -25,9 +26,12 @@ public sealed class StepsContext /// public void ClearScope(string scopeName) { - if (_contextData.TryGetValue(scopeName, out _)) + lock (_lock) { - _contextData[scopeName] = new DictionaryContextData(); + if (_contextData.TryGetValue(scopeName, out _)) + { + _contextData[scopeName] = new DictionaryContextData(); + } } } @@ -41,23 +45,26 @@ public void ClearScope(string scopeName) /// public DictionaryContextData GetScope(string scopeName) { - if (scopeName == null) + lock (_lock) { - scopeName = string.Empty; - } + if (scopeName == null) + { + scopeName = string.Empty; + } - var scope = default(DictionaryContextData); - if (_contextData.TryGetValue(scopeName, out var scopeValue)) - { - scope = scopeValue.AssertDictionary("scope"); - } - else - { - scope = new DictionaryContextData(); - _contextData.Add(scopeName, scope); - } + var scope = default(DictionaryContextData); + if (_contextData.TryGetValue(scopeName, out var scopeValue)) + { + scope = scopeValue.AssertDictionary("scope"); + } + else + { + scope = new DictionaryContextData(); + _contextData.Add(scopeName, scope); + } - return scope; + return scope; + } } public void SetOutput( @@ -67,16 +74,19 @@ public void SetOutput( string value, out string reference) { - var step = GetStep(scopeName, stepName); - var outputs = step["outputs"].AssertDictionary("outputs"); - outputs[outputName] = new StringContextData(value); - if (_propertyRegex.IsMatch(outputName)) + lock (_lock) { - reference = $"steps.{stepName}.outputs.{outputName}"; - } - else - { - reference = $"steps['{stepName}']['outputs']['{outputName}']"; + var step = GetStep(scopeName, stepName); + var outputs = step["outputs"].AssertDictionary("outputs"); + outputs[outputName] = new StringContextData(value); + if (_propertyRegex.IsMatch(outputName)) + { + reference = $"steps.{stepName}.outputs.{outputName}"; + } + else + { + reference = $"steps['{stepName}']['outputs']['{outputName}']"; + } } } @@ -85,8 +95,11 @@ public void SetConclusion( string stepName, ActionResult conclusion) { - var step = GetStep(scopeName, stepName); - step["conclusion"] = new StringContextData(conclusion.ToString().ToLowerInvariant()); + lock (_lock) + { + var step = GetStep(scopeName, stepName); + step["conclusion"] = new StringContextData(conclusion.ToString().ToLowerInvariant()); + } } public void SetOutcome( @@ -94,8 +107,11 @@ public void SetOutcome( string stepName, ActionResult outcome) { - var step = GetStep(scopeName, stepName); - step["outcome"] = new StringContextData(outcome.ToString().ToLowerInvariant()); + lock (_lock) + { + var step = GetStep(scopeName, stepName); + step["outcome"] = new StringContextData(outcome.ToString().ToLowerInvariant()); + } } private DictionaryContextData GetStep(string scopeName, string stepName) diff --git a/src/Sdk/DTPipelines/Pipelines/ActionStep.cs b/src/Sdk/DTPipelines/Pipelines/ActionStep.cs index f4ed5f041b5..785b37e6d49 100644 --- a/src/Sdk/DTPipelines/Pipelines/ActionStep.cs +++ b/src/Sdk/DTPipelines/Pipelines/ActionStep.cs @@ -25,6 +25,8 @@ private ActionStep(ActionStep actionToClone) Inputs = actionToClone.Inputs?.Clone(); ContextName = actionToClone?.ContextName; DisplayNameToken = actionToClone.DisplayNameToken?.Clone(); + Background = actionToClone.Background; + ParallelGroupId = actionToClone.ParallelGroupId; } public override StepType Type => StepType.Action; @@ -49,6 +51,12 @@ public ActionStepDefinitionReference Reference [DataMember(EmitDefaultValue = false)] public TemplateToken Inputs { get; set; } + [DataMember(EmitDefaultValue = false)] + public bool Background { get; set; } + + [DataMember(EmitDefaultValue = false)] + public string ParallelGroupId { get; set; } + public override Step Clone() { return new ActionStep(this); diff --git a/src/Sdk/DTPipelines/Pipelines/BackgroundStepControl.cs b/src/Sdk/DTPipelines/Pipelines/BackgroundStepControl.cs new file mode 100644 index 00000000000..b84421ab59f --- /dev/null +++ b/src/Sdk/DTPipelines/Pipelines/BackgroundStepControl.cs @@ -0,0 +1,88 @@ +using System.ComponentModel; +using System.Runtime.Serialization; +using GitHub.DistributedTask.ObjectTemplating.Tokens; +using Newtonsoft.Json; + +namespace GitHub.DistributedTask.Pipelines +{ + /// + /// Known control-flow types for background step control steps. + /// Wire values must match run-service constants (wait, wait-all, cancel). + /// + public static class BackgroundControlTypes + { + public const string Wait = "wait"; + public const string WaitAll = "wait-all"; + public const string Cancel = "cancel"; + } + + /// + /// Nested data for background step control, matching the run-service JSON shape. + /// + public class BackgroundStepControlData + { + [JsonProperty("controlType")] + public string ControlType { get; set; } + + [JsonProperty("stepIds")] + public string[] StepIds { get; set; } + + [JsonProperty("parallelGroupId")] + public string ParallelGroupId { get; set; } + } + + /// + /// Represents a unified background step control-flow step (wait, wait-all, cancel). + /// + [DataContract] + [EditorBrowsable(EditorBrowsableState.Never)] + public class BackgroundStepControl : JobStep + { + [JsonConstructor] + public BackgroundStepControl() + { + } + + private BackgroundStepControl(BackgroundStepControl stepToClone) + : base(stepToClone) + { + this.Data = stepToClone.Data != null ? new BackgroundStepControlData + { + ControlType = stepToClone.Data.ControlType, + StepIds = stepToClone.Data.StepIds != null + ? (string[])stepToClone.Data.StepIds.Clone() + : null, + ParallelGroupId = stepToClone.Data.ParallelGroupId, + } : null; + this.DisplayNameToken = stepToClone.DisplayNameToken?.Clone(); + } + + public override StepType Type => StepType.BackgroundStepControl; + + /// + /// Nested control data, deserialized from the "backgroundStepControl" JSON property. + /// + [JsonProperty("backgroundStepControl")] + public BackgroundStepControlData Data { get; set; } + + /// + /// Convenience accessors that delegate to Data. + /// + [JsonIgnore] + public string ControlType => Data?.ControlType; + + [JsonIgnore] + public string[] StepIds => Data?.StepIds; + + [JsonIgnore] + public string ParallelGroupId => Data?.ParallelGroupId; + + [DataMember(EmitDefaultValue = false)] + public TemplateToken DisplayNameToken { get; set; } + + public override Step Clone() + { + return new BackgroundStepControl(this); + } + } +} diff --git a/src/Sdk/DTPipelines/Pipelines/Step.cs b/src/Sdk/DTPipelines/Pipelines/Step.cs index 8c2492eaa28..34dae7af50b 100644 --- a/src/Sdk/DTPipelines/Pipelines/Step.cs +++ b/src/Sdk/DTPipelines/Pipelines/Step.cs @@ -7,6 +7,7 @@ namespace GitHub.DistributedTask.Pipelines { [DataContract] [KnownType(typeof(ActionStep))] + [KnownType(typeof(BackgroundStepControl))] [JsonConverter(typeof(StepConverter))] [EditorBrowsable(EditorBrowsableState.Never)] public abstract class Step @@ -68,5 +69,7 @@ public enum StepType { [DataMember] Action = 4, + [DataMember] + BackgroundStepControl = 5, } } diff --git a/src/Sdk/DTPipelines/Pipelines/StepConverter.cs b/src/Sdk/DTPipelines/Pipelines/StepConverter.cs index c6b9ad559b5..093be951006 100644 --- a/src/Sdk/DTPipelines/Pipelines/StepConverter.cs +++ b/src/Sdk/DTPipelines/Pipelines/StepConverter.cs @@ -51,6 +51,9 @@ public override object ReadJson( case StepType.Action: stepObject = new ActionStep(); break; + case StepType.BackgroundStepControl: + stepObject = new BackgroundStepControl(); + break; } using (var objectReader = value.CreateReader()) diff --git a/src/Sdk/DTWebApi/WebApi/TimelineRecord.cs b/src/Sdk/DTWebApi/WebApi/TimelineRecord.cs index 4e69762f9aa..dfdd42ed039 100644 --- a/src/Sdk/DTWebApi/WebApi/TimelineRecord.cs +++ b/src/Sdk/DTWebApi/WebApi/TimelineRecord.cs @@ -43,6 +43,10 @@ private TimelineRecord(TimelineRecord recordToBeCloned) this.WarningCount = recordToBeCloned.WarningCount; this.NoticeCount = recordToBeCloned.NoticeCount; this.AgentPlatform = recordToBeCloned.AgentPlatform; + this.IsBackground = recordToBeCloned.IsBackground; + this.BackgroundControlType = recordToBeCloned.BackgroundControlType; + this.BackgroundControlStepIds = recordToBeCloned.BackgroundControlStepIds; + this.ParallelGroupId = recordToBeCloned.ParallelGroupId; if (recordToBeCloned.Log != null) { @@ -289,6 +293,34 @@ public string AgentPlatform set; } + [DataMember(Order = 140, EmitDefaultValue = false)] + public bool IsBackground + { + get; + set; + } + + [DataMember(Order = 141, EmitDefaultValue = false)] + public string BackgroundControlType + { + get; + set; + } + + [DataMember(Order = 142, EmitDefaultValue = false)] + public string[] BackgroundControlStepIds + { + get; + set; + } + + [DataMember(Order = 144, EmitDefaultValue = false)] + public string ParallelGroupId + { + get; + set; + } + public IList PreviousAttempts { get diff --git a/src/Sdk/RSWebApi/Contracts/StepResult.cs b/src/Sdk/RSWebApi/Contracts/StepResult.cs index 300fb7741a7..213fde82a0c 100644 --- a/src/Sdk/RSWebApi/Contracts/StepResult.cs +++ b/src/Sdk/RSWebApi/Contracts/StepResult.cs @@ -50,5 +50,14 @@ public class StepResult [DataMember(Name = "annotations", EmitDefaultValue = false)] public List Annotations { get; set; } + + [DataMember(Name = "is_background", EmitDefaultValue = false)] + public bool IsBackground { get; set; } + + [DataMember(Name = "background_control_type", EmitDefaultValue = false)] + public string BackgroundControlType { get; set; } + + [DataMember(Name = "background_control_step_ids", EmitDefaultValue = false)] + public string[] BackgroundControlStepIds { get; set; } } } diff --git a/src/Sdk/WebApi/WebApi/Contracts.cs b/src/Sdk/WebApi/WebApi/Contracts.cs index 0018062ea58..2fd70101c66 100644 --- a/src/Sdk/WebApi/WebApi/Contracts.cs +++ b/src/Sdk/WebApi/WebApi/Contracts.cs @@ -179,6 +179,17 @@ public class Step public string CompletedAt; [DataMember] public Conclusion Conclusion; + [DataMember(EmitDefaultValue = false)] + public bool IsBackground; + [DataMember(EmitDefaultValue = false)] + [JsonProperty("backgroundControlType")] + public string BackgroundControlType; + [DataMember(EmitDefaultValue = false)] + [JsonProperty("backgroundControlStepIds")] + public string[] BackgroundControlStepIds; + [DataMember(EmitDefaultValue = false)] + [JsonProperty("parallelGroupId")] + public string ParallelGroupId; } public enum Status diff --git a/src/Sdk/WebApi/WebApi/ResultsHttpClient.cs b/src/Sdk/WebApi/WebApi/ResultsHttpClient.cs index 31819a4b2bf..f3b6eb728b0 100644 --- a/src/Sdk/WebApi/WebApi/ResultsHttpClient.cs +++ b/src/Sdk/WebApi/WebApi/ResultsHttpClient.cs @@ -514,7 +514,7 @@ public async Task UploadResultsDiagnosticLogsAsync(string planId, string jobId, private Step ConvertTimelineRecordToStep(TimelineRecord r) { - return new Step() + var step = new Step() { ExternalId = r.Id.ToString(), Number = r.Order.GetValueOrDefault(), @@ -522,8 +522,25 @@ private Step ConvertTimelineRecordToStep(TimelineRecord r) Status = ConvertStateToStatus(r.State.GetValueOrDefault()), StartedAt = r.StartTime?.ToString(Constants.TimestampFormat, CultureInfo.InvariantCulture), CompletedAt = r.FinishTime?.ToString(Constants.TimestampFormat, CultureInfo.InvariantCulture), - Conclusion = ConvertResultToConclusion(r.Result) + Conclusion = ConvertResultToConclusion(r.Result), + IsBackground = r.IsBackground, }; + + // Set background control type directly (no enum mapping needed) + if (!string.IsNullOrEmpty(r.BackgroundControlType)) + { + step.BackgroundControlType = r.BackgroundControlType; + } + if (r.BackgroundControlStepIds != null) + { + step.BackgroundControlStepIds = r.BackgroundControlStepIds; + } + if (!string.IsNullOrEmpty(r.ParallelGroupId)) + { + step.ParallelGroupId = r.ParallelGroupId; + } + + return step; } private Status ConvertStateToStatus(TimelineRecordState s)