// Copyright Epic Games, Inc. All Rights Reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using EpicGames.Core;
using EpicGames.Horde.Acls;
using EpicGames.Horde.Agents;
using EpicGames.Horde.Agents.Leases;
using EpicGames.Horde.Agents.Pools;
using EpicGames.Horde.Agents.Sessions;
using EpicGames.Horde.Artifacts;
using EpicGames.Horde.Commits;
using EpicGames.Horde.Jobs.Bisect;
using EpicGames.Horde.Jobs.Graphs;
using EpicGames.Horde.Jobs.Templates;
using EpicGames.Horde.Jobs.Timing;
using EpicGames.Horde.Logs;
using EpicGames.Horde.Notifications;
using EpicGames.Horde.Streams;
using EpicGames.Horde.Ugs;
using EpicGames.Horde.Users;
using Microsoft.Extensions.Logging;
#pragma warning disable CA1716
namespace EpicGames.Horde.Jobs
{
///
/// Document describing a job
///
public interface IJob
{
///
/// Job argument indicating a target that should be built
///
public const string TargetArgumentPrefix = "-Target=";
///
/// Name of the node which parses the buildgraph script
///
public const string SetupNodeName = "Setup Build";
///
/// Identifier for the job. Randomly generated.
///
public JobId Id { get; }
///
/// The stream that this job belongs to
///
public StreamId StreamId { get; }
///
/// The template ref id
///
public TemplateId TemplateId { get; }
///
/// The template that this job was created from
///
public ContentHash? TemplateHash { get; }
///
/// Hash of the graph definition
///
public ContentHash GraphHash { get; }
///
/// Graph for this job
///
public IGraph Graph { get; }
///
/// Id of the user that started this job
///
public UserId? StartedByUserId { get; }
///
/// Id of the user that aborted this job. Set to null if the job is not aborted.
///
public UserId? AbortedByUserId { get; }
///
/// Optional reason for why the job was canceled
///
public string? CancellationReason { get; }
///
/// Identifier of the bisect task that started this job
///
public BisectTaskId? StartedByBisectTaskId { get; }
///
/// Name of the job.
///
public string Name { get; }
///
/// The commit to build
///
public CommitIdWithOrder CommitId { get; }
///
/// The code commit for this build
///
public CommitIdWithOrder? CodeCommitId { get; }
///
/// The preflight changelist number
///
public CommitId? PreflightCommitId { get; }
///
/// Description for the shelved change if running a preflight
///
public string? PreflightDescription { get; }
///
/// Priority of this job
///
public Priority Priority { get; }
///
/// For preflights, submit the change if the job is successful
///
public bool AutoSubmit { get; }
///
/// The submitted changelist number
///
public int? AutoSubmitChange { get; }
///
/// Message produced by trying to auto-submit the change
///
public string? AutoSubmitMessage { get; }
///
/// Whether to update issues based on the outcome of this job
///
public bool UpdateIssues { get; }
///
/// Whether to promote issues by default based on the outcome of this job
///
public bool PromoteIssuesByDefault { get; }
///
/// Time that the job was created (in UTC)
///
public DateTime CreateTimeUtc { get; }
///
/// Options for executing the job
///
public JobOptions? JobOptions { get; }
///
/// Claims inherited from the user that started this job
///
public IReadOnlyList Claims { get; }
///
/// Array of jobstep runs
///
public IReadOnlyList Batches { get; }
///
/// Parameters for the job
///
public IReadOnlyDictionary Parameters { get; }
///
/// Optional user-defined properties for this job
///
public IReadOnlyList Arguments { get; }
///
/// Custom list of targets for the job. If null or empty, the list of targets is determined from the command line.
///
public IReadOnlyList? Targets { get; }
///
/// Additional arguments for the job, when a set of parameters are applied.
///
public IReadOnlyList AdditionalArguments { get; }
///
/// Environment variables for the job
///
public IReadOnlyDictionary Environment { get; }
///
/// Issues associated with this job
///
public IReadOnlyList Issues { get; }
///
/// Unique id for notifications
///
public NotificationTriggerId? NotificationTriggerId { get; }
///
/// Whether to show badges in UGS for this job
///
public bool ShowUgsBadges { get; }
///
/// Whether to show alerts in UGS for this job
///
public bool ShowUgsAlerts { get; }
///
/// Notification channel for this job.
///
public string? NotificationChannel { get; }
///
/// Notification channel filter for this job.
///
public string? NotificationChannelFilter { get; }
///
/// Mapping of label ids to notification trigger ids for notifications
///
public IReadOnlyDictionary LabelIdxToTriggerId { get; }
///
/// List of reports for this step
///
public IReadOnlyList? Reports { get; }
///
/// List of downstream job triggers
///
public IReadOnlyList ChainedJobs { get; }
///
/// The job which spawned this job
///
public JobId? ParentJobId { get; }
///
/// The job step which spawned this job
///
public JobStepId? ParentJobStepId { get; }
///
/// The last update time
///
public DateTime UpdateTimeUtc { get; }
///
/// The job meta data
///
public List Metadata { get; }
///
/// Update counter for this document. Any updates should compare-and-swap based on the value of this counter, or increment it in the case of server-side updates.
///
public int UpdateIndex { get; }
///
/// Gets the latest job state
///
/// Cancellation token for the operation
Task RefreshAsync(CancellationToken cancellationToken = default);
///
/// Attempt to get a batch with the given id
///
/// The job batch id
/// Receives the batch interface on success
/// True if the batch was found
bool TryGetBatch(JobStepBatchId batchId, [NotNullWhen(true)] out IJobStepBatch? batch);
///
/// Attempt to get a step with the given id
///
/// The job step id
/// Receives the step interface on success
/// True if the step was found
bool TryGetStep(JobStepId stepId, [NotNullWhen(true)] out IJobStep? step);
///
/// Attempt to delete the job
///
/// Cancellation token for the operation
/// True if the job was deleted. False if the job is not the latest revision.
Task TryDeleteAsync(CancellationToken cancellationToken = default);
///
/// Removes a job from the dispatch queue. Ignores the state of any batches still remaining to execute. Should only be used to correct for inconsistent state.
///
/// Cancellation token for the operation
///
Task TryRemoveFromDispatchQueueAsync(CancellationToken cancellationToken = default);
///
/// Updates a new job
///
/// Name of the job
/// Priority of the job
/// Automatically submit the job on completion
/// Changelist that was automatically submitted
///
/// Name of the user that aborted the job
/// Id for a notification trigger
/// New reports
/// New arguments for the job
/// New trigger ID for a label in the job
/// New downstream job id
/// Optional reason why the job was canceled
/// Cancellation token for the operation
Task TryUpdateJobAsync(string? name = null, Priority? priority = null, bool? autoSubmit = null, int? autoSubmitChange = null, string? autoSubmitMessage = null, UserId? abortedByUserId = null, NotificationTriggerId? notificationTriggerId = null, List? reports = null, List? arguments = null, KeyValuePair? labelIdxToTriggerId = null, KeyValuePair? jobTrigger = null, string? cancellationReason = null, CancellationToken cancellationToken = default);
///
/// Updates the state of a batch
///
/// Unique id of the batch to update
/// The new log file id
/// New state of the jobstep
/// Error code for the batch
/// Cancellation token for the operation
/// True if the job was updated, false if it was deleted
Task TryUpdateBatchAsync(JobStepBatchId batchId, LogId? newLogId, JobStepBatchState? newState, JobStepBatchError? newError, CancellationToken cancellationToken = default);
///
/// Update a jobstep state
///
/// Unique id of the batch containing the step
/// Unique id of the step to update
/// New state of the jobstep
/// New outcome of the jobstep
/// New error annotation for this jobstep
/// New state of request abort
/// New name of user that requested the abort
/// New log id for the jobstep
/// New id for a notification trigger
/// Whether the step should be retried
/// New priority for this step
/// New report documents
/// Property changes. Any properties with a null value will be removed.
/// The reason the job step was canceled
/// JobId of any job this step spawned
/// Cancellation token for the operation
/// True if the job was updated, false if it was deleted in the meantime
Task TryUpdateStepAsync(JobStepBatchId batchId, JobStepId stepId, JobStepState newState = default, JobStepOutcome newOutcome = default, JobStepError? newError = null, bool? newAbortRequested = null, UserId? newAbortByUserId = null, LogId? newLogId = null, NotificationTriggerId? newNotificationTriggerId = null, UserId? newRetryByUserId = null, Priority? newPriority = null, List? newReports = null, Dictionary? newProperties = null, string? newCancellationReason = null, JobId? newSpawnedJob = null, CancellationToken cancellationToken = default);
///
/// Attempts to update the node groups to be executed for a job. Fails if another write happens in the meantime.
///
/// New graph for this job
/// Cancellation token for the operation
/// True if the groups were updated to the given list. False if another write happened first.
Task TryUpdateGraphAsync(IGraph newGraph, CancellationToken cancellationToken = default);
///
/// Marks a job as skipped
///
/// Reason for this batch being failed
/// Cancellation token for the operation
/// Updated version of the job
Task TrySkipAllBatchesAsync(JobStepBatchError reason, CancellationToken cancellationToken = default);
///
/// Marks a batch as skipped
///
/// The batch to mark as skipped
/// Reason for this batch being failed
/// Cancellation token for the operation
/// Updated version of the job
Task TrySkipBatchAsync(JobStepBatchId batchId, JobStepBatchError reason, CancellationToken cancellationToken = default);
///
/// Abort an agent's lease, and update the payload accordingly
///
/// Index of the batch to cancel
/// Reason for this batch being failed
/// Cancellation token for the operation
/// True if the job is updated
Task TryFailBatchAsync(int batchIdx, JobStepBatchError reason, CancellationToken cancellationToken = default);
///
/// Attempt to assign a lease to execute a batch
///
/// Index of the batch
/// The pool id
/// New agent to execute the batch
/// Session of the agent that is to execute the batch
/// The lease unique id
/// Unique id of the log for the batch
/// Cancellation token for the operation
/// True if the batch is updated
Task TryAssignLeaseAsync(int batchIdx, PoolId poolId, AgentId agentId, SessionId sessionId, LeaseId leaseId, LogId logId, CancellationToken cancellationToken = default);
///
/// Cancel a lease reservation on a batch (before it has started)
///
/// Index of the batch to cancel
/// Cancellation token for the operation
/// True if the job is updated
Task TryCancelLeaseAsync(int batchIdx, CancellationToken cancellationToken = default);
}
///
/// Extension methods for jobs
///
public static class JobExtensions
{
///
/// Gets the current job state
///
/// The job document
/// Job state
public static JobState GetState(this IJob job)
{
bool waiting = false;
foreach (IJobStepBatch batch in job.Batches)
{
foreach (IJobStep step in batch.Steps)
{
if (step.State == JobStepState.Running)
{
return JobState.Running;
}
else if (step.State == JobStepState.Ready || step.State == JobStepState.Waiting)
{
if (batch.State == JobStepBatchState.Starting || batch.State == JobStepBatchState.Running)
{
return JobState.Running;
}
else
{
waiting = true;
}
}
}
}
return waiting ? JobState.Waiting : JobState.Complete;
}
///
/// Gets the outcome for a particular named target. May be an aggregate or node name.
///
/// The job to check
/// The step outcome
public static (JobStepState, JobStepOutcome) GetTargetState(this IJob job)
{
IReadOnlyDictionary nodeToStep = GetStepForNodeMap(job);
return GetTargetState(nodeToStep.Values);
}
///
/// Gets the outcome for a particular named target. May be an aggregate or node name.
///
/// The job to check
/// Graph for the job
/// Target to find an outcome for
/// The step outcome
public static (JobStepState, JobStepOutcome)? GetTargetState(this IJob job, IGraph graph, string? target)
{
if (target == null)
{
return GetTargetState(job);
}
NodeRef nodeRef;
if (graph.TryFindNode(target, out nodeRef))
{
IJobStep? step;
if (job.TryGetStepForNode(nodeRef, out step))
{
return (step.State, step.Outcome);
}
else
{
return null;
}
}
IAggregate? aggregate;
if (graph.TryFindAggregate(target, out aggregate))
{
IReadOnlyDictionary stepForNode = GetStepForNodeMap(job);
List steps = new List();
foreach (NodeRef aggregateNodeRef in aggregate.Nodes)
{
IJobStep? step;
if (!stepForNode.TryGetValue(aggregateNodeRef, out step))
{
return null;
}
steps.Add(step);
}
return GetTargetState(steps);
}
return null;
}
///
/// Gets the outcome for a particular named target. May be an aggregate or node name.
///
/// Steps to include
/// The step outcome
public static (JobStepState, JobStepOutcome) GetTargetState(IEnumerable steps)
{
bool anySkipped = false;
bool anyWarnings = false;
bool anyFailed = false;
bool anyPending = false;
foreach (IJobStep step in steps)
{
anyPending |= step.IsPending();
anySkipped |= step.State == JobStepState.Aborted || step.State == JobStepState.Skipped;
anyFailed |= (step.Outcome == JobStepOutcome.Failure);
anyWarnings |= (step.Outcome == JobStepOutcome.Warnings);
}
JobStepState newState = anyPending ? JobStepState.Running : JobStepState.Completed;
JobStepOutcome newOutcome = anyFailed ? JobStepOutcome.Failure : anyWarnings ? JobStepOutcome.Warnings : anySkipped ? JobStepOutcome.Unspecified : JobStepOutcome.Success;
return (newState, newOutcome);
}
///
/// Gets the outcome for a particular named target. May be an aggregate or node name.
///
/// The job to check
/// Graph for the job
/// Target to find an outcome for
/// The step outcome
public static JobStepOutcome GetTargetOutcome(this IJob job, IGraph graph, string target)
{
NodeRef nodeRef;
if (graph.TryFindNode(target, out nodeRef))
{
IJobStep? step;
if (job.TryGetStepForNode(nodeRef, out step))
{
return step.Outcome;
}
else
{
return JobStepOutcome.Unspecified;
}
}
IAggregate? aggregate;
if (graph.TryFindAggregate(target, out aggregate))
{
IReadOnlyDictionary stepForNode = GetStepForNodeMap(job);
bool warnings = false;
foreach (NodeRef aggregateNodeRef in aggregate.Nodes)
{
IJobStep? step;
if (!stepForNode.TryGetValue(aggregateNodeRef, out step))
{
return JobStepOutcome.Unspecified;
}
if (step.Outcome == JobStepOutcome.Failure)
{
return JobStepOutcome.Failure;
}
warnings |= (step.Outcome == JobStepOutcome.Warnings);
}
return warnings ? JobStepOutcome.Warnings : JobStepOutcome.Success;
}
return JobStepOutcome.Unspecified;
}
///
/// Gets the job step for a particular node
///
/// The job to search
/// The node ref
/// Receives the jobstep on success
/// True if the jobstep was founds
public static bool TryGetStepForNode(this IJob job, NodeRef nodeRef, [NotNullWhen(true)] out IJobStep? jobStep)
{
jobStep = null;
foreach (IJobStepBatch batch in job.Batches)
{
if (batch.GroupIdx == nodeRef.GroupIdx)
{
foreach (IJobStep batchStep in batch.Steps)
{
if (batchStep.NodeIdx == nodeRef.NodeIdx)
{
jobStep = batchStep;
}
}
}
}
return jobStep != null;
}
///
/// Gets a dictionary that maps objects to their associated
/// objects on a .
///
/// The job document
/// Map of to
public static IReadOnlyDictionary GetStepForNodeMap(this IJob job)
{
Dictionary stepForNode = new Dictionary();
foreach (IJobStepBatch batch in job.Batches)
{
foreach (IJobStep batchStep in batch.Steps)
{
NodeRef batchNodeRef = new NodeRef(batch.GroupIdx, batchStep.NodeIdx);
stepForNode[batchNodeRef] = batchStep;
}
}
return stepForNode;
}
///
/// Find the latest step executing the given node
///
/// The job being run
/// Node to find
/// The retried step information
public static JobStepRefId? FindLatestStepForNode(this IJob job, NodeRef nodeRef)
{
for (int batchIdx = job.Batches.Count - 1; batchIdx >= 0; batchIdx--)
{
IJobStepBatch batch = job.Batches[batchIdx];
if (batch.GroupIdx == nodeRef.GroupIdx)
{
for (int stepIdx = batch.Steps.Count - 1; stepIdx >= 0; stepIdx--)
{
IJobStep step = batch.Steps[stepIdx];
if (step.NodeIdx == nodeRef.NodeIdx)
{
return new JobStepRefId(job.Id, batch.Id, step.Id);
}
}
}
}
return null;
}
///
/// Gets the estimated timing info for all nodes in the job
///
/// The job document
/// Graph for this job
/// Job timing information
/// Logger for any diagnostic messages
/// Map of node to expected timing info
public static Dictionary GetTimingInfo(this IJob job, IGraph graph, IJobTiming jobTiming, ILogger logger)
{
#pragma warning disable IDE0054 // Use compound assignment
TimeSpan currentTime = DateTime.UtcNow - job.CreateTimeUtc;
Dictionary nodeToTimingInfo = graph.Groups.SelectMany(x => x.Nodes).ToDictionary(x => x, x => new TimingInfo());
foreach (IJobStepBatch batch in job.Batches)
{
INodeGroup group = graph.Groups[batch.GroupIdx];
// Step through the batch, keeping track of the time that things finish.
TimingInfo timingInfo = new TimingInfo();
// Wait for the dependencies for the batch to start
HashSet dependencyNodes = batch.GetStartDependencies(graph.Groups);
timingInfo.WaitForAll(dependencyNodes.Select(x => nodeToTimingInfo[x]));
// If the batch has actually started, correct the expected time to use this instead
if (batch.StartTimeUtc != null)
{
timingInfo.TotalTimeToComplete = batch.StartTimeUtc - job.CreateTimeUtc;
}
// Get the average times for this batch
TimeSpan? averageWaitTime = GetAverageWaitTime(graph, batch, jobTiming, logger);
TimeSpan? averageInitTime = GetAverageInitTime(graph, batch, jobTiming, logger);
// Update the wait times and initialization times along this path
timingInfo.TotalWaitTime = timingInfo.TotalWaitTime + (batch.GetWaitTime() ?? averageWaitTime);
timingInfo.TotalInitTime = timingInfo.TotalInitTime + (batch.GetInitTime() ?? averageInitTime);
// Update the average wait and initialization times too
timingInfo.AverageTotalWaitTime = timingInfo.AverageTotalWaitTime + averageWaitTime;
timingInfo.AverageTotalInitTime = timingInfo.AverageTotalInitTime + averageInitTime;
// Step through the batch, updating the expected times as we go
foreach (IJobStep step in batch.Steps)
{
INode node = group.Nodes[step.NodeIdx];
// Get the timing for this step
IJobStepTiming? stepTimingInfo;
jobTiming.TryGetStepTiming(node.Name, logger, out stepTimingInfo);
// If the step has already started, update the actual time to reach this point
if (step.StartTimeUtc != null)
{
timingInfo.TotalTimeToComplete = step.StartTimeUtc.Value - job.CreateTimeUtc;
}
// If the step hasn't started yet, make sure the start time is later than the current time
if (step.StartTimeUtc == null && currentTime > timingInfo.TotalTimeToComplete)
{
timingInfo.TotalTimeToComplete = currentTime;
}
// Wait for all the node dependencies to complete
timingInfo.WaitForAll(graph.GetDependencies(node).Select(x => nodeToTimingInfo[x]));
// If the step has actually finished, correct the time to use that instead
if (step.FinishTimeUtc != null)
{
timingInfo.TotalTimeToComplete = step.FinishTimeUtc.Value - job.CreateTimeUtc;
}
else
{
timingInfo.TotalTimeToComplete = timingInfo.TotalTimeToComplete + NullableTimeSpanFromSeconds(stepTimingInfo?.AverageDuration);
}
// If the step hasn't finished yet, make sure the start time is later than the current time
if (step.FinishTimeUtc == null && currentTime > timingInfo.TotalTimeToComplete)
{
timingInfo.TotalTimeToComplete = currentTime;
}
// Update the average time to complete
timingInfo.AverageTotalTimeToComplete = timingInfo.AverageTotalTimeToComplete + NullableTimeSpanFromSeconds(stepTimingInfo?.AverageDuration);
// Add it to the lookup
TimingInfo nodeTimingInfo = new TimingInfo(timingInfo);
nodeTimingInfo.StepTiming = stepTimingInfo;
nodeToTimingInfo[node] = nodeTimingInfo;
}
}
return nodeToTimingInfo;
#pragma warning restore IDE0054 // Use compound assignment
}
///
/// Gets the step completion info of the job.
///
/// The job document
/// The job's step completion info.
/// Any steps not in a terminal state (, , ) are excluded in total counts except .
public static JobStepsCompletionInfo GetStepCompletionInfo(this IJob job)
{
JobStepsCompletionInfo jobStepsCompletionInfo = new JobStepsCompletionInfo();
foreach (IJobStepBatch batch in job.Batches)
{
foreach (IJobStep step in batch.Steps)
{
jobStepsCompletionInfo.StepTotalCount++;
if (step.State != JobStepState.Completed && step.State != JobStepState.Aborted && step.State != JobStepState.Skipped)
{
continue;
}
if (step.FinishTimeUtc != null && step.StartTimeUtc != null)
{
TimeSpan? stepDuration = step.FinishTimeUtc.Value - step.StartTimeUtc;
if (stepDuration != null)
{
jobStepsCompletionInfo.JobStepsTotalTime += (float)stepDuration.Value.TotalSeconds;
}
}
switch (step.Outcome)
{
case JobStepOutcome.Success:
jobStepsCompletionInfo.StepPassCount++;
break;
case JobStepOutcome.Warnings:
jobStepsCompletionInfo.StepWarningCount++;
break;
case JobStepOutcome.Failure:
jobStepsCompletionInfo.StepFailureCount++;
break;
}
}
}
return jobStepsCompletionInfo;
}
///
/// Gets the average wait time for this batch
///
/// Graph for the job
/// The batch to get timing info for
/// The job timing information
/// Logger for diagnostic info
/// Wait time for the batch
public static TimeSpan? GetAverageWaitTime(IGraph graph, IJobStepBatch batch, IJobTiming jobTiming, ILogger logger)
{
TimeSpan? waitTime = null;
foreach (IJobStep step in batch.Steps)
{
INode node = graph.Groups[batch.GroupIdx].Nodes[step.NodeIdx];
if (jobTiming.TryGetStepTiming(node.Name, logger, out IJobStepTiming? timingInfo))
{
if (timingInfo.AverageWaitTime != null)
{
TimeSpan stepWaitTime = TimeSpan.FromSeconds(timingInfo.AverageWaitTime.Value);
if (waitTime == null || stepWaitTime > waitTime.Value)
{
waitTime = stepWaitTime;
}
}
}
}
return waitTime;
}
///
/// Gets the average initialization time for this batch
///
/// Graph for the job
/// The batch to get timing info for
/// The job timing information
/// Logger for diagnostic messages
/// Initialization time for this batch
public static TimeSpan? GetAverageInitTime(IGraph graph, IJobStepBatch batch, IJobTiming jobTiming, ILogger logger)
{
TimeSpan? initTime = null;
foreach (IJobStep step in batch.Steps)
{
INode node = graph.Groups[batch.GroupIdx].Nodes[step.NodeIdx];
if (jobTiming.TryGetStepTiming(node.Name, logger, out IJobStepTiming? timingInfo))
{
if (timingInfo.AverageInitTime != null)
{
TimeSpan stepInitTime = TimeSpan.FromSeconds(timingInfo.AverageInitTime.Value);
if (initTime == null || stepInitTime > initTime.Value)
{
initTime = stepInitTime;
}
}
}
}
return initTime;
}
///
/// Creates a nullable timespan from a nullable number of seconds
///
/// The number of seconds to construct from
/// TimeSpan object
static TimeSpan? NullableTimeSpanFromSeconds(float? seconds)
{
if (seconds == null)
{
return null;
}
else
{
return TimeSpan.FromSeconds(seconds.Value);
}
}
///
/// Attempts to get a batch with the given id
///
/// The job document
/// The batch id
/// The step id
/// On success, receives the step object
/// True if the batch was found
public static bool TryGetStep(this IJob job, JobStepBatchId batchId, JobStepId stepId, [NotNullWhen(true)] out IJobStep? step)
{
if (!job.TryGetStep(stepId, out step) || step.Batch.Id != batchId)
{
step = null;
return false;
}
return true;
}
///
/// Finds the set of nodes affected by a label
///
/// The job document
/// Graph definition for the job
/// Index of the label. -1 or Graph.Labels.Count are treated as referring to the default lable.
/// Set of nodes affected by the given label
public static HashSet GetNodesForLabel(this IJob job, IGraph graph, int labelIdx)
{
if (labelIdx != -1 && labelIdx != graph.Labels.Count)
{
// Return all the nodes included by the label
return new HashSet(graph.Labels[labelIdx].IncludedNodes);
}
else
{
// Set of nodes which are not covered by an existing label, initially containing everything
HashSet unlabeledNodes = new HashSet();
for (int groupIdx = 0; groupIdx < graph.Groups.Count; groupIdx++)
{
INodeGroup group = graph.Groups[groupIdx];
for (int nodeIdx = 0; nodeIdx < group.Nodes.Count; nodeIdx++)
{
unlabeledNodes.Add(new NodeRef(groupIdx, nodeIdx));
}
}
// Remove all the nodes that are part of an active label
IReadOnlyDictionary stepForNode = job.GetStepForNodeMap();
foreach (ILabel label in graph.Labels)
{
if (label.RequiredNodes.Any(x => stepForNode.ContainsKey(x)))
{
unlabeledNodes.ExceptWith(label.IncludedNodes);
}
}
return unlabeledNodes;
}
}
///
/// Create a list of aggregate responses, combining the graph definitions with the state of the job
///
/// The job document
/// Graph definition for the job
/// List to receive all the responses
/// The default label state
public static GetDefaultLabelStateResponse? GetLabelStateResponses(this IJob job, IGraph graph, List responses)
{
// Create a lookup from noderef to step information
IReadOnlyDictionary stepForNode = job.GetStepForNodeMap();
// Set of nodes which are not covered by an existing label, initially containing everything
HashSet unlabeledNodes = new HashSet();
for (int groupIdx = 0; groupIdx < graph.Groups.Count; groupIdx++)
{
INodeGroup group = graph.Groups[groupIdx];
for (int nodeIdx = 0; nodeIdx < group.Nodes.Count; nodeIdx++)
{
unlabeledNodes.Add(new NodeRef(groupIdx, nodeIdx));
}
}
// Create the responses
foreach (ILabel label in graph.Labels)
{
// Refresh the state for this label
LabelState newState = LabelState.Unspecified;
foreach (NodeRef requiredNodeRef in label.RequiredNodes)
{
if (stepForNode.ContainsKey(requiredNodeRef))
{
newState = LabelState.Complete;
break;
}
}
// Refresh the outcome
LabelOutcome newOutcome = LabelOutcome.Success;
if (newState == LabelState.Complete)
{
GetLabelState(label.IncludedNodes, stepForNode, out newState, out newOutcome);
unlabeledNodes.ExceptWith(label.IncludedNodes);
}
// Create the response
GetLabelStateResponse response = new GetLabelStateResponse();
response.DashboardName = label.DashboardName;
response.DashboardCategory = label.DashboardCategory;
response.UgsName = label.UgsName;
response.UgsProject = label.UgsProject;
response.State = newState;
response.Outcome = newOutcome;
foreach (NodeRef includedNodeRef in label.IncludedNodes)
{
IJobStep? includedStep;
if (stepForNode.TryGetValue(includedNodeRef, out includedStep))
{
response.Steps.Add(includedStep.Id);
}
}
responses.Add(response);
}
// Remove all the nodes that don't have a step
unlabeledNodes.RemoveWhere(x => !stepForNode.ContainsKey(x));
// Remove successful "setup build" nodes from the list
if (graph.Groups.Count > 1 && graph.Groups[0].Nodes.Count > 0)
{
INode node = graph.Groups[0].Nodes[0];
if (node.Name == IJob.SetupNodeName)
{
NodeRef nodeRef = new NodeRef(0, 0);
if (unlabeledNodes.Contains(nodeRef))
{
IJobStep step = stepForNode[nodeRef];
if (step.State == JobStepState.Completed && step.Outcome == JobStepOutcome.Success && responses.Count > 0)
{
unlabeledNodes.Remove(nodeRef);
}
}
}
}
// Add a response for everything not included elsewhere.
GetLabelState(unlabeledNodes, stepForNode, out LabelState otherState, out LabelOutcome otherOutcome);
GetDefaultLabelStateResponse defaultResponse = new GetDefaultLabelStateResponse();
defaultResponse.State = otherState;
defaultResponse.Outcome = otherOutcome;
defaultResponse.Nodes.AddRange(unlabeledNodes.Select(x => graph.GetNode(x).Name));
defaultResponse.Steps.AddRange(unlabeledNodes.Select(x => stepForNode[x].Id));
return defaultResponse;
}
///
/// Get the states of all labels for this job
///
/// The job to get states for
/// The graph for this job
/// Collection of label states by label index
public static IReadOnlyList<(LabelState, LabelOutcome)> GetLabelStates(this IJob job, IGraph graph)
{
IReadOnlyDictionary stepForNodeRef = job.GetStepForNodeMap();
List<(LabelState, LabelOutcome)> states = new List<(LabelState, LabelOutcome)>();
for (int idx = 0; idx < graph.Labels.Count; idx++)
{
ILabel label = graph.Labels[idx];
// Default the label to the unspecified state
LabelState newState = LabelState.Unspecified;
LabelOutcome newOutcome = LabelOutcome.Unspecified;
// Check if the label should be included
if (label.RequiredNodes.Any(x => stepForNodeRef.ContainsKey(x)))
{
// Combine the state of the steps contributing towards this label
bool anySkipped = false;
bool anyWarnings = false;
bool anyFailed = false;
bool anyPending = false;
foreach (NodeRef includedNode in label.IncludedNodes)
{
IJobStep? step;
if (stepForNodeRef.TryGetValue(includedNode, out step))
{
anyPending |= step.IsPending();
anySkipped |= step.State == JobStepState.Aborted || step.State == JobStepState.Skipped;
anyFailed |= (step.Outcome == JobStepOutcome.Failure);
anyWarnings |= (step.Outcome == JobStepOutcome.Warnings);
}
}
// Figure out the overall label state
newState = anyPending ? LabelState.Running : LabelState.Complete;
newOutcome = anyFailed ? LabelOutcome.Failure : anyWarnings ? LabelOutcome.Warnings : anySkipped ? LabelOutcome.Unspecified : LabelOutcome.Success;
}
states.Add((newState, newOutcome));
}
return states;
}
///
/// Get the states of all UGS badges for this job
///
/// The job to get states for
/// The graph for this job
/// List of badge states
public static Dictionary GetUgsBadgeStates(this IJob job, IGraph graph)
{
IReadOnlyList<(LabelState, LabelOutcome)> labelStates = GetLabelStates(job, graph);
return job.GetUgsBadgeStates(graph, labelStates);
}
///
/// Get the states of all UGS badges for this job
///
/// The job to get states for
/// The graph for this job
/// The existing label states to get the UGS badge states from
/// List of badge states
#pragma warning disable IDE0060 // Remove unused parameter
public static Dictionary GetUgsBadgeStates(this IJob job, IGraph graph, IReadOnlyList<(LabelState, LabelOutcome)> labelStates)
#pragma warning restore IDE0060 // Remove unused parameter
{
Dictionary ugsBadgeStates = new Dictionary();
for (int labelIdx = 0; labelIdx < labelStates.Count; ++labelIdx)
{
if (graph.Labels[labelIdx].UgsName == null)
{
continue;
}
(LabelState state, LabelOutcome outcome) = labelStates[labelIdx];
switch (state)
{
case LabelState.Complete:
{
switch (outcome)
{
case LabelOutcome.Success:
{
ugsBadgeStates.Add(labelIdx, UgsBadgeState.Success);
break;
}
case LabelOutcome.Warnings:
{
ugsBadgeStates.Add(labelIdx, UgsBadgeState.Warning);
break;
}
case LabelOutcome.Failure:
{
ugsBadgeStates.Add(labelIdx, UgsBadgeState.Failure);
break;
}
case LabelOutcome.Unspecified:
{
ugsBadgeStates.Add(labelIdx, UgsBadgeState.Skipped);
break;
}
}
break;
}
case LabelState.Running:
{
ugsBadgeStates.Add(labelIdx, UgsBadgeState.Starting);
break;
}
case LabelState.Unspecified:
{
ugsBadgeStates.Add(labelIdx, UgsBadgeState.Skipped);
break;
}
}
}
return ugsBadgeStates;
}
///
/// Gets the state of a job, as a label that includes all steps
///
/// The job to query
/// Map from node to step
/// Receives the state of the label
/// Receives the outcome of the label
public static void GetJobState(this IJob job, IReadOnlyDictionary stepForNode, out LabelState newState, out LabelOutcome newOutcome)
{
List nodes = new List();
foreach (IJobStepBatch batch in job.Batches)
{
foreach (IJobStep step in batch.Steps)
{
nodes.Add(new NodeRef(batch.GroupIdx, step.NodeIdx));
}
}
GetLabelState(nodes, stepForNode, out newState, out newOutcome);
}
///
/// Gets the state of a label
///
/// Nodes to include in this label
/// Map from node to step
/// Receives the state of the label
/// Receives the outcome of the label
public static void GetLabelState(IEnumerable includedNodes, IReadOnlyDictionary stepForNode, out LabelState newState, out LabelOutcome newOutcome)
{
newState = LabelState.Complete;
newOutcome = LabelOutcome.Success;
foreach (NodeRef includedNodeRef in includedNodes)
{
IJobStep? includedStep;
if (stepForNode.TryGetValue(includedNodeRef, out includedStep))
{
// Update the state
if (includedStep.State != JobStepState.Completed && includedStep.State != JobStepState.Skipped && includedStep.State != JobStepState.Aborted)
{
newState = LabelState.Running;
}
// Update the outcome
if (includedStep.State == JobStepState.Skipped || includedStep.State == JobStepState.Aborted || includedStep.Outcome == JobStepOutcome.Failure)
{
newOutcome = LabelOutcome.Failure;
}
else if (includedStep.Outcome == JobStepOutcome.Warnings && newOutcome == LabelOutcome.Success)
{
newOutcome = LabelOutcome.Warnings;
}
}
}
}
///
/// Gets a key attached to all artifacts produced for a job
///
public static string GetArtifactKey(this IJob job)
{
return $"job:{job.Id}";
}
///
/// Gets a key attached to all artifacts produced for a job step
///
public static string GetArtifactKey(this IJob job, IJobStep jobStep)
{
return $"job:{job.Id}/step:{jobStep.Id}";
}
///
public static async Task SkipBatchAsync(this IJob job, JobStepBatchId batchId, JobStepBatchError error, CancellationToken cancellationToken)
{
for (; ; )
{
IJob? newJob = await job.TrySkipBatchAsync(batchId, error, cancellationToken);
if (newJob != null)
{
return newJob;
}
newJob = await job.RefreshAsync(cancellationToken);
if (newJob == null)
{
return null;
}
job = newJob;
}
}
///
public static async Task SkipAllBatchesAsync(this IJob job, JobStepBatchError reason, CancellationToken cancellationToken)
{
for (; ; )
{
IJob? newJob = await job.TrySkipAllBatchesAsync(reason, cancellationToken);
if (newJob != null)
{
return newJob;
}
newJob = await job.RefreshAsync(cancellationToken);
if (newJob == null)
{
return null;
}
job = newJob;
}
}
}
///
/// Stores information about a batch of job steps
///
public interface IJobStepBatch
{
///
/// Job that this batch belongs to
///
public IJob Job { get; }
///
/// Unique id for this group
///
public JobStepBatchId Id { get; }
///
/// The type of agent to execute this group
///
public string AgentType { get; }
///
/// The log file id for this batch
///
public LogId? LogId { get; }
///
/// The node group for this batch
///
public INodeGroup Group { get; }
///
/// Index of the group being executed
///
public int GroupIdx { get; }
///
/// The state of this group
///
public JobStepBatchState State { get; }
///
/// Error associated with this group
///
public JobStepBatchError Error { get; }
///
/// Steps within this run
///
public IReadOnlyList Steps { get; }
///
/// The pool that this agent was taken from
///
public PoolId? PoolId { get; }
///
/// The agent assigned to execute this group
///
public AgentId? AgentId { get; }
///
/// The agent session that is executing this group
///
public SessionId? SessionId { get; }
///
/// The lease that's executing this group
///
public LeaseId? LeaseId { get; }
///
/// The weighted priority of this batch for the scheduler
///
public int SchedulePriority { get; }
///
/// Time at which the group became ready (UTC).
///
public DateTime? ReadyTimeUtc { get; }
///
/// Time at which the group started (UTC).
///
public DateTime? StartTimeUtc { get; }
///
/// Time at which the group finished (UTC)
///
public DateTime? FinishTimeUtc { get; }
}
///
/// Extension methods for IJobStepBatch
///
public static class JobStepBatchExtensions
{
///
/// Attempts to get a step with the given id
///
/// The batch to search
/// The step id
/// On success, receives the step object
/// True if the step was found
public static bool TryGetStep(this IJobStepBatch batch, JobStepId stepId, [NotNullWhen(true)] out IJobStep? step)
{
step = batch.Steps.FirstOrDefault(x => x.Id == stepId);
return step != null;
}
///
/// Determines if new steps can be appended to this batch. We do not allow this after the last step has been completed, because the agent is shutting down.
///
/// The batch to search
/// True if new steps can be appended to this batch
public static bool CanBeAppendedTo(this IJobStepBatch batch)
{
return batch.State <= JobStepBatchState.Running;
}
///
/// Gets the wait time for this batch
///
/// The batch to search
/// Wait time for the batch
public static TimeSpan? GetWaitTime(this IJobStepBatch batch)
{
if (batch.StartTimeUtc == null || batch.ReadyTimeUtc == null)
{
return null;
}
else
{
return batch.StartTimeUtc.Value - batch.ReadyTimeUtc.Value;
}
}
///
/// Gets the initialization time for this batch
///
/// The batch to search
/// Initialization time for this batch
public static TimeSpan? GetInitTime(this IJobStepBatch batch)
{
if (batch.StartTimeUtc != null)
{
foreach (IJobStep step in batch.Steps)
{
if (step.StartTimeUtc != null)
{
return step.StartTimeUtc - batch.StartTimeUtc.Value;
}
}
}
return null;
}
///
/// Get the dependencies required for this batch to start, taking run-early nodes into account
///
/// The batch to search
/// List of node groups
/// Set of nodes that must have completed for this batch to start
public static HashSet GetStartDependencies(this IJobStepBatch batch, IReadOnlyList groups)
{
List batchNodes = batch.Steps.ConvertAll(x => groups[batch.GroupIdx].Nodes[x.NodeIdx]);
return GetStartDependencies(batchNodes, groups);
}
///
/// Get the dependencies required for this batch to start, taking run-early nodes into account
///
/// Nodes in the batch to search
/// List of node groups
/// Set of nodes that must have completed for this batch to start
public static HashSet GetStartDependencies(IEnumerable batchNodes, IReadOnlyList groups)
{
// Find all the nodes that this group will start with.
List nodes = new List(batchNodes);
if (nodes.Any(x => x.RunEarly))
{
nodes.RemoveAll(x => !x.RunEarly);
}
// Find all their dependencies
HashSet dependencies = new HashSet();
foreach (INode node in nodes)
{
dependencies.UnionWith(node.InputDependencies.Select(x => groups[x.GroupIdx].Nodes[x.NodeIdx]));
dependencies.UnionWith(node.OrderDependencies.Select(x => groups[x.GroupIdx].Nodes[x.NodeIdx]));
}
// Exclude all the dependencies within the same group
dependencies.ExceptWith(batchNodes);
return dependencies;
}
}
///
/// Embedded jobstep document
///
public interface IJobStep
{
///
/// Job that this step belongs to
///
public IJob Job { get; }
///
/// Batch that this step belongs to
///
public IJobStepBatch Batch { get; }
///
/// Unique ID assigned to this jobstep. A new id is generated whenever a jobstep's order is changed.
///
public JobStepId Id { get; }
///
/// The node for this step
///
public INode Node { get; }
///
/// Index of the node which this jobstep is to execute
///
public int NodeIdx { get; }
///
/// The name of this node
///
public string Name { get; }
///
/// References to inputs for this node
///
public IReadOnlyList Inputs { get; }
///
/// List of output names
///
public IReadOnlyList OutputNames { get; }
///
/// Indices of nodes which must have succeeded for this node to run
///
public IReadOnlyList InputDependencies { get; }
///
/// Indices of nodes which must have completed for this node to run
///
public IReadOnlyList OrderDependencies { get; }
///
/// Whether this node can be run multiple times
///
public bool AllowRetry { get; }
///
/// This node can start running early, before dependencies of other nodes in the same group are complete
///
public bool RunEarly { get; }
///
/// Whether to include warnings in the output (defaults to true)
///
public bool Warnings { get; }
///
/// List of credentials required for this node. Each entry maps an environment variable name to a credential in the form "CredentialName.PropertyName".
///
public IReadOnlyDictionary? Credentials { get; }
///
/// Annotations for this node
///
public IReadOnlyNodeAnnotations Annotations { get; }
///
/// Metadata for this node
///
public IReadOnlyList Metadata { get; }
///
/// Current state of the job step. This is updated automatically when runs complete.
///
public JobStepState State { get; }
///
/// Current outcome of the jobstep
///
public JobStepOutcome Outcome { get; }
///
/// Error from executing this step
///
public JobStepError Error { get; }
///
/// The log id for this step
///
public LogId? LogId { get; }
///
/// Unique id for notifications
///
public NotificationTriggerId? NotificationTriggerId { get; }
///
/// Time at which the batch transitioned to the ready state (UTC).
///
public DateTime? ReadyTimeUtc { get; }
///
/// Time at which the batch transitioned to the executing state (UTC).
///
public DateTime? StartTimeUtc { get; }
///
/// Time at which the run finished (UTC)
///
public DateTime? FinishTimeUtc { get; }
///
/// Override for the priority of this step
///
public Priority? Priority { get; }
///
/// If a retry is requested, stores the name of the user that requested it
///
public UserId? RetriedByUserId { get; }
///
/// Signal if a step should be aborted
///
public bool AbortRequested { get; }
///
/// If an abort is requested, stores the id of the user that requested it
///
public UserId? AbortedByUserId { get; }
///
/// Optional reason for why the job step was canceled
///
public string? CancellationReason { get; }
///
/// List of reports for this step
///
public IReadOnlyList? Reports { get; }
///
/// List of jobs this step has spawned
///
public IReadOnlyList? SpawnedJobs { get; }
///
/// Reports for this jobstep.
///
public IReadOnlyDictionary? Properties { get; }
}
///
/// Extension methods for job steps
///
public static class JobStepExtensions
{
///
/// Determines if a jobstep state is completed, skipped, or aborted.
///
/// True if the step is completed, skipped, or aborted
public static bool IsPendingState(JobStepState state)
{
return state != JobStepState.Aborted && state != JobStepState.Completed && state != JobStepState.Skipped;
}
///
/// Determines if a jobstep is done by checking to see if it is completed, skipped, or aborted.
///
/// True if the step is completed, skipped, or aborted
public static bool IsPending(this IJobStep step)
=> IsPendingState(step.State);
///
/// Determine if a step should be timed out
///
///
///
///
public static bool HasTimedOut(this IJobStep step, DateTime utcNow)
{
if (step.State == JobStepState.Running && step.StartTimeUtc != null)
{
TimeSpan elapsed = utcNow - step.StartTimeUtc.Value;
if (elapsed > TimeSpan.FromHours(24.0))
{
return true;
}
}
return false;
}
}
///
/// Cumulative timing information to reach a certain point in a job
///
public class TimingInfo
{
///
/// Wait time on the critical path
///
public TimeSpan? TotalWaitTime { get; set; }
///
/// Sync time on the critical path
///
public TimeSpan? TotalInitTime { get; set; }
///
/// Duration to this point
///
public TimeSpan? TotalTimeToComplete { get; set; }
///
/// Average wait time to this point
///
public TimeSpan? AverageTotalWaitTime { get; set; }
///
/// Average sync time to this point
///
public TimeSpan? AverageTotalInitTime { get; set; }
///
/// Average duration to this point
///
public TimeSpan? AverageTotalTimeToComplete { get; set; }
///
/// Individual step timing information
///
public IJobStepTiming? StepTiming { get; set; }
///
/// Constructor
///
public TimingInfo()
{
TotalWaitTime = TimeSpan.Zero;
TotalInitTime = TimeSpan.Zero;
TotalTimeToComplete = TimeSpan.Zero;
AverageTotalWaitTime = TimeSpan.Zero;
AverageTotalInitTime = TimeSpan.Zero;
AverageTotalTimeToComplete = TimeSpan.Zero;
}
///
/// Copy constructor
///
/// The timing info object to copy from
public TimingInfo(TimingInfo other)
{
TotalWaitTime = other.TotalWaitTime;
TotalInitTime = other.TotalInitTime;
TotalTimeToComplete = other.TotalTimeToComplete;
AverageTotalWaitTime = other.AverageTotalWaitTime;
AverageTotalInitTime = other.AverageTotalInitTime;
AverageTotalTimeToComplete = other.AverageTotalTimeToComplete;
}
///
/// Modifies this timing to wait for another timing
///
/// The other node to wait for
public void WaitFor(TimingInfo other)
{
if (TotalTimeToComplete != null)
{
if (other.TotalTimeToComplete == null || other.TotalTimeToComplete.Value > TotalTimeToComplete.Value)
{
TotalInitTime = other.TotalInitTime;
TotalWaitTime = other.TotalWaitTime;
TotalTimeToComplete = other.TotalTimeToComplete;
}
}
if (AverageTotalTimeToComplete != null)
{
if (other.AverageTotalTimeToComplete == null || other.AverageTotalTimeToComplete.Value > AverageTotalTimeToComplete.Value)
{
AverageTotalInitTime = other.AverageTotalInitTime;
AverageTotalWaitTime = other.AverageTotalWaitTime;
AverageTotalTimeToComplete = other.AverageTotalTimeToComplete;
}
}
}
///
/// Waits for all the given timing info objects to complete
///
/// Other timing info objects to wait for
public void WaitForAll(IEnumerable others)
{
foreach (TimingInfo other in others)
{
WaitFor(other);
}
}
///
/// Constructs a new TimingInfo object which represents the last TimingInfo to finish
///
/// TimingInfo objects to wait for
/// New TimingInfo instance
public static TimingInfo Max(IEnumerable others)
{
TimingInfo timingInfo = new TimingInfo();
timingInfo.WaitForAll(others);
return timingInfo;
}
///
/// Copies this info to a repsonse object
///
public void CopyToResponse(GetTimingInfoResponse response)
{
response.TotalWaitTime = (float?)TotalWaitTime?.TotalSeconds;
response.TotalInitTime = (float?)TotalInitTime?.TotalSeconds;
response.TotalTimeToComplete = (float?)TotalTimeToComplete?.TotalSeconds;
response.AverageTotalWaitTime = (float?)AverageTotalWaitTime?.TotalSeconds;
response.AverageTotalInitTime = (float?)AverageTotalInitTime?.TotalSeconds;
response.AverageTotalTimeToComplete = (float?)AverageTotalTimeToComplete?.TotalSeconds;
}
}
///
/// Step completion information
///
public class JobStepsCompletionInfo
{
///
/// The total count of steps passed within a job.
///
public int StepPassCount { get; set; }
///
/// The total count of steps with a warning result within a job.
///
public int StepWarningCount { get; set; }
///
/// The total count of steps with a failure result within a job.
///
public int StepFailureCount { get; set; }
///
/// The total number of steps within a job.
///
public int StepTotalCount { get; set; }
///
/// The total step time for the job, in seconds.
///
public float JobStepsTotalTime { get; set; }
///
/// The pass count as compared to the total step count.
///
public float PassRatio => StepTotalCount == 0 ? 0 : StepPassCount / (float)StepTotalCount;
///
/// The pass and warning count as compared to the total step count.
///
public float PassWithWarningRatio => StepTotalCount == 0 ? 0 : (StepPassCount + StepWarningCount) / (float)StepTotalCount;
///
/// The warning count as compared to the total step count.
///
public float WarningRatio => StepTotalCount == 0 ? 0 : StepWarningCount / (float)StepTotalCount;
///
/// The failure count as compared to the total step count.
///
public float FailureRatio => StepTotalCount == 0 ? 0 : StepFailureCount / (float)StepTotalCount;
}
///
/// Information about a chained job trigger
///
public interface IChainedJob
{
///
/// The target to monitor
///
public string Target { get; }
///
/// The template to trigger on success
///
public TemplateId TemplateRefId { get; }
///
/// The triggered job id
///
public JobId? JobId { get; }
///
/// Whether to run the latest change, or default change for the template, when starting the new job. Uses same change as the triggering job by default.
///
public bool UseDefaultChangeForTemplate { get; }
}
///
/// Report for a job or jobstep
///
public interface IJobReport
{
///
/// Name of the report
///
string Name { get; }
///
/// Where to render the report
///
JobReportPlacement Placement { get; }
///
/// The artifact id
///
ArtifactId? ArtifactId { get; }
///
/// Inline data for the report
///
string? Content { get; }
}
///
/// Implementation of IReport
///
public class JobReport : IJobReport
{
///
public string Name { get; set; } = String.Empty;
///
public JobReportPlacement Placement { get; set; }
///
public ArtifactId? ArtifactId { get; set; }
///
public string? Content { get; set; }
}
}