Repository: akuity/kargo
Pull request #2940 (2024-11-15 22:30:53)

feat: allow configuration of retry attempts for Promotion steps
We have discovered some edge cases in which a Promotion step may end up in an infinite loop. To remediate this, we can configure the number of (retry) attempts a step is allowed to make before bailing.

This could later be extended to a waiting (or polling) configuration for steps like `git-wait-for-pr`, to e.g., set a deadline for merging a pull request.

With this pull request, **any** step execution (no matter the outcome) is counted as an "attempt", while the `wait-for-pr` and `argocd-update` steps have been allowed an unlimited number of retries (by default) to achieve backwards compatible behavior.

```yaml
steps:
- uses: argocd-update
  retry:
    attempts: 5
  config:
    apps:
    - name: guestbook
       sources:
       - repoURL: https://github.com/example/guestbook.git
```

**Note:** In the future, we probably want to introduce more rigorous handling of errors, to be able to distinguish "fatal" errors from transient ones.

netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy Preview for *docs-kargo-io* processing.


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | a6d7b247a0bdb9ffb837782dfb789698981bc9b6 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/docs-kargo-io/deploys/6737cb9f03cc140008299113 |
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/akuity/kargo/pull/2940?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) Report
Attention: Patch coverage is `88.30189%` with `31 lines` in your changes missing coverage. Please review.
> Project coverage is 51.17%. Comparing base [(`547f686`)](https://app.codecov.io/gh/akuity/kargo/commit/547f686b999c49f8b807f0edc932ef9d9e58ca08?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) to head [(`a98bbd1`)](https://app.codecov.io/gh/akuity/kargo/commit/a98bbd16bb81f80ecf5ec450629f8d8d5aac392f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity).
> Report is 2 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/akuity/kargo/pull/2940?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) | Patch % | Lines |
|---|---|---|
| [internal/directives/simple\_engine\_promote.go](https://app.codecov.io/gh/akuity/kargo/pull/2940?src=pr&el=tree&filepath=internal%2Fdirectives%2Fsimple_engine_promote.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvZGlyZWN0aXZlcy9zaW1wbGVfZW5naW5lX3Byb21vdGUuZ28=) | 85.88% | [18 Missing and 5 partials :warning: ](https://app.codecov.io/gh/akuity/kargo/pull/2940?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) |
| [internal/directives/git\_pr\_opener.go](https://app.codecov.io/gh/akuity/kargo/pull/2940?src=pr&el=tree&filepath=internal%2Fdirectives%2Fgit_pr_opener.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvZGlyZWN0aXZlcy9naXRfcHJfb3BlbmVyLmdv) | 40.00% | [3 Missing :warning: ](https://app.codecov.io/gh/akuity/kargo/pull/2940?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) |
| [internal/directives/simple\_engine\_health.go](https://app.codecov.io/gh/akuity/kargo/pull/2940?src=pr&el=tree&filepath=internal%2Fdirectives%2Fsimple_engine_health.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvZGlyZWN0aXZlcy9zaW1wbGVfZW5naW5lX2hlYWx0aC5nbw==) | 95.58% | [2 Missing and 1 partial :warning: ](https://app.codecov.io/gh/akuity/kargo/pull/2940?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) |
| [internal/directives/argocd\_revisions.go](https://app.codecov.io/gh/akuity/kargo/pull/2940?src=pr&el=tree&filepath=internal%2Fdirectives%2Fargocd_revisions.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvZGlyZWN0aXZlcy9hcmdvY2RfcmV2aXNpb25zLmdv) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/akuity/kargo/pull/2940?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) |
| [internal/directives/git\_pr\_waiter.go](https://app.codecov.io/gh/akuity/kargo/pull/2940?src=pr&el=tree&filepath=internal%2Fdirectives%2Fgit_pr_waiter.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvZGlyZWN0aXZlcy9naXRfcHJfd2FpdGVyLmdv) | 50.00% | [1 Missing :warning: ](https://app.codecov.io/gh/akuity/kargo/pull/2940?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #2940      +/-   ##
==========================================
+ Coverage   50.91%   51.17%   +0.25%     
==========================================
  Files         279      281       +2     
  Lines       25175    25234      +59     
==========================================
+ Hits        12818    12913      +95     
+ Misses      11666    11637      -29     
+ Partials      691      684       -7     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/akuity/kargo/pull/2940?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity).

----
🚨 Try these New Features:

- [Flaky Tests Detection](https://docs.codecov.com/docs/test-result-ingestion-beta) - Detect and resolve failed and flaky tests
- [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis) - Avoid shipping oversized bundles

Patch:
diff --git a/api/v1alpha1/generated.pb.go b/api/v1alpha1/generated.pb.go
index aca238da..a3c7fd4a 100644
--- a/api/v1alpha1/generated.pb.go
+++ b/api/v1alpha1/generated.pb.go
@@ -1178,10 +1178,38 @@ func (m *PromotionStep) XXX_DiscardUnknown() {
 
 var xxx_messageInfo_PromotionStep proto.InternalMessageInfo
 
+func (m *PromotionStepRetry) Reset()      { *m = PromotionStepRetry{} }
+func (*PromotionStepRetry) ProtoMessage() {}
+func (*PromotionStepRetry) Descriptor() ([]byte, []int) {
+	return fileDescriptor_e26b7f7bbc391025, []int{41}
+}
+func (m *PromotionStepRetry) XXX_Unmarshal(b []byte) error {
+	return m.Unmarshal(b)
+}
+func (m *PromotionStepRetry) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
+	b = b[:cap(b)]
+	n, err := m.MarshalToSizedBuffer(b)
+	if err != nil {
+		return nil, err
+	}
+	return b[:n], nil
+}
+func (m *PromotionStepRetry) XXX_Merge(src proto.Message) {
+	xxx_messageInfo_PromotionStepRetry.Merge(m, src)
+}
+func (m *PromotionStepRetry) XXX_Size() int {
+	return m.Size()
+}
+func (m *PromotionStepRetry) XXX_DiscardUnknown() {
+	xxx_messageInfo_PromotionStepRetry.DiscardUnknown(m)
+}
+
+var xxx_messageInfo_PromotionStepRetry proto.InternalMessageInfo
+
 func (m *PromotionTemplate) Reset()      { *m = PromotionTemplate{} }
 func (*PromotionTemplate) ProtoMessage() {}
 func (*PromotionTemplate) Descriptor() ([]byte, []int) {
-	return fileDescriptor_e26b7f7bbc391025, []int{41}
+	return fileDescriptor_e26b7f7bbc391025, []int{42}
 }
 func (m *PromotionTemplate) XXX_Unmarshal(b []byte) error {
 	return m.Unmarshal(b)
@@ -1209,7 +1237,7 @@ var xxx_messageInfo_PromotionTemplate proto.InternalMessageInfo
 func (m *PromotionTemplateSpec) Reset()      { *m = PromotionTemplateSpec{} }
 func (*PromotionTemplateSpec) ProtoMessage() {}
 func (*PromotionTemplateSpec) Descriptor() ([]byte, []int) {
-	return fileDescriptor_e26b7f7bbc391025, []int{42}
+	return fileDescriptor_e26b7f7bbc391025, []int{43}
 }
 func (m *PromotionTemplateSpec) XXX_Unmarshal(b []byte) error {
 	return m.Unmarshal(b)
@@ -1237,7 +1265,7 @@ var xxx_messageInfo_PromotionTemplateSpec proto.InternalMessageInfo
 func (m *PromotionVariable) Reset()      { *m = PromotionVariable{} }
 func (*PromotionVariable) ProtoMessage() {}
 func (*PromotionVariable) Descriptor() ([]byte, []int) {
-	return fileDescriptor_e26b7f7bbc391025, []int{43}
+	return fileDescriptor_e26b7f7bbc391025, []int{44}
 }
 func (m *PromotionVariable) XXX_Unmarshal(b []byte) error {
 	return m.Unmarshal(b)
@@ -1265,7 +1293,7 @@ var xxx_messageInfo_PromotionVariable proto.InternalMessageInfo
 func (m *RepoSubscription) Reset()      { *m = RepoSubscription{} }
 func (*RepoSubscription) ProtoMessage() {}
 func (*RepoSubscription) Descriptor() ([]byte, []int) {
-	return fileDescriptor_e26b7f7bbc391025, []int{44}
+	return fileDescriptor_e26b7f7bbc391025, []int{45}
 }
 func (m *RepoSubscription) XXX_Unmarshal(b []byte) error {
 	return m.Unmarshal(b)
@@ -1293,7 +1321,7 @@ var xxx_messageInfo_RepoSubscription proto.InternalMessageInfo
 func (m *Stage) Reset()      { *m = Stage{} }
 func (*Stage) ProtoMessage() {}
 func (*Stage) Descriptor() ([]byte, []int) {
-	return fileDescriptor_e26b7f7bbc391025, []int{45}
+	return fileDescriptor_e26b7f7bbc391025, []int{46}
 }
 func (m *Stage) XXX_Unmarshal(b []byte) error {
 	return m.Unmarshal(b)
@@ -1321,7 +1349,7 @@ var xxx_messageInfo_Stage proto.InternalMessageInfo
 func (m *StageList) Reset()      { *m = StageList{} }
 func (*StageList) ProtoMessage() {}
 func (*StageList) Descriptor() ([]byte, []int) {
-	return fileDescriptor_e26b7f7bbc391025, []int{46}
+	return fileDescriptor_e26b7f7bbc391025, []int{47}
 }
 func (m *StageList) XXX_Unmarshal(b []byte) error {
 	return m.Unmarshal(b)
@@ -1349,7 +1377,7 @@ var xxx_messageInfo_StageList proto.InternalMessageInfo
 func (m *StageSpec) Reset()      { *m = StageSpec{} }
 func (*StageSpec) ProtoMessage() {}
 func (*StageSpec) Descriptor() ([]byte, []int) {
-	return fileDescriptor_e26b7f7bbc391025, []int{47}
+	return fileDescriptor_e26b7f7bbc391025, []int{48}
 }
 func (m *StageSpec) XXX_Unmarshal(b []byte) error {
 	return m.Unmarshal(b)
@@ -1377,7 +1405,7 @@ var xxx_messageInfo_StageSpec proto.InternalMessageInfo
 func (m *StageStatus) Reset()      { *m = StageStatus{} }
 func (*StageStatus) ProtoMessage() {}
 func (*StageStatus) Descriptor() ([]byte, []int) {
-	return fileDescriptor_e26b7f7bbc391025, []int{48}
+	return fileDescriptor_e26b7f7bbc391025, []int{49}
 }
 func (m *StageStatus) XXX_Unmarshal(b []byte) error {
 	return m.Unmarshal(b)
@@ -1405,7 +1433,7 @@ var xxx_messageInfo_StageStatus proto.InternalMessageInfo
 func (m *Verification) Reset()      { *m = Verification{} }
 func (*Verification) ProtoMessage() {}
 func (*Verification) Descriptor() ([]byte, []int) {
-	return fileDescriptor_e26b7f7bbc391025, []int{49}
+	return fileDescriptor_e26b7f7bbc391025, []int{50}
 }
 func (m *Verification) XXX_Unmarshal(b []byte) error {
 	return m.Unmarshal(b)
@@ -1433,7 +1461,7 @@ var xxx_messageInfo_Verification proto.InternalMessageInfo
 func (m *VerificationInfo) Reset()      { *m = VerificationInfo{} }
 func (*VerificationInfo) ProtoMessage() {}
 func (*VerificationInfo) Descriptor() ([]byte, []int) {
-	return fileDescriptor_e26b7f7bbc391025, []int{50}
+	return fileDescriptor_e26b7f7bbc391025, []int{51}
 }
 func (m *VerificationInfo) XXX_Unmarshal(b []byte) error {
 	return m.Unmarshal(b)
@@ -1461,7 +1489,7 @@ var xxx_messageInfo_VerificationInfo proto.InternalMessageInfo
 func (m *VerifiedStage) Reset()      { *m = VerifiedStage{} }
 func (*VerifiedStage) ProtoMessage() {}
 func (*VerifiedStage) Descriptor() ([]byte, []int) {
-	return fileDescriptor_e26b7f7bbc391025, []int{51}
+	return fileDescriptor_e26b7f7bbc391025, []int{52}
 }
 func (m *VerifiedStage) XXX_Unmarshal(b []byte) error {
 	return m.Unmarshal(b)
@@ -1489,7 +1517,7 @@ var xxx_messageInfo_VerifiedStage proto.InternalMessageInfo
 func (m *Warehouse) Reset()      { *m = Warehouse{} }
 func (*Warehouse) ProtoMessage() {}
 func (*Warehouse) Descriptor() ([]byte, []int) {
-	return fileDescriptor_e26b7f7bbc391025, []int{52}
+	return fileDescriptor_e26b7f7bbc391025, []int{53}
 }
 func (m *Warehouse) XXX_Unmarshal(b []byte) error {
 	return m.Unmarshal(b)
@@ -1517,7 +1545,7 @@ var xxx_messageInfo_Warehouse proto.InternalMessageInfo
 func (m *WarehouseList) Reset()      { *m = WarehouseList{} }
 func (*WarehouseList) ProtoMessage() {}
 func (*WarehouseList) Descriptor() ([]byte, []int) {
-	return fileDescriptor_e26b7f7bbc391025, []int{53}
+	return fileDescriptor_e26b7f7bbc391025, []int{54}
 }
 func (m *WarehouseList) XXX_Unmarshal(b []byte) error {
 	return m.Unmarshal(b)
@@ -1545,7 +1573,7 @@ var xxx_messageInfo_WarehouseList proto.InternalMessageInfo
 func (m *WarehouseSpec) Reset()      { *m = WarehouseSpec{} }
 func (*WarehouseSpec) ProtoMessage() {}
 func (*WarehouseSpec) Descriptor() ([]byte, []int) {
-	return fileDescriptor_e26b7f7bbc391025, []int{54}
+	return fileDescriptor_e26b7f7bbc391025, []int{55}
 }
 func (m *WarehouseSpec) XXX_Unmarshal(b []byte) error {
 	return m.Unmarshal(b)
@@ -1573,7 +1601,7 @@ var xxx_messageInfo_WarehouseSpec proto.InternalMessageInfo
 func (m *WarehouseStatus) Reset()      { *m = WarehouseStatus{} }
 func (*WarehouseStatus) ProtoMessage() {}
 func (*WarehouseStatus) Descriptor() ([]byte, []int) {
-	return fileDescriptor_e26b7f7bbc391025, []int{55}
+	return fileDescriptor_e26b7f7bbc391025, []int{56}
 }
 func (m *WarehouseStatus) XXX_Unmarshal(b []byte) error {
 	return m.Unmarshal(b)
@@ -1645,6 +1673,7 @@ func init() {
 	proto.RegisterType((*PromotionSpec)(nil), "github.com.akuity.kargo.api.v1alpha1.PromotionSpec")
 	proto.RegisterType((*PromotionStatus)(nil), "github.com.akuity.kargo.api.v1alpha1.PromotionStatus")
 	proto.RegisterType((*PromotionStep)(nil), "github.com.akuity.kargo.api.v1alpha1.PromotionStep")
+	proto.RegisterType((*PromotionStepRetry)(nil), "github.com.akuity.kargo.api.v1alpha1.PromotionStepRetry")
 	proto.RegisterType((*PromotionTemplate)(nil), "github.com.akuity.kargo.api.v1alpha1.PromotionTemplate")
 	proto.RegisterType((*PromotionTemplateSpec)(nil), "github.com.akuity.kargo.api.v1alpha1.PromotionTemplateSpec")
 	proto.RegisterType((*PromotionVariable)(nil), "github.com.akuity.kargo.api.v1alpha1.PromotionVariable")
@@ -1667,233 +1696,237 @@ func init() {
 }
 
 var fileDescriptor_e26b7f7bbc391025 = []byte{
-	// 3604 bytes of a gzipped FileDescriptorProto
-	0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xdc, 0x5b, 0x4b, 0x6c, 0x1b, 0xc7,
-	0xdd, 0xf7, 0x92, 0x12, 0x25, 0xfe, 0xa9, 0xe7, 0x58, 0x4e, 0x14, 0xe5, 0x8b, 0xe4, 0x6f, 0x13,
-	0x04, 0xc9, 0x97, 0x84, 0xfa, 0xfc, 0x8a, 0x1f, 0x69, 0x5d, 0x88, 0x94, 0x1f, 0x72, 0x95, 0x58,
-	0x1d, 0x3a, 0x76, 0xea, 0xd8, 0x48, 0x47, 0xe4, 0x88, 0xdc, 0x8a, 0xdc, 0x65, 0x76, 0x96, 0x4a,
-	0xd4, 0x16, 0x6d, 0xd2, 0x07, 0x10, 0xa0, 0x68, 0xd1, 0x43, 0xd0, 0xa4, 0x40, 0x0b, 0x14, 0xed,
-	0xb9, 0x05, 0x7a, 0xee, 0x21, 0x87, 0x5e, 0x82, 0x22, 0x28, 0x82, 0xb6, 0x87, 0x1c, 0x02, 0xa1,
-	0x51, 0x80, 0x1e, 0x73, 0xeb, 0xc5, 0x40, 0x81, 0x62, 0x1e, 0xbb, 0x3b, 0xbb, 0x5c, 0x5a, 0x5c,
-	0x5a, 0x32, 0x8c, 0xde, 0xc8, 0xf9, 0xcf, 0xfc, 0xfe, 0xf3, 0xf8, 0xbf, 0x67, 0x16, 0x4e, 0xd6,
-	0x2d, 0xaf, 0xd1, 0x59, 0x2f, 0x56, 0x9d, 0xd6, 0x22, 0xd9, 0xec, 0x58, 0xde, 0xf6, 0xe2, 0x26,
-	0x71, 0xeb, 0xce, 0x22, 0x69, 0x5b, 0x8b, 0x5b, 0xc7, 0x48, 0xb3, 0xdd, 0x20, 0xc7, 0x16, 0xeb,
-	0xd4, 0xa6, 0x2e, 0xf1, 0x68, 0xad, 0xd8, 0x76, 0x1d, 0xcf, 0x41, 0x4f, 0x84, 0xa3, 0x8a, 0x72,
-	0x54, 0x51, 0x8c, 0x2a, 0x92, 0xb6, 0x55, 0xf4, 0x47, 0xcd, 0x3d, 0xa7, 0x61, 0xd7, 0x9d, 0xba,
-	0xb3, 0x28, 0x06, 0xaf, 0x77, 0x36, 0xc4, 0x3f, 0xf1, 0x47, 0xfc, 0x92, 0xa0, 0x73, 0x97, 0x37,
-	0xcf, 0xb0, 0xa2, 0x25, 0x38, 0xd3, 0x37, 0x3d, 0x6a, 0x33, 0xcb, 0xb1, 0xd9, 0x73, 0xa4, 0x6d,
-	0x31, 0xea, 0x6e, 0x51, 0x77, 0xb1, 0xbd, 0x59, 0xe7, 0x34, 0x16, 0xed, 0xb0, 0xb8, 0xd5, 0x35,
-	0xbd, 0xb9, 0x93, 0x21, 0x52, 0x8b, 0x54, 0x1b, 0x96, 0x4d, 0xdd, 0xed, 0x70, 0x78, 0x8b, 0x7a,
-	0x24, 0x69, 0xd4, 0x62, 0xaf, 0x51, 0x6e, 0xc7, 0xf6, 0xac, 0x16, 0xed, 0x1a, 0xf0, 0xfc, 0x5e,
-	0x03, 0x58, 0xb5, 0x41, 0x5b, 0x24, 0x3e, 0xce, 0xbc, 0x05, 0x87, 0x97, 0x6c, 0xd2, 0xdc, 0x66,
-	0x16, 0xc3, 0x1d, 0x7b, 0xc9, 0xad, 0x77, 0x5a, 0xd4, 0xf6, 0xd0, 0x51, 0x18, 0xb2, 0x49, 0x8b,
-	0xce, 0x1a, 0x47, 0x8d, 0xa7, 0xf2, 0xa5, 0xb1, 0x0f, 0x77, 0x16, 0x0e, 0xed, 0xee, 0x2c, 0x0c,
-	0xbd, 0x44, 0x5a, 0x14, 0x0b, 0x0a, 0x7a, 0x1c, 0x86, 0xb7, 0x48, 0xb3, 0x43, 0x67, 0x33, 0xa2,
-	0xcb, 0xb8, 0xea, 0x32, 0x7c, 0x9d, 0x37, 0x62, 0x49, 0x33, 0x7f, 0x90, 0x8d, 0xc0, 0xbf, 0x48,
-	0x3d, 0x52, 0x23, 0x1e, 0x41, 0x2d, 0xc8, 0x35, 0xc9, 0x3a, 0x6d, 0xb2, 0x59, 0xe3, 0x68, 0xf6,
-	0xa9, 0xc2, 0xf1, 0x0b, 0xc5, 0x7e, 0x0e, 0xb1, 0x98, 0x00, 0x55, 0x5c, 0x15, 0x38, 0x17, 0x6c,
-	0xcf, 0xdd, 0x2e, 0x4d, 0xa8, 0x49, 0xe4, 0x64, 0x23, 0x56, 0x4c, 0xd0, 0xdb, 0x06, 0x14, 0x88,
-	0x6d, 0x3b, 0x1e, 0xf1, 0xf8, 0x31, 0xcd, 0x66, 0x04, 0xd3, 0x2b, 0x83, 0x33, 0x5d, 0x0a, 0xc1,
-	0x24, 0xe7, 0xc3, 0x8a, 0x73, 0x41, 0xa3, 0x60, 0x9d, 0xe7, 0xdc, 0x59, 0x28, 0x68, 0x53, 0x45,
-	0x53, 0x90, 0xdd, 0xa4, 0xdb, 0x72, 0x7f, 0x31, 0xff, 0x89, 0x66, 0x22, 0x1b, 0xaa, 0x76, 0xf0,
-	0x5c, 0xe6, 0x8c, 0x31, 0x77, 0x1e, 0xa6, 0xe2, 0x0c, 0xd3, 0x8c, 0x37, 0x7f, 0x6a, 0xc0, 0x8c,
-	0xb6, 0x0a, 0x4c, 0x37, 0xa8, 0x4b, 0xed, 0x2a, 0x45, 0x8b, 0x90, 0xe7, 0x67, 0xc9, 0xda, 0xa4,
-	0xea, 0x1f, 0xf5, 0xb4, 0x5a, 0x48, 0xfe, 0x25, 0x9f, 0x80, 0xc3, 0x3e, 0x81, 0x58, 0x64, 0xee,
-	0x26, 0x16, 0xed, 0x06, 0x61, 0x74, 0x36, 0x1b, 0x15, 0x8b, 0x35, 0xde, 0x88, 0x25, 0xcd, 0xfc,
-	0x32, 0x3c, 0xe2, 0xcf, 0xe7, 0x1a, 0x6d, 0xb5, 0x9b, 0xc4, 0xa3, 0xe1, 0xa4, 0xf6, 0x14, 0x3d,
-	0x73, 0x12, 0xc6, 0x97, 0xda, 0x6d, 0xd7, 0xd9, 0xa2, 0xb5, 0x8a, 0x47, 0xea, 0xd4, 0xfc, 0xbe,
-	0x01, 0x47, 0x96, 0xdc, 0xba, 0x53, 0x5e, 0x5e, 0x6a, 0xb7, 0x2f, 0x53, 0xd2, 0xf4, 0x1a, 0x15,
-	0x8f, 0x78, 0x1d, 0x86, 0xce, 0x43, 0x8e, 0x89, 0x5f, 0x0a, 0xee, 0x49, 0x5f, 0x42, 0x24, 0xfd,
-	0xce, 0xce, 0xc2, 0x4c, 0xc2, 0x40, 0x8a, 0xd5, 0x28, 0xf4, 0x34, 0x8c, 0xb4, 0x28, 0x63, 0xa4,
-	0xee, 0xaf, 0x79, 0x52, 0x01, 0x8c, 0xbc, 0x28, 0x9b, 0xb1, 0x4f, 0x37, 0xff, 0x9c, 0x81, 0xc9,
-	0x00, 0x4b, 0xb1, 0x3f, 0x80, 0x0d, 0xee, 0xc0, 0x58, 0x43, 0x5b, 0xa1, 0xd8, 0xe7, 0xc2, 0xf1,
-	0x17, 0xfa, 0x94, 0xe5, 0xa4, 0x4d, 0x2a, 0xcd, 0x28, 0x36, 0x63, 0x7a, 0x2b, 0x8e, 0xb0, 0x41,
-	0x2d, 0x00, 0xb6, 0x6d, 0x57, 0x15, 0xd3, 0x21, 0xc1, 0xf4, 0x6c, 0x4a, 0xa6, 0x95, 0x00, 0xa0,
-	0x84, 0x14, 0x4b, 0x08, 0xdb, 0xb0, 0xc6, 0xc0, 0xfc, 0xbd, 0x01, 0x87, 0x13, 0xc6, 0xa1, 0x2f,
-	0xc5, 0xce, 0xf3, 0x89, 0xae, 0xf3, 0x44, 0x5d, 0xc3, 0xc2, 0xd3, 0x7c, 0x16, 0x46, 0x5d, 0xba,
-	0x65, 0x71, 0x5b, 0xad, 0x76, 0x78, 0x4a, 0x8d, 0x1f, 0xc5, 0xaa, 0x1d, 0x07, 0x3d, 0xd0, 0x33,
-	0x90, 0xf7, 0x7f, 0xf3, 0x6d, 0xce, 0x72, 0x71, 0xe6, 0x07, 0xe7, 0x77, 0x65, 0x38, 0xa4, 0x9b,
-	0xdf, 0x83, 0xe1, 0x72, 0x83, 0xb8, 0x1e, 0x97, 0x18, 0x97, 0xb6, 0x9d, 0x97, 0xf1, 0xaa, 0x9a,
-	0x62, 0x20, 0x31, 0x58, 0x36, 0x63, 0x9f, 0xde, 0xc7, 0x61, 0x3f, 0x0d, 0x23, 0x5b, 0xd4, 0x15,
-	0xf3, 0xcd, 0x46, 0xc1, 0xae, 0xcb, 0x66, 0xec, 0xd3, 0xcd, 0xbf, 0x19, 0x30, 0x23, 0x66, 0xb0,
-	0x6c, 0xb1, 0xaa, 0xb3, 0x45, 0xdd, 0x6d, 0x4c, 0x59, 0xa7, 0xb9, 0xcf, 0x13, 0x5a, 0x86, 0x29,
-	0x46, 0x5b, 0x5b, 0xd4, 0x2d, 0x3b, 0x36, 0xf3, 0x5c, 0x62, 0xd9, 0x9e, 0x9a, 0xd9, 0xac, 0xea,
-	0x3d, 0x55, 0x89, 0xd1, 0x71, 0xd7, 0x08, 0xf4, 0x14, 0x8c, 0xaa, 0x69, 0x73, 0x51, 0xe2, 0x1b,
-	0x3b, 0xc6, 0xcf, 0x40, 0xad, 0x89, 0xe1, 0x80, 0x6a, 0xfe, 0xd3, 0x80, 0x69, 0xb1, 0xaa, 0x4a,
-	0x67, 0x9d, 0x55, 0x5d, 0xab, 0xcd, 0x4d, 0xe0, 0x83, 0xb8, 0xa4, 0xf3, 0x30, 0x51, 0xf3, 0x37,
-	0x7e, 0xd5, 0x6a, 0x59, 0x9e, 0xd0, 0x91, 0xe1, 0xd2, 0x43, 0x0a, 0x63, 0x62, 0x39, 0x42, 0xc5,
-	0xb1, 0xde, 0xe6, 0x2f, 0xb3, 0x70, 0xd8, 0xef, 0x42, 0x6b, 0x4b, 0xae, 0x67, 0x6d, 0x90, 0xaa,
-	0xc7, 0x50, 0x0d, 0xc6, 0x6a, 0x61, 0xb3, 0xa7, 0x34, 0xef, 0xff, 0x8a, 0xd2, 0xdd, 0x17, 0x75,
-	0x77, 0x5f, 0x6c, 0x6f, 0xd6, 0x79, 0x03, 0x2b, 0xf2, 0xa8, 0xa2, 0xb8, 0x75, 0xac, 0x78, 0xcd,
-	0x6a, 0xd1, 0x50, 0xbb, 0x35, 0x78, 0x0f, 0x47, 0x50, 0xd1, 0x0d, 0xc8, 0xd6, 0x2d, 0x4f, 0x39,
-	0xe3, 0x33, 0xfd, 0xa9, 0xf5, 0x25, 0x2b, 0x2e, 0x6a, 0xa5, 0x82, 0x62, 0x95, 0xbd, 0x64, 0x79,
-	0x98, 0x23, 0xa2, 0x75, 0xc8, 0x59, 0x2d, 0x52, 0xa7, 0xbe, 0xcf, 0x3d, 0xd7, 0x1f, 0xf6, 0x0a,
-	0x1f, 0x13, 0x47, 0x0f, 0xbc, 0xbb, 0xa0, 0x32, 0xac, 0x90, 0x39, 0x8f, 0x2a, 0x17, 0x11, 0xa9,
-	0xa4, 0x7d, 0xf3, 0x48, 0x52, 0x96, 0x90, 0x87, 0xa0, 0x32, 0xac, 0x90, 0xcd, 0x4f, 0x32, 0x30,
-	0x15, 0xee, 0x5f, 0xd9, 0x69, 0xb5, 0x2c, 0x0f, 0xcd, 0x41, 0xc6, 0xaa, 0x29, 0x09, 0x04, 0x35,
-	0x30, 0xb3, 0xb2, 0x8c, 0x33, 0x56, 0x0d, 0x3d, 0x09, 0xb9, 0x75, 0x97, 0xd8, 0xd5, 0x86, 0x92,
-	0xbc, 0x00, 0xb8, 0x24, 0x5a, 0xb1, 0xa2, 0xa2, 0xc7, 0x20, 0xeb, 0x91, 0xba, 0x12, 0xb8, 0x60,
-	0xff, 0xae, 0x91, 0x3a, 0xe6, 0xed, 0x5c, 0xd2, 0x59, 0x67, 0xfd, 0x9b, 0xb4, 0x2a, 0x4f, 0x5e,
-	0x93, 0xf4, 0x8a, 0x6c, 0xc6, 0x3e, 0x9d, 0x73, 0x24, 0x1d, 0xaf, 0xe1, 0xb8, 0xb3, 0xc3, 0x51,
-	0x8e, 0x4b, 0xa2, 0x15, 0x2b, 0x2a, 0xf7, 0x49, 0x55, 0x31, 0x7f, 0x8f, 0xba, 0xb3, 0xb9, 0xa8,
-	0x4f, 0x2a, 0xfb, 0x04, 0x1c, 0xf6, 0x41, 0xb7, 0xa1, 0x50, 0x75, 0x29, 0xf1, 0x1c, 0x77, 0x99,
-	0x78, 0x74, 0x76, 0x24, 0xb5, 0x04, 0x4e, 0xf2, 0xc0, 0xa8, 0x1c, 0x42, 0x60, 0x1d, 0xcf, 0xfc,
-	0xc2, 0x80, 0xd9, 0x70, 0x6b, 0xc5, 0xd9, 0x86, 0xc1, 0x80, 0xda, 0x1e, 0xa3, 0xc7, 0xf6, 0x3c,
-	0x09, 0xb9, 0x9a, 0x55, 0xa7, 0xcc, 0x8b, 0xef, 0xf2, 0xb2, 0x68, 0xc5, 0x8a, 0x8a, 0x8e, 0x03,
-	0xd4, 0x2d, 0x4f, 0x19, 0x07, 0xb5, 0xd9, 0x81, 0x0b, 0xba, 0x14, 0x50, 0xb0, 0xd6, 0x0b, 0xdd,
-	0x80, 0xbc, 0x98, 0xe6, 0x80, 0x6a, 0x27, 0x5c, 0x45, 0xd9, 0x07, 0xc0, 0x21, 0x96, 0xf9, 0xf1,
-	0x10, 0x8c, 0x5c, 0x74, 0xa9, 0x55, 0x6f, 0x78, 0xe8, 0x1b, 0x30, 0xda, 0x52, 0x41, 0xa5, 0x58,
-	0x64, 0xe1, 0xf8, 0xff, 0xf7, 0xc7, 0xe3, 0xaa, 0x38, 0x74, 0x1e, 0x90, 0x86, 0x0b, 0x09, 0xdb,
-	0x70, 0x80, 0xca, 0x03, 0x32, 0xd2, 0xb4, 0x08, 0x13, 0xe7, 0xa6, 0x05, 0x64, 0x4b, 0xbc, 0x11,
-	0x4b, 0x1a, 0x7a, 0x15, 0x72, 0x8e, 0x6b, 0xd5, 0x2d, 0x7b, 0x36, 0x2f, 0x26, 0x71, 0xa2, 0x3f,
-	0x15, 0x52, 0xab, 0xb8, 0x2a, 0x86, 0x86, 0x9b, 0x2f, 0xff, 0x63, 0x05, 0x89, 0x6e, 0xc2, 0x88,
-	0x14, 0x26, 0x5f, 0x41, 0x17, 0xfb, 0x36, 0x30, 0x52, 0x1e, 0x43, 0xa1, 0x97, 0xff, 0x19, 0xf6,
-	0x01, 0x51, 0x25, 0xb0, 0x2f, 0x43, 0x02, 0xfa, 0x99, 0x14, 0xf6, 0xa5, 0xa7, 0x41, 0xa9, 0x04,
-	0x06, 0x65, 0x38, 0x0d, 0xa8, 0x30, 0x19, 0xbd, 0x2c, 0x08, 0xdf, 0x62, 0x15, 0xb9, 0xe4, 0x06,
-	0xd8, 0x62, 0x15, 0x36, 0x4d, 0x44, 0xc3, 0x1d, 0x3f, 0xb0, 0x31, 0xdf, 0xcd, 0xc2, 0xb4, 0xea,
-	0x59, 0x76, 0x9a, 0x4d, 0x5a, 0x15, 0x6e, 0x52, 0xda, 0xa7, 0x6c, 0xa2, 0x7d, 0xb2, 0x60, 0xd8,
-	0xf2, 0x68, 0xcb, 0x4f, 0xc0, 0x4a, 0xa9, 0x66, 0x13, 0xf2, 0x28, 0xae, 0x70, 0x10, 0x99, 0x03,
-	0x05, 0xa7, 0xa4, 0x7a, 0x61, 0xc9, 0x01, 0xfd, 0xc8, 0x80, 0xc3, 0x5b, 0xd4, 0xb5, 0x36, 0xac,
-	0xaa, 0xc8, 0x60, 0x2e, 0x5b, 0xcc, 0x73, 0xdc, 0x6d, 0xe5, 0x11, 0x9e, 0xef, 0x8f, 0xf3, 0x75,
-	0x0d, 0x60, 0xc5, 0xde, 0x70, 0x4a, 0x8f, 0x2a, 0x6e, 0x87, 0xaf, 0x77, 0x43, 0xe3, 0x24, 0x7e,
-	0x73, 0x6d, 0x80, 0x70, 0xb6, 0x09, 0x09, 0xd4, 0xaa, 0x9e, 0x40, 0xf5, 0x3d, 0x31, 0x7f, 0xb1,
-	0xbe, 0xc9, 0xd2, 0x13, 0xaf, 0x0f, 0x0c, 0x28, 0x28, 0xfa, 0xaa, 0xc5, 0x3c, 0x74, 0xab, 0x4b,
-	0xdb, 0x8b, 0xfd, 0x69, 0x3b, 0x1f, 0x2d, 0x74, 0x3d, 0x88, 0x57, 0xfd, 0x16, 0x4d, 0xd3, 0xb1,
-	0x7f, 0xa4, 0x72, 0x63, 0x9f, 0x4b, 0x35, 0xff, 0xd0, 0x30, 0x88, 0x3d, 0x52, 0x67, 0x67, 0xba,
-	0x30, 0x1e, 0x51, 0x72, 0x74, 0x0a, 0x86, 0x36, 0x2d, 0xdb, 0xf7, 0x7a, 0xff, 0xeb, 0xc7, 0x53,
-	0x5f, 0xb5, 0xec, 0xda, 0x9d, 0x9d, 0x85, 0xe9, 0x48, 0x67, 0xde, 0x88, 0x45, 0xf7, 0xbd, 0xc3,
-	0xb0, 0x73, 0xa3, 0xef, 0xff, 0x7a, 0xe1, 0xd0, 0x5b, 0x9f, 0x1e, 0x3d, 0x64, 0xbe, 0x97, 0x85,
-	0xa9, 0xf8, 0xae, 0xf6, 0x51, 0x90, 0x08, 0x6d, 0xd8, 0xe8, 0x81, 0xda, 0xb0, 0xcc, 0xc1, 0xd9,
-	0xb0, 0xec, 0x41, 0xd8, 0xb0, 0xa1, 0x7d, 0xb3, 0x61, 0xe6, 0x5f, 0x0c, 0x98, 0x08, 0x4e, 0xe6,
-	0xf5, 0x0e, 0xf7, 0xac, 0xe1, 0xae, 0x1b, 0xfb, 0xbf, 0xeb, 0xaf, 0xc1, 0x08, 0x73, 0x3a, 0x6e,
-	0x55, 0x84, 0x8f, 0x1c, 0xfd, 0x64, 0x3a, 0xa3, 0x29, 0xc7, 0x6a, 0x31, 0x93, 0x6c, 0xc0, 0x3e,
-	0xaa, 0x79, 0x2b, 0x58, 0x8f, 0x22, 0xc9, 0x88, 0xc2, 0xe5, 0xf1, 0x16, 0x5f, 0xcf, 0xa8, 0x1e,
-	0x51, 0xf0, 0x56, 0xac, 0xa8, 0xc8, 0x14, 0xe6, 0xdc, 0x0f, 0x6c, 0xf3, 0x25, 0x50, 0x56, 0x59,
-	0x9c, 0x81, 0xa4, 0x98, 0x5f, 0x64, 0x03, 0xed, 0x51, 0xe9, 0xeb, 0x1b, 0x00, 0xd2, 0x32, 0xd1,
-	0xda, 0x8a, 0xad, 0x4c, 0x6f, 0x79, 0x00, 0x47, 0xa0, 0xcc, 0x21, 0x47, 0x91, 0xb6, 0x37, 0x88,
-	0x01, 0x42, 0x02, 0xd6, 0x58, 0xa1, 0x6f, 0x43, 0x81, 0xa8, 0x92, 0xc9, 0x45, 0xc7, 0x55, 0x32,
-	0xbc, 0x3c, 0x08, 0xe7, 0xa5, 0x10, 0x26, 0x5e, 0xfa, 0x0a, 0x29, 0x58, 0xe7, 0x36, 0xe7, 0xc2,
-	0x64, 0x6c, 0xbe, 0x09, 0xd6, 0x77, 0x25, 0x6a, 0x7d, 0x4f, 0xa4, 0x71, 0x0b, 0xaa, 0x0e, 0xa4,
-	0xd7, 0xcc, 0x18, 0x4c, 0xc5, 0x67, 0xba, 0x6f, 0x4c, 0x23, 0xc5, 0x27, 0xdd, 0xde, 0xff, 0x2a,
-	0x03, 0xf9, 0x40, 0xe3, 0xd3, 0x64, 0xa9, 0xd2, 0x53, 0x67, 0xf6, 0xc8, 0x24, 0xb2, 0xfd, 0x64,
-	0x12, 0x43, 0xbd, 0x33, 0x09, 0xbf, 0x92, 0x95, 0xbb, 0x7b, 0x25, 0x4b, 0xcb, 0x24, 0x46, 0xfa,
-	0xcf, 0x24, 0x46, 0xf7, 0xce, 0x24, 0xcc, 0xdf, 0x18, 0x80, 0xba, 0xd3, 0xc6, 0x34, 0x1b, 0x45,
-	0xe2, 0x76, 0xb8, 0x4f, 0x2f, 0x1d, 0xcf, 0xdd, 0x7a, 0x9b, 0x63, 0xf3, 0x83, 0x61, 0x98, 0xbc,
-	0x64, 0x0d, 0x5c, 0x70, 0xf0, 0xe0, 0x61, 0x89, 0x54, 0xa1, 0x2a, 0x46, 0xaa, 0x78, 0x2e, 0xf1,
-	0x68, 0x7d, 0x5b, 0x9d, 0xef, 0x39, 0x35, 0xf4, 0xe1, 0x72, 0x72, 0xb7, 0x3b, 0xbd, 0x49, 0xb8,
-	0x17, 0x74, 0xdf, 0x42, 0xf2, 0x02, 0x8c, 0x33, 0xcf, 0xb5, 0xaa, 0x9e, 0x2c, 0x69, 0xb0, 0xd9,
-	0x82, 0xb0, 0x72, 0x47, 0x54, 0xf7, 0xf1, 0x8a, 0x4e, 0xc4, 0xd1, 0xbe, 0x89, 0x95, 0x92, 0xa1,
-	0xd4, 0x95, 0x92, 0x45, 0xc8, 0x93, 0x66, 0xd3, 0x79, 0xe3, 0x1a, 0xa9, 0x33, 0x95, 0xaa, 0x06,
-	0x52, 0xb3, 0xe4, 0x13, 0x70, 0xd8, 0x07, 0x15, 0x01, 0xac, 0xba, 0xed, 0xb8, 0x54, 0x8c, 0xc8,
-	0x09, 0x73, 0x3b, 0xc1, 0x6d, 0xdd, 0x4a, 0xd0, 0x8a, 0xb5, 0x1e, 0xa8, 0x02, 0x47, 0x2c, 0x9b,
-	0xd1, 0x6a, 0xc7, 0xa5, 0x95, 0x4d, 0xab, 0x7d, 0x6d, 0xb5, 0x22, 0xac, 0xc4, 0xb6, 0x90, 0xe6,
-	0xd1, 0xd2, 0x63, 0x8a, 0xd9, 0x91, 0x95, 0xa4, 0x4e, 0x38, 0x79, 0x2c, 0x3a, 0x09, 0x63, 0x96,
-	0x5d, 0x6d, 0x76, 0x6a, 0x74, 0x8d, 0x78, 0x0d, 0x36, 0x3b, 0x2a, 0xa6, 0x31, 0xb5, 0xbb, 0xb3,
-	0x30, 0xb6, 0xa2, 0xb5, 0xe3, 0x48, 0x2f, 0x3e, 0x8a, 0xbe, 0xa9, 0x8d, 0xca, 0x87, 0xa3, 0x2e,
-	0xbc, 0xa9, 0x8f, 0xd2, 0x7b, 0x25, 0xd4, 0x92, 0x20, 0x55, 0x2d, 0xe9, 0xe7, 0x59, 0xc8, 0xc9,
-	0x52, 0x2e, 0x3a, 0x15, 0xab, 0x97, 0x3e, 0xd6, 0x55, 0x2f, 0x2d, 0x24, 0x95, 0xbd, 0x4d, 0xc8,
-	0x59, 0x8c, 0x75, 0xa2, 0xde, 0x6d, 0x45, 0xb4, 0x60, 0x45, 0x41, 0x16, 0x00, 0xf1, 0x4b, 0xad,
-	0x7e, 0xe8, 0x72, 0x2a, 0x6d, 0x45, 0x38, 0x56, 0x0d, 0x0e, 0x08, 0x0c, 0x6b, 0xe0, 0xa2, 0xc2,
-	0xe3, 0xd8, 0x1b, 0x56, 0x5d, 0xe5, 0xe1, 0xe7, 0xb5, 0xa8, 0x39, 0xbc, 0x7d, 0x7b, 0x2d, 0xb8,
-	0x9e, 0x0b, 0x03, 0xe8, 0x48, 0x07, 0x1e, 0x49, 0x5f, 0xa9, 0x5c, 0x7d, 0x49, 0x2e, 0xa7, 0x2c,
-	0x10, 0xb1, 0x42, 0xe6, 0x3c, 0x9c, 0x8e, 0xd7, 0xee, 0x78, 0x42, 0x26, 0xf7, 0x89, 0xc7, 0x55,
-	0x81, 0x88, 0x15, 0xb2, 0xf9, 0x9e, 0x01, 0x93, 0x72, 0xbb, 0xcb, 0x0d, 0x5a, 0xdd, 0xac, 0x78,
-	0xb4, 0xcd, 0x03, 0xdb, 0x0e, 0xa3, 0x2c, 0x1e, 0xd8, 0xbe, 0xcc, 0x28, 0xc3, 0x82, 0xa2, 0xad,
-	0x3e, 0x73, 0x50, 0xab, 0x37, 0x7f, 0x67, 0xc0, 0xb0, 0x88, 0x20, 0xd3, 0x98, 0xba, 0x68, 0x55,
-	0x25, 0xd3, 0x57, 0x55, 0x65, 0x8f, 0x7a, 0x57, 0x58, 0xd0, 0x19, 0xba, 0x5b, 0x41, 0xc7, 0xfc,
-	0xdc, 0x80, 0x99, 0xa4, 0x22, 0x61, 0x9a, 0xe9, 0x3f, 0x0b, 0xa3, 0xed, 0x26, 0xf1, 0x36, 0x1c,
-	0xb7, 0x15, 0xbf, 0x0d, 0x58, 0x53, 0xed, 0x38, 0xe8, 0x81, 0x5c, 0x00, 0xd7, 0xcf, 0x46, 0x7c,
-	0x71, 0x3f, 0x9f, 0xd6, 0xf9, 0x44, 0xab, 0x5b, 0xe1, 0x66, 0x05, 0x4d, 0x0c, 0x6b, 0x5c, 0xcc,
-	0x1f, 0x0f, 0xc3, 0xb4, 0x18, 0x32, 0xa8, 0x33, 0x1a, 0xe4, 0x84, 0xda, 0xf0, 0x90, 0xc8, 0x21,
-	0xba, 0xfd, 0x97, 0x3c, 0xb4, 0x33, 0x6a, 0xfc, 0x43, 0x2b, 0x89, 0xbd, 0xee, 0xf4, 0xa4, 0xe0,
-	0x1e, 0xb8, 0xdd, 0x4e, 0x09, 0xfe, 0xfb, 0x9c, 0x92, 0x2e, 0x6c, 0x23, 0x7b, 0x0a, 0x5b, 0x4f,
-	0x17, 0x36, 0x7a, 0x0f, 0x2e, 0xac, 0xdb, 0xad, 0xe4, 0x53, 0xb9, 0x95, 0x5f, 0x64, 0x60, 0x64,
-	0xcd, 0x75, 0x44, 0xb1, 0xf9, 0xe0, 0xeb, 0x96, 0x57, 0x61, 0x88, 0xb5, 0x69, 0x55, 0xd9, 0xbc,
-	0x63, 0xfd, 0x69, 0x9a, 0x9a, 0x5e, 0xa5, 0x4d, 0xab, 0xa5, 0x51, 0x6e, 0x46, 0xf9, 0x2f, 0x2c,
-	0x80, 0xb4, 0x02, 0x5c, 0x36, 0x4d, 0xb0, 0xef, 0x43, 0xde, 0xbd, 0x00, 0xf7, 0x81, 0x01, 0x05,
-	0xd5, 0xf3, 0x81, 0xad, 0xf4, 0xa8, 0xf9, 0xf5, 0xa8, 0xf4, 0xfc, 0x24, 0x5c, 0x01, 0xdf, 0x34,
-	0xf4, 0x5d, 0x98, 0x6e, 0xbb, 0x4e, 0xcb, 0xe1, 0x9a, 0xba, 0xe6, 0x34, 0xad, 0xaa, 0x45, 0xfd,
-	0x62, 0xe1, 0xa9, 0xbe, 0xf9, 0x69, 0xc3, 0xb7, 0x4b, 0x8f, 0x28, 0xbe, 0xd3, 0x6b, 0x71, 0x5c,
-	0xdc, 0xcd, 0xca, 0xfc, 0xbb, 0x01, 0xe3, 0x91, 0xbd, 0x47, 0x55, 0x80, 0xaa, 0x63, 0xd7, 0x2c,
-	0x2f, 0xb8, 0x90, 0x2d, 0x1c, 0x5f, 0xec, 0x6f, 0x57, 0xcb, 0xfe, 0xb8, 0x50, 0xe8, 0x82, 0x26,
-	0x86, 0x35, 0x58, 0x74, 0xc2, 0x7f, 0xbf, 0x10, 0x8d, 0x97, 0xe4, 0xfb, 0x85, 0x3b, 0x3b, 0x0b,
-	0x63, 0x6a, 0x4e, 0xfa, 0x7b, 0x86, 0x34, 0xaf, 0x04, 0x7e, 0x9b, 0x81, 0x7c, 0xb0, 0xfe, 0xfb,
-	0xa0, 0x46, 0x2f, 0x47, 0xd4, 0xe8, 0x44, 0xca, 0x93, 0x13, 0x8a, 0x14, 0xc4, 0x24, 0x9a, 0x32,
-	0xdd, 0x8e, 0x29, 0x53, 0x5a, 0x91, 0xd8, 0x43, 0x9d, 0xfe, 0x24, 0x0f, 0x5f, 0xf6, 0xbd, 0x0f,
-	0x0a, 0x75, 0x2d, 0xaa, 0x50, 0x8b, 0x29, 0x57, 0xd3, 0x43, 0xa5, 0xde, 0x31, 0x60, 0x32, 0xa6,
-	0x04, 0xe8, 0x71, 0x18, 0x16, 0xd5, 0x21, 0x25, 0x5f, 0xc1, 0x40, 0x55, 0x4b, 0x10, 0x34, 0xb4,
-	0x06, 0x33, 0xa4, 0xe3, 0x39, 0xc1, 0xd8, 0x0b, 0x36, 0x59, 0x6f, 0x52, 0x59, 0x20, 0x18, 0x2d,
-	0xfd, 0x8f, 0x1a, 0x33, 0xb3, 0x94, 0xd0, 0x07, 0x27, 0x8e, 0x34, 0x3f, 0xca, 0x00, 0x0a, 0x1a,
-	0xd3, 0x54, 0x55, 0x6f, 0xc3, 0xc8, 0x86, 0xac, 0xff, 0xdc, 0x5b, 0x59, 0xbc, 0x54, 0xd0, 0x6f,
-	0x06, 0x7c, 0x4c, 0xf4, 0xf5, 0xfd, 0x91, 0x23, 0xe8, 0x96, 0x21, 0x74, 0x13, 0x60, 0xc3, 0xb2,
-	0x2d, 0xd6, 0x18, 0xf0, 0x02, 0x4f, 0x78, 0xf3, 0x8b, 0x01, 0x02, 0xd6, 0xd0, 0xcc, 0x77, 0x33,
-	0x9a, 0x7c, 0x0a, 0x73, 0xd9, 0xd7, 0xb9, 0x3e, 0x1d, 0xdd, 0xcc, 0x7c, 0xf7, 0x95, 0x89, 0xb6,
-	0x31, 0x43, 0x5b, 0xc4, 0xf5, 0xab, 0xb7, 0xa7, 0x53, 0x6e, 0xcb, 0x75, 0xe2, 0x5a, 0xfc, 0xe0,
-	0xc3, 0x23, 0xbd, 0x4e, 0x5c, 0x86, 0x05, 0x24, 0x7a, 0x85, 0x4f, 0x95, 0x06, 0x39, 0x5b, 0x6a,
-	0x9b, 0xe0, 0xd1, 0xb6, 0xbe, 0x3e, 0xda, 0x66, 0x58, 0x02, 0x9a, 0x6f, 0xe7, 0x34, 0x81, 0x57,
-	0x56, 0xfb, 0x0a, 0xa0, 0x26, 0x61, 0xde, 0x65, 0x62, 0xd7, 0xb8, 0x78, 0xd2, 0x0d, 0x97, 0xb2,
-	0x86, 0x8a, 0xd0, 0xe6, 0x14, 0x0a, 0x5a, 0xed, 0xea, 0x81, 0x13, 0x46, 0xa1, 0x53, 0x51, 0xe3,
-	0xbc, 0x10, 0x37, 0xce, 0x13, 0xa1, 0xb6, 0x0d, 0x66, 0x9e, 0x75, 0x71, 0x1f, 0x3e, 0x00, 0x71,
-	0xff, 0x0e, 0x4c, 0x6f, 0xc4, 0xaf, 0xd0, 0xd4, 0x85, 0xfa, 0xe9, 0x01, 0x6f, 0xe0, 0x4a, 0x47,
-	0x76, 0xc3, 0x7b, 0x97, 0xb0, 0x19, 0x77, 0x33, 0x42, 0x8e, 0xff, 0x74, 0x4c, 0x64, 0x9f, 0xb2,
-	0x86, 0xd1, 0xb7, 0xca, 0xc5, 0xf2, 0xd6, 0xf8, 0xa3, 0x31, 0x09, 0x89, 0x23, 0x0c, 0x62, 0x2a,
-	0x98, 0xdb, 0x4f, 0x15, 0x44, 0xa7, 0xa0, 0x50, 0xed, 0xb8, 0x2e, 0xb5, 0x3d, 0x3e, 0x1d, 0x11,
-	0xca, 0x66, 0xc3, 0x5a, 0x74, 0x39, 0x24, 0x61, 0xbd, 0x1f, 0x7a, 0x4d, 0xe8, 0xa9, 0x47, 0x45,
-	0x8e, 0x71, 0xef, 0xb9, 0x74, 0x5e, 0xe9, 0xb8, 0x27, 0x75, 0xdc, 0xa3, 0xe6, 0x1f, 0x74, 0xd7,
-	0xd5, 0x67, 0x86, 0x3f, 0x07, 0x19, 0xc2, 0xe2, 0xe5, 0xdf, 0x25, 0x86, 0x33, 0x44, 0xcf, 0xfe,
-	0xb3, 0x07, 0x96, 0xfd, 0xbb, 0x10, 0xc6, 0x64, 0xfe, 0x83, 0x4c, 0x74, 0x5b, 0x45, 0x0e, 0x46,
-	0x9a, 0x07, 0x86, 0x5d, 0x30, 0xbd, 0x22, 0x08, 0xf3, 0x23, 0x03, 0x8e, 0x24, 0xf6, 0x0e, 0x4c,
-	0x5f, 0xe6, 0x00, 0x4d, 0x9f, 0xb1, 0xdf, 0xa6, 0xef, 0xa6, 0xb6, 0x85, 0xfe, 0x14, 0xf6, 0xeb,
-	0x15, 0xf5, 0xfb, 0x19, 0x98, 0xe2, 0xd9, 0x79, 0xa4, 0x0a, 0xb0, 0xe6, 0x3f, 0xd9, 0x4a, 0xe1,
-	0x36, 0x63, 0x65, 0xed, 0xd2, 0x48, 0xe4, 0xad, 0xd6, 0x2b, 0x30, 0x2c, 0x12, 0x74, 0xe5, 0xe8,
-	0x4f, 0xa7, 0xb8, 0x86, 0x8c, 0xa0, 0x0a, 0x9d, 0x90, 0x95, 0x0e, 0x09, 0xc8, 0x91, 0xc5, 0x0d,
-	0xa2, 0x12, 0xe1, 0xd3, 0x29, 0xee, 0x22, 0xbb, 0x91, 0x45, 0x33, 0x96, 0x80, 0xe6, 0x7b, 0x19,
-	0x90, 0x2e, 0xf6, 0x3e, 0x84, 0xd2, 0x5f, 0x8b, 0x84, 0xd2, 0x7d, 0xc6, 0x88, 0x62, 0x72, 0x3d,
-	0xc3, 0xe8, 0x78, 0xf8, 0x73, 0x2c, 0x0d, 0xe8, 0xdd, 0x43, 0xe8, 0x3f, 0x1a, 0x90, 0x17, 0xfd,
-	0xee, 0x43, 0xf8, 0xbc, 0x16, 0x0d, 0x9f, 0x9f, 0x49, 0xb1, 0x8a, 0x1e, 0xa1, 0xf3, 0xbb, 0x59,
-	0x35, 0xfb, 0x20, 0xb8, 0x6a, 0x10, 0xb7, 0xa6, 0xc2, 0x86, 0x50, 0x03, 0x79, 0x23, 0x96, 0x34,
-	0xf4, 0x2d, 0x98, 0x72, 0xe5, 0xa5, 0x34, 0xad, 0x5d, 0x0c, 0x7c, 0x78, 0x36, 0xf5, 0xad, 0xb1,
-	0xba, 0xd9, 0x0e, 0xcb, 0x47, 0x38, 0x86, 0x8a, 0xbb, 0xf8, 0x70, 0xbf, 0xde, 0x8e, 0xdb, 0x32,
-	0xe5, 0xef, 0x4e, 0x0f, 0x68, 0x38, 0xa5, 0x5f, 0xef, 0x6a, 0xc6, 0xdd, 0x8c, 0x50, 0x03, 0xc6,
-	0xf4, 0xf7, 0x2e, 0x4a, 0x96, 0x8e, 0xa7, 0x7f, 0x58, 0x23, 0x6f, 0x26, 0xf4, 0x16, 0x1c, 0x41,
-	0x36, 0x77, 0x72, 0x50, 0xd0, 0x84, 0x2f, 0x96, 0x92, 0x8f, 0x1f, 0x4c, 0x4a, 0x9e, 0x1c, 0x41,
-	0x16, 0x06, 0x8a, 0x20, 0x8f, 0x45, 0x23, 0xc8, 0x47, 0xe3, 0x11, 0x24, 0x88, 0xd5, 0x45, 0xa2,
-	0x47, 0x06, 0x13, 0x2a, 0x94, 0xf2, 0x1f, 0x2e, 0xa5, 0x8a, 0xc9, 0xbb, 0x03, 0x36, 0xb4, 0xbb,
-	0xb3, 0xe0, 0x3f, 0x3b, 0xf0, 0x1f, 0x2c, 0xc5, 0x58, 0xa0, 0xf3, 0x01, 0xd3, 0x4a, 0xa7, 0xd5,
-	0x22, 0xee, 0xf6, 0xec, 0x98, 0x98, 0x70, 0x50, 0xab, 0xbb, 0x18, 0xa1, 0xe2, 0x58, 0x6f, 0xb4,
-	0x06, 0x39, 0x19, 0x89, 0xa9, 0xc7, 0x30, 0xcf, 0xa6, 0x09, 0xf2, 0x64, 0x8c, 0x20, 0x7f, 0x63,
-	0x85, 0xa3, 0x07, 0xd1, 0xf9, 0x3d, 0x82, 0xe8, 0x2b, 0x80, 0x9c, 0x75, 0x11, 0x8d, 0xd4, 0x2e,
-	0xc9, 0xcf, 0x8d, 0xb8, 0x54, 0xe6, 0x44, 0x84, 0x16, 0x1c, 0xd8, 0xd5, 0xae, 0x1e, 0x38, 0x61,
-	0x14, 0xd7, 0x6a, 0x15, 0xbe, 0x05, 0xaa, 0xa0, 0x02, 0xe6, 0x33, 0x29, 0x15, 0x2b, 0x8c, 0xcd,
-	0x67, 0xb8, 0x56, 0x97, 0x63, 0xa8, 0xb8, 0x8b, 0x0f, 0x7a, 0x1d, 0xc6, 0xb9, 0x08, 0x85, 0x8c,
-	0xe1, 0x1e, 0x19, 0x4f, 0xef, 0xee, 0x2c, 0x8c, 0xaf, 0xea, 0x90, 0x38, 0xca, 0xc1, 0xfc, 0x77,
-	0x06, 0x22, 0xfa, 0x87, 0xde, 0x31, 0x60, 0x9a, 0xc4, 0xbe, 0x95, 0xf1, 0xc3, 0x97, 0xaf, 0xa4,
-	0xfb, 0x80, 0xa9, 0xeb, 0x53, 0x9b, 0xb0, 0x22, 0x17, 0xef, 0xc2, 0x70, 0x37, 0x53, 0xf4, 0x43,
-	0x03, 0x0e, 0x93, 0xee, 0x8f, 0xa1, 0x94, 0x3f, 0x3c, 0x3b, 0xf0, 0xd7, 0x54, 0xa5, 0x87, 0x77,
-	0x77, 0x16, 0x92, 0x3e, 0x13, 0xc3, 0x49, 0xec, 0xd0, 0xab, 0x30, 0x44, 0xdc, 0xba, 0x9f, 0xbd,
-	0xa6, 0x67, 0xeb, 0x7f, 0xe3, 0x16, 0x3a, 0xe4, 0x25, 0xb7, 0xce, 0xb0, 0x00, 0x35, 0x3f, 0xcd,
-	0xc2, 0x54, 0xfc, 0xa9, 0xa1, 0x7a, 0x9d, 0x31, 0x94, 0xf8, 0x3a, 0xe3, 0x71, 0x18, 0x26, 0x55,
-	0x2f, 0x78, 0x2a, 0x11, 0x3e, 0xaf, 0xe5, 0x8d, 0x58, 0xd2, 0xd0, 0x0d, 0xc8, 0x33, 0x8f, 0xb8,
-	0x1e, 0x4f, 0x6a, 0x54, 0x5e, 0x99, 0xfa, 0x29, 0x71, 0xc5, 0x07, 0xc0, 0x21, 0x16, 0x3a, 0x13,
-	0x35, 0x67, 0x66, 0xdc, 0x9c, 0x4d, 0xeb, 0x6b, 0x19, 0x34, 0x27, 0x6e, 0x41, 0x41, 0x3b, 0x07,
-	0xe5, 0x5d, 0xce, 0xa5, 0xde, 0xf7, 0x50, 0xec, 0x26, 0xe5, 0x87, 0x72, 0x21, 0x45, 0xc7, 0x0f,
-	0x93, 0x46, 0xb1, 0x5b, 0xf7, 0x94, 0x34, 0x8a, 0xed, 0xd2, 0xd0, 0xcc, 0x49, 0x18, 0x8f, 0xbc,
-	0x18, 0x12, 0xe5, 0xd8, 0x1b, 0xc4, 0xa5, 0x0d, 0xa7, 0xc3, 0xe8, 0x83, 0x5a, 0x8e, 0x0d, 0x26,
-	0xb8, 0xdf, 0xe5, 0xd8, 0x10, 0x78, 0xef, 0x72, 0x6c, 0xd0, 0xf7, 0x81, 0x2d, 0xc7, 0x06, 0x33,
-	0xec, 0x11, 0x53, 0xfe, 0x2b, 0xa3, 0xad, 0x22, 0x1a, 0x57, 0x66, 0xee, 0x12, 0x57, 0xde, 0x82,
-	0x51, 0xcb, 0xf6, 0xa8, 0xbb, 0x45, 0x9a, 0xaa, 0x8a, 0xd8, 0xe7, 0x52, 0x97, 0x3b, 0xae, 0x8a,
-	0xaa, 0xfc, 0xa5, 0xae, 0x28, 0x1c, 0x1c, 0x20, 0xa2, 0x26, 0x1c, 0xf1, 0x0b, 0x35, 0x2e, 0x25,
-	0x61, 0xa1, 0x58, 0x5d, 0xb6, 0x3e, 0xef, 0xdf, 0xf4, 0x5d, 0x4c, 0xea, 0x74, 0xa7, 0x17, 0x01,
-	0x27, 0x83, 0x22, 0x06, 0xe3, 0x4c, 0x4b, 0xa8, 0x7c, 0x47, 0xd2, 0x67, 0x91, 0x2b, 0x9e, 0x83,
-	0x6a, 0x37, 0xb4, 0x3a, 0x28, 0x8e, 0xf2, 0x30, 0xff, 0x9a, 0x85, 0xc9, 0x98, 0xa4, 0xc5, 0x02,
-	0xc7, 0xfc, 0xfd, 0x0c, 0x1c, 0x73, 0x03, 0x05, 0x8e, 0xc9, 0x31, 0xcd, 0xd0, 0x40, 0x31, 0xcd,
-	0x0b, 0x32, 0xae, 0x50, 0x27, 0xb7, 0xb2, 0xac, 0xde, 0xce, 0x05, 0xbb, 0xb9, 0xaa, 0x13, 0x71,
-	0xb4, 0xaf, 0xf0, 0xc2, 0xb5, 0xee, 0x0f, 0xc5, 0x54, 0x50, 0x74, 0x36, 0xed, 0x8b, 0x84, 0x00,
-	0x40, 0x7a, 0xe1, 0x04, 0x02, 0x4e, 0x62, 0x57, 0xba, 0xf2, 0xe1, 0x67, 0xf3, 0x87, 0x3e, 0xfe,
-	0x6c, 0xfe, 0xd0, 0x27, 0x9f, 0xcd, 0x1f, 0x7a, 0x6b, 0x77, 0xde, 0xf8, 0x70, 0x77, 0xde, 0xf8,
-	0x78, 0x77, 0xde, 0xf8, 0x64, 0x77, 0xde, 0xf8, 0xc7, 0xee, 0xbc, 0xf1, 0xb3, 0xcf, 0xe7, 0x0f,
-	0xdd, 0x7c, 0xa2, 0x9f, 0x2f, 0xfa, 0xff, 0x13, 0x00, 0x00, 0xff, 0xff, 0xa6, 0x0e, 0x2f, 0xd4,
-	0xf8, 0x3f, 0x00, 0x00,
+	// 3673 bytes of a gzipped FileDescriptorProto
+	0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xdc, 0x3c, 0x4d, 0x6f, 0x23, 0x47,
+	0x76, 0xd3, 0xa4, 0x44, 0x89, 0x8f, 0xfa, 0xac, 0xd1, 0xd8, 0x5a, 0x6d, 0x2c, 0x4d, 0xda, 0x86,
+	0x61, 0xc7, 0x36, 0x95, 0xf9, 0xf2, 0x7c, 0x38, 0x99, 0x40, 0xa4, 0xe6, 0x43, 0x13, 0xad, 0x47,
+	0x29, 0x8e, 0x67, 0x76, 0x67, 0x67, 0xe0, 0x94, 0xc8, 0x12, 0xd9, 0x11, 0xd9, 0xcd, 0xed, 0x2a,
+	0x6a, 0xad, 0x24, 0x48, 0x36, 0x5f, 0xc0, 0x02, 0xf9, 0x40, 0x0e, 0x46, 0xbc, 0x01, 0x12, 0x20,
+	0x48, 0xce, 0xc9, 0x1f, 0xc8, 0xc1, 0x87, 0x5c, 0x8c, 0xc0, 0x09, 0x8c, 0x24, 0x07, 0x1f, 0x0c,
+	0x21, 0x96, 0x81, 0x1c, 0x7d, 0xcb, 0x65, 0x80, 0x00, 0x41, 0x7d, 0x74, 0x77, 0x75, 0xb3, 0x39,
+	0x62, 0x73, 0xa4, 0xc1, 0x60, 0x6f, 0x54, 0xbd, 0xaf, 0xaa, 0x57, 0xef, 0xbd, 0x7a, 0xef, 0x55,
+	0xb5, 0xe0, 0x62, 0xd3, 0xe1, 0xad, 0xde, 0x76, 0xb9, 0xee, 0x75, 0x56, 0xc9, 0x6e, 0xcf, 0xe1,
+	0xfb, 0xab, 0xbb, 0xc4, 0x6f, 0x7a, 0xab, 0xa4, 0xeb, 0xac, 0xee, 0x9d, 0x23, 0xed, 0x6e, 0x8b,
+	0x9c, 0x5b, 0x6d, 0x52, 0x97, 0xfa, 0x84, 0xd3, 0x46, 0xb9, 0xeb, 0x7b, 0xdc, 0x43, 0xaf, 0x45,
+	0x54, 0x65, 0x45, 0x55, 0x96, 0x54, 0x65, 0xd2, 0x75, 0xca, 0x01, 0xd5, 0xd2, 0x3b, 0x06, 0xef,
+	0xa6, 0xd7, 0xf4, 0x56, 0x25, 0xf1, 0x76, 0x6f, 0x47, 0xfe, 0x25, 0xff, 0x90, 0xbf, 0x14, 0xd3,
+	0xa5, 0xdb, 0xbb, 0x57, 0x58, 0xd9, 0x91, 0x92, 0xe9, 0x47, 0x9c, 0xba, 0xcc, 0xf1, 0x5c, 0xf6,
+	0x0e, 0xe9, 0x3a, 0x8c, 0xfa, 0x7b, 0xd4, 0x5f, 0xed, 0xee, 0x36, 0x05, 0x8c, 0xc5, 0x11, 0x56,
+	0xf7, 0xfa, 0xa6, 0xb7, 0x74, 0x31, 0xe2, 0xd4, 0x21, 0xf5, 0x96, 0xe3, 0x52, 0x7f, 0x3f, 0x22,
+	0xef, 0x50, 0x4e, 0xd2, 0xa8, 0x56, 0x07, 0x51, 0xf9, 0x3d, 0x97, 0x3b, 0x1d, 0xda, 0x47, 0xf0,
+	0xee, 0x51, 0x04, 0xac, 0xde, 0xa2, 0x1d, 0x92, 0xa4, 0xb3, 0x1f, 0xc1, 0xe9, 0x35, 0x97, 0xb4,
+	0xf7, 0x99, 0xc3, 0x70, 0xcf, 0x5d, 0xf3, 0x9b, 0xbd, 0x0e, 0x75, 0x39, 0x3a, 0x0b, 0x63, 0x2e,
+	0xe9, 0xd0, 0x45, 0xeb, 0xac, 0xf5, 0x46, 0xb1, 0x32, 0xf5, 0xd9, 0xc1, 0xca, 0xa9, 0xc3, 0x83,
+	0x95, 0xb1, 0xf7, 0x49, 0x87, 0x62, 0x09, 0x41, 0xaf, 0xc2, 0xf8, 0x1e, 0x69, 0xf7, 0xe8, 0x62,
+	0x4e, 0xa2, 0x4c, 0x6b, 0x94, 0xf1, 0xfb, 0x62, 0x10, 0x2b, 0x98, 0xfd, 0x47, 0xf9, 0x18, 0xfb,
+	0xef, 0x51, 0x4e, 0x1a, 0x84, 0x13, 0xd4, 0x81, 0x42, 0x9b, 0x6c, 0xd3, 0x36, 0x5b, 0xb4, 0xce,
+	0xe6, 0xdf, 0x28, 0x9d, 0xbf, 0x51, 0x1e, 0x66, 0x13, 0xcb, 0x29, 0xac, 0xca, 0x9b, 0x92, 0xcf,
+	0x0d, 0x97, 0xfb, 0xfb, 0x95, 0x19, 0x3d, 0x89, 0x82, 0x1a, 0xc4, 0x5a, 0x08, 0xfa, 0x03, 0x0b,
+	0x4a, 0xc4, 0x75, 0x3d, 0x4e, 0xb8, 0xd8, 0xa6, 0xc5, 0x9c, 0x14, 0x7a, 0x67, 0x74, 0xa1, 0x6b,
+	0x11, 0x33, 0x25, 0xf9, 0xb4, 0x96, 0x5c, 0x32, 0x20, 0xd8, 0x94, 0xb9, 0x74, 0x15, 0x4a, 0xc6,
+	0x54, 0xd1, 0x1c, 0xe4, 0x77, 0xe9, 0xbe, 0xd2, 0x2f, 0x16, 0x3f, 0xd1, 0x42, 0x4c, 0xa1, 0x5a,
+	0x83, 0xd7, 0x72, 0x57, 0xac, 0xa5, 0xeb, 0x30, 0x97, 0x14, 0x98, 0x85, 0xde, 0xfe, 0x0b, 0x0b,
+	0x16, 0x8c, 0x55, 0x60, 0xba, 0x43, 0x7d, 0xea, 0xd6, 0x29, 0x5a, 0x85, 0xa2, 0xd8, 0x4b, 0xd6,
+	0x25, 0xf5, 0x60, 0xab, 0xe7, 0xf5, 0x42, 0x8a, 0xef, 0x07, 0x00, 0x1c, 0xe1, 0x84, 0x66, 0x91,
+	0x7b, 0x9a, 0x59, 0x74, 0x5b, 0x84, 0xd1, 0xc5, 0x7c, 0xdc, 0x2c, 0xb6, 0xc4, 0x20, 0x56, 0x30,
+	0xfb, 0x57, 0xe1, 0x3b, 0xc1, 0x7c, 0xee, 0xd1, 0x4e, 0xb7, 0x4d, 0x38, 0x8d, 0x26, 0x75, 0xa4,
+	0xe9, 0xd9, 0xb3, 0x30, 0xbd, 0xd6, 0xed, 0xfa, 0xde, 0x1e, 0x6d, 0xd4, 0x38, 0x69, 0x52, 0xfb,
+	0x0f, 0x2d, 0x38, 0xb3, 0xe6, 0x37, 0xbd, 0xea, 0xfa, 0x5a, 0xb7, 0x7b, 0x9b, 0x92, 0x36, 0x6f,
+	0xd5, 0x38, 0xe1, 0x3d, 0x86, 0xae, 0x43, 0x81, 0xc9, 0x5f, 0x9a, 0xdd, 0xeb, 0x81, 0x85, 0x28,
+	0xf8, 0x93, 0x83, 0x95, 0x85, 0x14, 0x42, 0x8a, 0x35, 0x15, 0x7a, 0x13, 0x26, 0x3a, 0x94, 0x31,
+	0xd2, 0x0c, 0xd6, 0x3c, 0xab, 0x19, 0x4c, 0x7c, 0x4f, 0x0d, 0xe3, 0x00, 0x6e, 0xff, 0x6b, 0x0e,
+	0x66, 0x43, 0x5e, 0x5a, 0xfc, 0x09, 0x28, 0xb8, 0x07, 0x53, 0x2d, 0x63, 0x85, 0x52, 0xcf, 0xa5,
+	0xf3, 0xef, 0x0d, 0x69, 0xcb, 0x69, 0x4a, 0xaa, 0x2c, 0x68, 0x31, 0x53, 0xe6, 0x28, 0x8e, 0x89,
+	0x41, 0x1d, 0x00, 0xb6, 0xef, 0xd6, 0xb5, 0xd0, 0x31, 0x29, 0xf4, 0x6a, 0x46, 0xa1, 0xb5, 0x90,
+	0x41, 0x05, 0x69, 0x91, 0x10, 0x8d, 0x61, 0x43, 0x80, 0xfd, 0x4f, 0x16, 0x9c, 0x4e, 0xa1, 0x43,
+	0xbf, 0x92, 0xd8, 0xcf, 0xd7, 0xfa, 0xf6, 0x13, 0xf5, 0x91, 0x45, 0xbb, 0xf9, 0x36, 0x4c, 0xfa,
+	0x74, 0xcf, 0x11, 0xb1, 0x5a, 0x6b, 0x78, 0x4e, 0xd3, 0x4f, 0x62, 0x3d, 0x8e, 0x43, 0x0c, 0xf4,
+	0x16, 0x14, 0x83, 0xdf, 0x42, 0xcd, 0x79, 0x61, 0xce, 0x62, 0xe3, 0x02, 0x54, 0x86, 0x23, 0xb8,
+	0xfd, 0xfb, 0x30, 0x5e, 0x6d, 0x11, 0x9f, 0x0b, 0x8b, 0xf1, 0x69, 0xd7, 0xfb, 0x00, 0x6f, 0xea,
+	0x29, 0x86, 0x16, 0x83, 0xd5, 0x30, 0x0e, 0xe0, 0x43, 0x6c, 0xf6, 0x9b, 0x30, 0xb1, 0x47, 0x7d,
+	0x39, 0xdf, 0x7c, 0x9c, 0xd9, 0x7d, 0x35, 0x8c, 0x03, 0xb8, 0xfd, 0x9f, 0x16, 0x2c, 0xc8, 0x19,
+	0xac, 0x3b, 0xac, 0xee, 0xed, 0x51, 0x7f, 0x1f, 0x53, 0xd6, 0x6b, 0x1f, 0xf3, 0x84, 0xd6, 0x61,
+	0x8e, 0xd1, 0xce, 0x1e, 0xf5, 0xab, 0x9e, 0xcb, 0xb8, 0x4f, 0x1c, 0x97, 0xeb, 0x99, 0x2d, 0x6a,
+	0xec, 0xb9, 0x5a, 0x02, 0x8e, 0xfb, 0x28, 0xd0, 0x1b, 0x30, 0xa9, 0xa7, 0x2d, 0x4c, 0x49, 0x28,
+	0x76, 0x4a, 0xec, 0x81, 0x5e, 0x13, 0xc3, 0x21, 0xd4, 0xfe, 0x1f, 0x0b, 0xe6, 0xe5, 0xaa, 0x6a,
+	0xbd, 0x6d, 0x56, 0xf7, 0x9d, 0xae, 0x08, 0x81, 0x2f, 0xe2, 0x92, 0xae, 0xc3, 0x4c, 0x23, 0x50,
+	0xfc, 0xa6, 0xd3, 0x71, 0xb8, 0xf4, 0x91, 0xf1, 0xca, 0x4b, 0x9a, 0xc7, 0xcc, 0x7a, 0x0c, 0x8a,
+	0x13, 0xd8, 0xf6, 0xdf, 0xe4, 0xe1, 0x74, 0x80, 0x42, 0x1b, 0x6b, 0x3e, 0x77, 0x76, 0x48, 0x9d,
+	0x33, 0xd4, 0x80, 0xa9, 0x46, 0x34, 0xcc, 0xb5, 0xe7, 0xfd, 0x52, 0x59, 0x1d, 0xf7, 0x65, 0xf3,
+	0xb8, 0x2f, 0x77, 0x77, 0x9b, 0x62, 0x80, 0x95, 0x45, 0x56, 0x51, 0xde, 0x3b, 0x57, 0xbe, 0xe7,
+	0x74, 0x68, 0xe4, 0xdd, 0x06, 0x7b, 0x8e, 0x63, 0x5c, 0xd1, 0x03, 0xc8, 0x37, 0x1d, 0xae, 0x0f,
+	0xe3, 0x2b, 0xc3, 0xb9, 0xf5, 0x2d, 0x27, 0x69, 0x6a, 0x95, 0x92, 0x16, 0x95, 0xbf, 0xe5, 0x70,
+	0x2c, 0x38, 0xa2, 0x6d, 0x28, 0x38, 0x1d, 0xd2, 0xa4, 0xc1, 0x99, 0x7b, 0x6d, 0x38, 0xde, 0x1b,
+	0x82, 0x26, 0xc9, 0x3d, 0x3c, 0xdd, 0x25, 0x94, 0x61, 0xcd, 0x59, 0xc8, 0xa8, 0x0b, 0x13, 0x51,
+	0x4e, 0x3a, 0xb4, 0x8c, 0x34, 0x67, 0x89, 0x64, 0x48, 0x28, 0xc3, 0x9a, 0xb3, 0xfd, 0x65, 0x0e,
+	0xe6, 0x22, 0xfd, 0x55, 0xbd, 0x4e, 0xc7, 0xe1, 0x68, 0x09, 0x72, 0x4e, 0x43, 0x5b, 0x20, 0x68,
+	0xc2, 0xdc, 0xc6, 0x3a, 0xce, 0x39, 0x0d, 0xf4, 0x3a, 0x14, 0xb6, 0x7d, 0xe2, 0xd6, 0x5b, 0xda,
+	0xf2, 0x42, 0xc6, 0x15, 0x39, 0x8a, 0x35, 0x14, 0xbd, 0x02, 0x79, 0x4e, 0x9a, 0xda, 0xe0, 0x42,
+	0xfd, 0xdd, 0x23, 0x4d, 0x2c, 0xc6, 0x85, 0xa5, 0xb3, 0xde, 0xf6, 0x6f, 0xd1, 0xba, 0xda, 0x79,
+	0xc3, 0xd2, 0x6b, 0x6a, 0x18, 0x07, 0x70, 0x21, 0x91, 0xf4, 0x78, 0xcb, 0xf3, 0x17, 0xc7, 0xe3,
+	0x12, 0xd7, 0xe4, 0x28, 0xd6, 0x50, 0x71, 0x26, 0xd5, 0xe5, 0xfc, 0x39, 0xf5, 0x17, 0x0b, 0xf1,
+	0x33, 0xa9, 0x1a, 0x00, 0x70, 0x84, 0x83, 0x1e, 0x43, 0xa9, 0xee, 0x53, 0xc2, 0x3d, 0x7f, 0x9d,
+	0x70, 0xba, 0x38, 0x91, 0xd9, 0x02, 0x67, 0x45, 0x62, 0x54, 0x8d, 0x58, 0x60, 0x93, 0x9f, 0xfd,
+	0xad, 0x05, 0x8b, 0x91, 0x6a, 0xe5, 0xde, 0x46, 0xc9, 0x80, 0x56, 0x8f, 0x35, 0x40, 0x3d, 0xaf,
+	0x43, 0xa1, 0xe1, 0x34, 0x29, 0xe3, 0x49, 0x2d, 0xaf, 0xcb, 0x51, 0xac, 0xa1, 0xe8, 0x3c, 0x40,
+	0xd3, 0xe1, 0x3a, 0x38, 0x68, 0x65, 0x87, 0x47, 0xd0, 0xad, 0x10, 0x82, 0x0d, 0x2c, 0xf4, 0x00,
+	0x8a, 0x72, 0x9a, 0x23, 0xba, 0x9d, 0x3c, 0x2a, 0xaa, 0x01, 0x03, 0x1c, 0xf1, 0xb2, 0xbf, 0x18,
+	0x83, 0x89, 0x9b, 0x3e, 0x75, 0x9a, 0x2d, 0x8e, 0x7e, 0x13, 0x26, 0x3b, 0x3a, 0xa9, 0x94, 0x8b,
+	0x2c, 0x9d, 0xff, 0xe5, 0xe1, 0x64, 0xdc, 0x95, 0x9b, 0x2e, 0x12, 0xd2, 0x68, 0x21, 0xd1, 0x18,
+	0x0e, 0xb9, 0x8a, 0x84, 0x8c, 0xb4, 0x1d, 0xc2, 0xe4, 0xbe, 0x19, 0x09, 0xd9, 0x9a, 0x18, 0xc4,
+	0x0a, 0x86, 0x7e, 0x08, 0x05, 0xcf, 0x77, 0x9a, 0x8e, 0xbb, 0x58, 0x94, 0x93, 0xb8, 0x30, 0x9c,
+	0x0b, 0xe9, 0x55, 0xdc, 0x95, 0xa4, 0x91, 0xf2, 0xd5, 0xdf, 0x58, 0xb3, 0x44, 0x0f, 0x61, 0x42,
+	0x19, 0x53, 0xe0, 0xa0, 0xab, 0x43, 0x07, 0x18, 0x65, 0x8f, 0x91, 0xd1, 0xab, 0xbf, 0x19, 0x0e,
+	0x18, 0xa2, 0x5a, 0x18, 0x5f, 0xc6, 0x24, 0xeb, 0xb7, 0x32, 0xc4, 0x97, 0x81, 0x01, 0xa5, 0x16,
+	0x06, 0x94, 0xf1, 0x2c, 0x4c, 0x65, 0xc8, 0x18, 0x14, 0x41, 0x84, 0x8a, 0x75, 0xe6, 0x52, 0x18,
+	0x41, 0xc5, 0x3a, 0x6d, 0x9a, 0x89, 0xa7, 0x3b, 0x41, 0x62, 0x63, 0x7f, 0x9c, 0x87, 0x79, 0x8d,
+	0x59, 0xf5, 0xda, 0x6d, 0x5a, 0x97, 0xc7, 0xa4, 0x8a, 0x4f, 0xf9, 0xd4, 0xf8, 0xe4, 0xc0, 0xb8,
+	0xc3, 0x69, 0x27, 0x28, 0xc0, 0x2a, 0x99, 0x66, 0x13, 0xc9, 0x28, 0x6f, 0x08, 0x26, 0xaa, 0x06,
+	0x0a, 0x77, 0x49, 0x63, 0x61, 0x25, 0x01, 0xfd, 0x89, 0x05, 0xa7, 0xf7, 0xa8, 0xef, 0xec, 0x38,
+	0x75, 0x59, 0xc1, 0xdc, 0x76, 0x18, 0xf7, 0xfc, 0x7d, 0x7d, 0x22, 0xbc, 0x3b, 0x9c, 0xe4, 0xfb,
+	0x06, 0x83, 0x0d, 0x77, 0xc7, 0xab, 0x7c, 0x57, 0x4b, 0x3b, 0x7d, 0xbf, 0x9f, 0x35, 0x4e, 0x93,
+	0xb7, 0xd4, 0x05, 0x88, 0x66, 0x9b, 0x52, 0x40, 0x6d, 0x9a, 0x05, 0xd4, 0xd0, 0x13, 0x0b, 0x16,
+	0x1b, 0x84, 0x2c, 0xb3, 0xf0, 0xfa, 0xd4, 0x82, 0x92, 0x86, 0x6f, 0x3a, 0x8c, 0xa3, 0x47, 0x7d,
+	0xde, 0x5e, 0x1e, 0xce, 0xdb, 0x05, 0xb5, 0xf4, 0xf5, 0x30, 0x5f, 0x0d, 0x46, 0x0c, 0x4f, 0xc7,
+	0xc1, 0x96, 0x2a, 0xc5, 0xbe, 0x93, 0x69, 0xfe, 0x51, 0x60, 0x90, 0x3a, 0xd2, 0x7b, 0x67, 0xfb,
+	0x30, 0x1d, 0x73, 0x72, 0x74, 0x09, 0xc6, 0x76, 0x1d, 0x37, 0x38, 0xf5, 0x7e, 0x31, 0xc8, 0xa7,
+	0x7e, 0xdd, 0x71, 0x1b, 0x4f, 0x0e, 0x56, 0xe6, 0x63, 0xc8, 0x62, 0x10, 0x4b, 0xf4, 0xa3, 0xd3,
+	0xb0, 0x6b, 0x93, 0x3f, 0xfb, 0xbb, 0x95, 0x53, 0x3f, 0xf9, 0xea, 0xec, 0x29, 0xfb, 0x93, 0x3c,
+	0xcc, 0x25, 0xb5, 0x3a, 0x44, 0x43, 0x22, 0x8a, 0x61, 0x93, 0x27, 0x1a, 0xc3, 0x72, 0x27, 0x17,
+	0xc3, 0xf2, 0x27, 0x11, 0xc3, 0xc6, 0x8e, 0x2d, 0x86, 0xd9, 0xff, 0x6e, 0xc1, 0x4c, 0xb8, 0x33,
+	0x3f, 0xea, 0x89, 0x93, 0x35, 0xd2, 0xba, 0x75, 0xfc, 0x5a, 0xff, 0x10, 0x26, 0x98, 0xd7, 0xf3,
+	0xeb, 0x32, 0x7d, 0x14, 0xdc, 0x2f, 0x66, 0x0b, 0x9a, 0x8a, 0xd6, 0xc8, 0x99, 0xd4, 0x00, 0x0e,
+	0xb8, 0xda, 0x8f, 0xc2, 0xf5, 0x68, 0x90, 0xca, 0x28, 0x7c, 0x91, 0x6f, 0x89, 0xf5, 0x4c, 0x9a,
+	0x19, 0x85, 0x18, 0xc5, 0x1a, 0x8a, 0x6c, 0x19, 0xce, 0x83, 0xc4, 0xb6, 0x58, 0x01, 0x1d, 0x95,
+	0xe5, 0x1e, 0x28, 0x88, 0xfd, 0x6d, 0x3e, 0xf4, 0x1e, 0x5d, 0xbe, 0xfe, 0x18, 0x40, 0x45, 0x26,
+	0xda, 0xd8, 0x70, 0x75, 0xe8, 0xad, 0x8e, 0x70, 0x10, 0xe8, 0x70, 0x28, 0xb8, 0xa8, 0xd8, 0x1b,
+	0xe6, 0x00, 0x11, 0x00, 0x1b, 0xa2, 0xd0, 0xef, 0x40, 0x89, 0xe8, 0x96, 0xc9, 0x4d, 0xcf, 0xd7,
+	0x36, 0xbc, 0x3e, 0x8a, 0xe4, 0xb5, 0x88, 0x4d, 0xb2, 0xf5, 0x15, 0x41, 0xb0, 0x29, 0x6d, 0xc9,
+	0x87, 0xd9, 0xc4, 0x7c, 0x53, 0xa2, 0xef, 0x46, 0x3c, 0xfa, 0x5e, 0xc8, 0x72, 0x2c, 0xe8, 0x3e,
+	0x90, 0xd9, 0x33, 0x63, 0x30, 0x97, 0x9c, 0xe9, 0xb1, 0x09, 0x8d, 0x35, 0x9f, 0xcc, 0x78, 0xff,
+	0xb7, 0x39, 0x28, 0x86, 0x1e, 0x9f, 0xa5, 0x4a, 0x55, 0x27, 0x75, 0xee, 0x88, 0x4a, 0x22, 0x3f,
+	0x4c, 0x25, 0x31, 0x36, 0xb8, 0x92, 0x08, 0x3a, 0x59, 0x85, 0xa7, 0x77, 0xb2, 0x8c, 0x4a, 0x62,
+	0x62, 0xf8, 0x4a, 0x62, 0xf2, 0xe8, 0x4a, 0xc2, 0xfe, 0x7b, 0x0b, 0x50, 0x7f, 0xd9, 0x98, 0x45,
+	0x51, 0x24, 0x19, 0x87, 0x87, 0x3c, 0xa5, 0x93, 0xb5, 0xdb, 0xe0, 0x70, 0x6c, 0x7f, 0x3a, 0x0e,
+	0xb3, 0xb7, 0x9c, 0x91, 0x1b, 0x0e, 0x1c, 0x5e, 0x56, 0x9c, 0x6a, 0x54, 0xe7, 0x48, 0x35, 0xee,
+	0x13, 0x4e, 0x9b, 0xfb, 0x7a, 0x7f, 0xaf, 0x69, 0xd2, 0x97, 0xab, 0xe9, 0x68, 0x4f, 0x06, 0x83,
+	0xf0, 0x20, 0xd6, 0x43, 0x1b, 0xc9, 0x7b, 0x30, 0xcd, 0xb8, 0xef, 0xd4, 0xb9, 0x6a, 0x69, 0xb0,
+	0xc5, 0x92, 0x8c, 0x72, 0x67, 0x34, 0xfa, 0x74, 0xcd, 0x04, 0xe2, 0x38, 0x6e, 0x6a, 0xa7, 0x64,
+	0x2c, 0x73, 0xa7, 0x64, 0x15, 0x8a, 0xa4, 0xdd, 0xf6, 0x7e, 0x7c, 0x8f, 0x34, 0x99, 0x2e, 0x55,
+	0x43, 0xab, 0x59, 0x0b, 0x00, 0x38, 0xc2, 0x41, 0x65, 0x00, 0xa7, 0xe9, 0x7a, 0x3e, 0x95, 0x14,
+	0x05, 0x19, 0x6e, 0x67, 0x44, 0xac, 0xdb, 0x08, 0x47, 0xb1, 0x81, 0x81, 0x6a, 0x70, 0xc6, 0x71,
+	0x19, 0xad, 0xf7, 0x7c, 0x5a, 0xdb, 0x75, 0xba, 0xf7, 0x36, 0x6b, 0x32, 0x4a, 0xec, 0x4b, 0x6b,
+	0x9e, 0xac, 0xbc, 0xa2, 0x85, 0x9d, 0xd9, 0x48, 0x43, 0xc2, 0xe9, 0xb4, 0xe8, 0x22, 0x4c, 0x39,
+	0x6e, 0xbd, 0xdd, 0x6b, 0xd0, 0x2d, 0xc2, 0x5b, 0x6c, 0x71, 0x52, 0x4e, 0x63, 0xee, 0xf0, 0x60,
+	0x65, 0x6a, 0xc3, 0x18, 0xc7, 0x31, 0x2c, 0x41, 0x45, 0x3f, 0x32, 0xa8, 0x8a, 0x11, 0xd5, 0x8d,
+	0x8f, 0x4c, 0x2a, 0x13, 0x2b, 0xa5, 0x97, 0x04, 0x99, 0x7a, 0x49, 0x7f, 0x95, 0x87, 0x82, 0x6a,
+	0xe5, 0xa2, 0x4b, 0x89, 0x7e, 0xe9, 0x2b, 0x7d, 0xfd, 0xd2, 0x52, 0x5a, 0xdb, 0xdb, 0x86, 0x82,
+	0xc3, 0x58, 0x2f, 0x7e, 0xba, 0x6d, 0xc8, 0x11, 0xac, 0x21, 0xc8, 0x01, 0x20, 0x41, 0xab, 0x35,
+	0x48, 0x5d, 0x2e, 0x65, 0xed, 0x08, 0x27, 0xba, 0xc1, 0x21, 0x80, 0x61, 0x83, 0xb9, 0xec, 0xf0,
+	0x78, 0xee, 0x8e, 0xd3, 0xd4, 0x75, 0xf8, 0x75, 0x23, 0x6b, 0x8e, 0x6e, 0xdf, 0x3e, 0x0c, 0xaf,
+	0xe7, 0xa2, 0x04, 0x3a, 0x86, 0x20, 0x32, 0xe9, 0x3b, 0xb5, 0xbb, 0xef, 0xab, 0xe5, 0x54, 0x25,
+	0x47, 0xac, 0x39, 0x0b, 0x19, 0x5e, 0x8f, 0x77, 0x7b, 0x5c, 0xda, 0xe4, 0x31, 0xc9, 0xb8, 0x2b,
+	0x39, 0x62, 0xcd, 0xd9, 0xfe, 0xc4, 0x82, 0x59, 0xa5, 0xee, 0x6a, 0x8b, 0xd6, 0x77, 0x6b, 0x9c,
+	0x76, 0x45, 0x62, 0xdb, 0x63, 0x94, 0x25, 0x13, 0xdb, 0x0f, 0x18, 0x65, 0x58, 0x42, 0x8c, 0xd5,
+	0xe7, 0x4e, 0x6a, 0xf5, 0xf6, 0x3f, 0x5a, 0x30, 0x2e, 0x33, 0xc8, 0x2c, 0xa1, 0x2e, 0xde, 0x55,
+	0xc9, 0x0d, 0xd5, 0x55, 0x39, 0xa2, 0xdf, 0x15, 0x35, 0x74, 0xc6, 0x9e, 0xd6, 0xd0, 0xb1, 0xbf,
+	0xb1, 0x60, 0x21, 0xad, 0x49, 0x98, 0x65, 0xfa, 0x6f, 0xc3, 0x64, 0xb7, 0x4d, 0xf8, 0x8e, 0xe7,
+	0x77, 0x92, 0xb7, 0x01, 0x5b, 0x7a, 0x1c, 0x87, 0x18, 0xc8, 0x07, 0xf0, 0x83, 0x6a, 0x24, 0x30,
+	0xf7, 0xeb, 0x59, 0x0f, 0x9f, 0x78, 0x77, 0x2b, 0x52, 0x56, 0x38, 0xc4, 0xb0, 0x21, 0xc5, 0xfe,
+	0xd3, 0x71, 0x98, 0x97, 0x24, 0xa3, 0x1e, 0x46, 0xa3, 0xec, 0x50, 0x17, 0x5e, 0x92, 0x35, 0x44,
+	0xff, 0xf9, 0xa5, 0x36, 0xed, 0x8a, 0xa6, 0x7f, 0x69, 0x23, 0x15, 0xeb, 0xc9, 0x40, 0x08, 0x1e,
+	0xc0, 0xb7, 0xff, 0x50, 0x82, 0x9f, 0xbf, 0x43, 0xc9, 0x34, 0xb6, 0x89, 0x23, 0x8d, 0x6d, 0xe0,
+	0x11, 0x36, 0xf9, 0x0c, 0x47, 0x58, 0xff, 0xb1, 0x52, 0xcc, 0x74, 0xac, 0xfc, 0x75, 0x0e, 0x26,
+	0xb6, 0x7c, 0x4f, 0x36, 0x9b, 0x4f, 0xbe, 0x6f, 0x79, 0x17, 0xc6, 0x58, 0x97, 0xd6, 0x75, 0xcc,
+	0x3b, 0x37, 0x9c, 0xa7, 0xe9, 0xe9, 0xd5, 0xba, 0xb4, 0x5e, 0x99, 0x14, 0x61, 0x54, 0xfc, 0xc2,
+	0x92, 0x91, 0xd1, 0x80, 0xcb, 0x67, 0x49, 0xf6, 0x03, 0x96, 0x4f, 0x6f, 0xc0, 0x7d, 0x6a, 0x41,
+	0x49, 0x63, 0xbe, 0xb0, 0x9d, 0x1e, 0x3d, 0xbf, 0x01, 0x9d, 0x9e, 0x3f, 0x8f, 0x56, 0x20, 0x94,
+	0x86, 0x7e, 0x0f, 0xe6, 0xbb, 0xbe, 0xd7, 0xf1, 0x84, 0xa7, 0x6e, 0x79, 0x6d, 0xa7, 0xee, 0xd0,
+	0xa0, 0x59, 0x78, 0x69, 0x68, 0x79, 0x06, 0xf9, 0x7e, 0xe5, 0x3b, 0x5a, 0xee, 0xfc, 0x56, 0x92,
+	0x2f, 0xee, 0x17, 0x65, 0xff, 0x97, 0x05, 0xd3, 0x31, 0xdd, 0xa3, 0x3a, 0x40, 0xdd, 0x73, 0x1b,
+	0x0e, 0x0f, 0x2f, 0x64, 0x4b, 0xe7, 0x57, 0x87, 0xd3, 0x6a, 0x35, 0xa0, 0x8b, 0x8c, 0x2e, 0x1c,
+	0x62, 0xd8, 0x60, 0x8b, 0x2e, 0x04, 0xef, 0x17, 0xe2, 0xf9, 0x92, 0x7a, 0xbf, 0xf0, 0xe4, 0x60,
+	0x65, 0x4a, 0xcf, 0xc9, 0x7c, 0xcf, 0x90, 0xe5, 0x95, 0xc0, 0x3f, 0xe4, 0xa0, 0x18, 0xae, 0xff,
+	0x39, 0xb8, 0xd1, 0x07, 0x31, 0x37, 0xba, 0x90, 0x71, 0xe7, 0xa4, 0x23, 0x85, 0x39, 0x89, 0xe1,
+	0x4c, 0x8f, 0x13, 0xce, 0x94, 0xd5, 0x24, 0x8e, 0x70, 0xa7, 0x7f, 0x51, 0x9b, 0xaf, 0x70, 0x9f,
+	0x83, 0x43, 0xdd, 0x8b, 0x3b, 0xd4, 0x6a, 0xc6, 0xd5, 0x0c, 0x70, 0xa9, 0x9f, 0x5a, 0x30, 0x9b,
+	0x70, 0x02, 0xf4, 0x2a, 0x8c, 0xcb, 0xee, 0x90, 0xb6, 0xaf, 0x90, 0x50, 0xf7, 0x12, 0x24, 0x0c,
+	0x6d, 0xc1, 0x02, 0xe9, 0x71, 0x2f, 0xa4, 0xbd, 0xe1, 0x92, 0xed, 0x36, 0x55, 0x0d, 0x82, 0xc9,
+	0xca, 0x2f, 0x68, 0x9a, 0x85, 0xb5, 0x14, 0x1c, 0x9c, 0x4a, 0x69, 0x7f, 0x9e, 0x03, 0x14, 0x0e,
+	0x66, 0xe9, 0xaa, 0x3e, 0x86, 0x89, 0x1d, 0xd5, 0xff, 0x79, 0xb6, 0xb6, 0x78, 0xa5, 0x64, 0xde,
+	0x0c, 0x04, 0x3c, 0xd1, 0x0f, 0x8e, 0xc7, 0x8e, 0xa0, 0xdf, 0x86, 0xd0, 0x43, 0x80, 0x1d, 0xc7,
+	0x75, 0x58, 0x6b, 0xc4, 0x0b, 0x3c, 0x79, 0x9a, 0xdf, 0x0c, 0x39, 0x60, 0x83, 0x9b, 0xfd, 0x71,
+	0xce, 0xb0, 0x4f, 0x19, 0x2e, 0x87, 0xda, 0xd7, 0x37, 0xe3, 0xca, 0x2c, 0xf6, 0x5f, 0x99, 0x18,
+	0x8a, 0x19, 0xdb, 0x23, 0x7e, 0xd0, 0xbd, 0xbd, 0x9c, 0x51, 0x2d, 0xf7, 0x89, 0xef, 0x88, 0x8d,
+	0x8f, 0xb6, 0xf4, 0x3e, 0xf1, 0x19, 0x96, 0x2c, 0xd1, 0xf7, 0xc5, 0x54, 0x69, 0x58, 0xb3, 0x65,
+	0x8e, 0x09, 0x9c, 0x76, 0xcd, 0xf5, 0xd1, 0x2e, 0xc3, 0x8a, 0xa1, 0xfd, 0x6f, 0x05, 0xc3, 0xe0,
+	0x75, 0xd4, 0xbe, 0x03, 0xa8, 0x4d, 0x18, 0xbf, 0x4d, 0xdc, 0x86, 0x30, 0x4f, 0xba, 0xe3, 0x53,
+	0xd6, 0xd2, 0x19, 0xda, 0x92, 0xe6, 0x82, 0x36, 0xfb, 0x30, 0x70, 0x0a, 0x15, 0xba, 0x14, 0x0f,
+	0xce, 0x2b, 0xc9, 0xe0, 0x3c, 0x13, 0x79, 0xdb, 0x68, 0xe1, 0xd9, 0x34, 0xf7, 0xf1, 0x13, 0x30,
+	0xf7, 0xdf, 0x85, 0xf9, 0x9d, 0xe4, 0x15, 0x9a, 0xbe, 0x50, 0xbf, 0x3c, 0xe2, 0x0d, 0x5c, 0xe5,
+	0xcc, 0x61, 0x74, 0xef, 0x12, 0x0d, 0xe3, 0x7e, 0x41, 0xc8, 0x0b, 0x9e, 0x8e, 0xc9, 0xea, 0x53,
+	0xf5, 0x30, 0x86, 0x76, 0xb9, 0x44, 0xdd, 0x9a, 0x7c, 0x34, 0xa6, 0x58, 0xe2, 0x98, 0x80, 0x84,
+	0x0b, 0x16, 0x8e, 0xd3, 0x05, 0xd1, 0x25, 0x28, 0xd5, 0x7b, 0xbe, 0x4f, 0x5d, 0x2e, 0xa6, 0x23,
+	0x53, 0xd9, 0x7c, 0xd4, 0x8b, 0xae, 0x46, 0x20, 0x6c, 0xe2, 0x09, 0x73, 0x34, 0xfe, 0x5c, 0xe3,
+	0x9c, 0x76, 0xba, 0x5c, 0x76, 0xc1, 0xf2, 0x91, 0x39, 0x56, 0xfb, 0x30, 0x70, 0x0a, 0x15, 0xfa,
+	0x50, 0xfa, 0x3c, 0xa7, 0xb2, 0x5e, 0x79, 0xf6, 0xba, 0xbc, 0xa8, 0xe3, 0x05, 0x57, 0xf1, 0x82,
+	0x53, 0xfb, 0xcf, 0x62, 0x61, 0x66, 0xb8, 0x6e, 0xc1, 0x12, 0xe4, 0x08, 0x4b, 0xb6, 0x92, 0xd7,
+	0x18, 0xce, 0x11, 0x86, 0x7e, 0x00, 0xe3, 0x3e, 0xe5, 0xfe, 0xbe, 0x8e, 0x86, 0x57, 0x46, 0xf0,
+	0x7c, 0x2c, 0xe8, 0xd5, 0x54, 0xe5, 0x4f, 0xac, 0x38, 0x1a, 0x4d, 0x8a, 0xfc, 0x89, 0x35, 0x29,
+	0x2a, 0xc6, 0x19, 0x16, 0xce, 0x45, 0x54, 0x56, 0x44, 0x6d, 0x88, 0x52, 0x4b, 0x3e, 0x3a, 0xe9,
+	0xf5, 0x46, 0x31, 0x1c, 0x62, 0xd8, 0x3e, 0x44, 0xe9, 0x67, 0xf0, 0xf6, 0x14, 0x3d, 0xd6, 0x49,
+	0x92, 0x95, 0xe5, 0x2d, 0x65, 0x1f, 0x9b, 0x41, 0xc9, 0x92, 0xfd, 0xb9, 0x05, 0x67, 0x52, 0xb1,
+	0xc3, 0x28, 0x9f, 0x3b, 0xc1, 0x28, 0x6f, 0x1d, 0x77, 0x94, 0x7f, 0x68, 0xa8, 0x30, 0x98, 0xc2,
+	0x71, 0x3d, 0x18, 0xff, 0x59, 0x0e, 0xe6, 0x30, 0xed, 0x7a, 0xb1, 0x86, 0xc7, 0x56, 0xf0, 0x3a,
+	0x2d, 0x43, 0x86, 0x90, 0xe8, 0xe0, 0x57, 0x26, 0x62, 0xcf, 0xd2, 0xbe, 0x0f, 0xe3, 0xb2, 0x17,
+	0xa1, 0x73, 0x9a, 0xcb, 0x19, 0x6e, 0x5c, 0x63, 0x5c, 0xa5, 0x1f, 0xa8, 0xa6, 0x8e, 0x62, 0x28,
+	0x38, 0xcb, 0xcb, 0x52, 0xed, 0x06, 0x97, 0x33, 0x5c, 0xbb, 0xf6, 0x73, 0x96, 0xc3, 0x58, 0x31,
+	0xb4, 0x3f, 0xc9, 0x81, 0xca, 0x26, 0x9e, 0x43, 0xd5, 0xf0, 0x1b, 0xb1, 0xaa, 0x61, 0xc8, 0x74,
+	0x58, 0x4e, 0x6e, 0x60, 0xc5, 0x90, 0xcc, 0xf4, 0xce, 0x65, 0x61, 0xfa, 0xf4, 0x6a, 0xe1, 0x9f,
+	0x2d, 0x28, 0x4a, 0xbc, 0xe7, 0x50, 0x29, 0x6c, 0xc5, 0x2b, 0x85, 0xb7, 0x32, 0xac, 0x62, 0x40,
+	0x95, 0xf0, 0x71, 0x5e, 0xcf, 0x3e, 0xcc, 0x23, 0x5b, 0xc4, 0x6f, 0xe8, 0x0c, 0x29, 0xf2, 0x40,
+	0x31, 0x88, 0x15, 0x0c, 0xfd, 0x36, 0xcc, 0xf9, 0xea, 0xfe, 0x9d, 0x36, 0x6e, 0x86, 0xe9, 0x4a,
+	0x3e, 0xf3, 0x05, 0xb9, 0xbe, 0xc4, 0x8f, 0x3a, 0x65, 0x38, 0xc1, 0x15, 0xf7, 0xc9, 0x11, 0x29,
+	0x4c, 0x37, 0x19, 0xcb, 0xf4, 0xd1, 0x7e, 0x79, 0xc4, 0xc0, 0xa9, 0x52, 0x98, 0xbe, 0x61, 0xdc,
+	0x2f, 0x08, 0xb5, 0x60, 0xca, 0x7c, 0xda, 0xa3, 0x6d, 0xe9, 0x7c, 0xf6, 0x37, 0x44, 0xea, 0x12,
+	0xc6, 0x1c, 0xc1, 0x31, 0xce, 0xf6, 0x41, 0x01, 0x4a, 0x86, 0xf1, 0x25, 0xba, 0x0f, 0xd3, 0x27,
+	0xd3, 0x7d, 0x48, 0x4f, 0x96, 0x4b, 0x23, 0x25, 0xcb, 0xe7, 0xe2, 0xc9, 0xf2, 0x77, 0x93, 0xc9,
+	0x32, 0xc8, 0xd5, 0xc5, 0x12, 0x65, 0x06, 0x33, 0x3a, 0x6b, 0x0c, 0xde, 0x68, 0x65, 0x2a, 0x3f,
+	0xfa, 0x73, 0x53, 0x74, 0x78, 0xb0, 0x12, 0xbc, 0xb0, 0x08, 0xde, 0x66, 0x25, 0x44, 0xa0, 0xeb,
+	0xa1, 0xd0, 0x5a, 0xaf, 0xd3, 0x21, 0xfe, 0xfe, 0xe2, 0x94, 0x9c, 0x70, 0xd8, 0x96, 0xbc, 0x19,
+	0x83, 0xe2, 0x04, 0x36, 0xda, 0x82, 0x82, 0x4a, 0x3a, 0xf5, 0xbb, 0x9f, 0xb7, 0xb3, 0xe4, 0xb3,
+	0x2a, 0xcf, 0x50, 0xbf, 0xb1, 0xe6, 0x63, 0xd6, 0x0b, 0xc5, 0x23, 0xea, 0x85, 0x3b, 0x80, 0xbc,
+	0x6d, 0x99, 0xd1, 0x34, 0x6e, 0xa9, 0x2f, 0xab, 0x84, 0x55, 0x16, 0xe2, 0xe9, 0xe4, 0xdd, 0x3e,
+	0x0c, 0x9c, 0x42, 0x25, 0xbc, 0x5a, 0x27, 0x99, 0xa1, 0x2b, 0xe8, 0xda, 0x20, 0x6b, 0xa2, 0x16,
+	0x95, 0x21, 0x0b, 0xc2, 0xab, 0xab, 0x09, 0xae, 0xb8, 0x4f, 0x0e, 0xfa, 0x11, 0x4c, 0x0b, 0x13,
+	0x8a, 0x04, 0xc3, 0x33, 0x0a, 0x9e, 0x3f, 0x3c, 0x58, 0x99, 0xde, 0x34, 0x59, 0xe2, 0xb8, 0x04,
+	0xfb, 0xff, 0x72, 0x10, 0xf3, 0x3f, 0xf4, 0x53, 0x0b, 0xe6, 0x49, 0xe2, 0xb3, 0xa0, 0x20, 0x7d,
+	0xf9, 0xb5, 0x6c, 0xdf, 0x6a, 0xf5, 0x7d, 0x55, 0x14, 0x35, 0x1f, 0x93, 0x28, 0x0c, 0xf7, 0x0b,
+	0x45, 0x7f, 0x6c, 0xc1, 0x69, 0xd2, 0xff, 0xdd, 0x97, 0x3e, 0x0f, 0xaf, 0x8e, 0xfc, 0xe1, 0x58,
+	0xe5, 0xe5, 0xc3, 0x83, 0x95, 0xb4, 0x2f, 0xe2, 0x70, 0x9a, 0x38, 0xf4, 0x43, 0x18, 0x23, 0x7e,
+	0x33, 0x28, 0xd4, 0xb3, 0x8b, 0x0d, 0x3e, 0xe7, 0x8b, 0x0e, 0xe4, 0x35, 0xbf, 0xc9, 0xb0, 0x64,
+	0x6a, 0x7f, 0x95, 0x87, 0xb9, 0xe4, 0xab, 0x4a, 0xfd, 0x10, 0x65, 0x2c, 0xf5, 0x21, 0xca, 0xab,
+	0x30, 0x4e, 0xea, 0x3c, 0x7c, 0x15, 0x12, 0xbd, 0x24, 0x16, 0x83, 0x58, 0xc1, 0xd0, 0x03, 0x28,
+	0x32, 0x4e, 0x7c, 0x2e, 0xea, 0x37, 0x5d, 0x42, 0x67, 0x7e, 0x35, 0x5d, 0x0b, 0x18, 0xe0, 0x88,
+	0x17, 0xba, 0x12, 0x0f, 0x67, 0x76, 0x32, 0x9c, 0xcd, 0x9b, 0x6b, 0x19, 0xb5, 0xfc, 0xef, 0x40,
+	0xc9, 0xd8, 0x07, 0x7d, 0xba, 0x5c, 0xcb, 0xac, 0xf7, 0xc8, 0xec, 0x66, 0xd5, 0x37, 0x81, 0x11,
+	0xc4, 0xe4, 0x1f, 0xd5, 0xc7, 0x52, 0x5b, 0xcf, 0x54, 0x1f, 0x4b, 0x75, 0x19, 0xdc, 0xec, 0x59,
+	0x98, 0x8e, 0x3d, 0x8e, 0x92, 0x9d, 0xe7, 0x07, 0xc4, 0xa7, 0x2d, 0xaf, 0xc7, 0xe8, 0x8b, 0xda,
+	0x79, 0x0e, 0x27, 0x78, 0xdc, 0x9d, 0xe7, 0x88, 0xf1, 0xd1, 0x9d, 0xe7, 0x10, 0xf7, 0x85, 0xed,
+	0x3c, 0x87, 0x33, 0x1c, 0x90, 0x53, 0xfe, 0x6f, 0xce, 0x58, 0x45, 0x3c, 0xaf, 0xcc, 0x3d, 0x25,
+	0xaf, 0x7c, 0x04, 0x93, 0x8e, 0xcb, 0xa9, 0xbf, 0x47, 0xda, 0xba, 0x45, 0x30, 0xe4, 0x52, 0xd7,
+	0x7b, 0xbe, 0xce, 0xaa, 0x82, 0xa5, 0x6e, 0x68, 0x3e, 0x38, 0xe4, 0x88, 0xda, 0x70, 0x26, 0xe8,
+	0x49, 0xf9, 0x94, 0x44, 0x3d, 0x71, 0x7d, 0xaf, 0xfc, 0x6e, 0x70, 0xa9, 0x79, 0x33, 0x0d, 0xe9,
+	0xc9, 0x20, 0x00, 0x4e, 0x67, 0x8a, 0x18, 0x4c, 0x33, 0xa3, 0xa0, 0x0a, 0x0e, 0x92, 0x21, 0xfb,
+	0x79, 0xc9, 0x1a, 0xd4, 0xb8, 0x8c, 0x36, 0x99, 0xe2, 0xb8, 0x0c, 0xfb, 0x3f, 0xf2, 0x30, 0x9b,
+	0xb0, 0xb4, 0x44, 0xe2, 0x58, 0x7c, 0x9e, 0x89, 0x63, 0x61, 0xa4, 0xc4, 0x31, 0x3d, 0xa7, 0x19,
+	0x1b, 0x29, 0xa7, 0x79, 0x4f, 0xe5, 0x15, 0x7a, 0xe7, 0x36, 0xd6, 0xf5, 0x33, 0xc1, 0x50, 0x9b,
+	0x9b, 0x26, 0x10, 0xc7, 0x71, 0xe5, 0x29, 0xdc, 0xe8, 0xff, 0x26, 0x4e, 0x27, 0x45, 0x57, 0xb3,
+	0x3e, 0xbe, 0x08, 0x19, 0xa8, 0x53, 0x38, 0x05, 0x80, 0xd3, 0xc4, 0x55, 0xee, 0x7c, 0xf6, 0xf5,
+	0xf2, 0xa9, 0x2f, 0xbe, 0x5e, 0x3e, 0xf5, 0xe5, 0xd7, 0xcb, 0xa7, 0x7e, 0x72, 0xb8, 0x6c, 0x7d,
+	0x76, 0xb8, 0x6c, 0x7d, 0x71, 0xb8, 0x6c, 0x7d, 0x79, 0xb8, 0x6c, 0xfd, 0xf7, 0xe1, 0xb2, 0xf5,
+	0x97, 0xdf, 0x2c, 0x9f, 0x7a, 0xf8, 0xda, 0x30, 0xff, 0xbc, 0xe0, 0xff, 0x03, 0x00, 0x00, 0xff,
+	0xff, 0x90, 0x47, 0xd6, 0xce, 0xe3, 0x40, 0x00, 0x00,
 }
 
 func (m *AnalysisRunArgument) Marshal() (dAtA []byte, err error) {
@@ -3975,6 +4008,9 @@ func (m *PromotionStatus) MarshalToSizedBuffer(dAtA []byte) (int, error) {
 	_ = i
 	var l int
 	_ = l
+	i = encodeVarintGenerated(dAtA, i, uint64(m.CurrentStepAttempt))
+	i--
+	dAtA[i] = 0x58
 	if m.State != nil {
 		{
 			size, err := m.State.MarshalToSizedBuffer(dAtA[:i])
@@ -4078,6 +4114,18 @@ func (m *PromotionStep) MarshalToSizedBuffer(dAtA []byte) (int, error) {
 	_ = i
 	var l int
 	_ = l
+	if m.Retry != nil {
+		{
+			size, err := m.Retry.MarshalToSizedBuffer(dAtA[:i])
+			if err != nil {
+				return 0, err
+			}
+			i -= size
+			i = encodeVarintGenerated(dAtA, i, uint64(size))
+		}
+		i--
+		dAtA[i] = 0x22
+	}
 	if m.Config != nil {
 		{
 			size, err := m.Config.MarshalToSizedBuffer(dAtA[:i])
@@ -4103,6 +4151,32 @@ func (m *PromotionStep) MarshalToSizedBuffer(dAtA []byte) (int, error) {
 	return len(dAtA) - i, nil
 }
 
+func (m *PromotionStepRetry) Marshal() (dAtA []byte, err error) {
+	size := m.Size()
+	dAtA = make([]byte, size)
+	n, err := m.MarshalToSizedBuffer(dAtA[:size])
+	if err != nil {
+		return nil, err
+	}
+	return dAtA[:n], nil
+}
+
+func (m *PromotionStepRetry) MarshalTo(dAtA []byte) (int, error) {
+	size := m.Size()
+	return m.MarshalToSizedBuffer(dAtA[:size])
+}
+
+func (m *PromotionStepRetry) MarshalToSizedBuffer(dAtA []byte) (int, error) {
+	i := len(dAtA)
+	_ = i
+	var l int
+	_ = l
+	i = encodeVarintGenerated(dAtA, i, uint64(m.Attempts))
+	i--
+	dAtA[i] = 0x8
+	return len(dAtA) - i, nil
+}
+
 func (m *PromotionTemplate) Marshal() (dAtA []byte, err error) {
 	size := m.Size()
 	dAtA = make([]byte, size)
@@ -5762,6 +5836,7 @@ func (m *PromotionStatus) Size() (n int) {
 		l = m.State.Size()
 		n += 1 + l + sovGenerated(uint64(l))
 	}
+	n += 1 + sovGenerated(uint64(m.CurrentStepAttempt))
 	return n
 }
 
@@ -5779,6 +5854,20 @@ func (m *PromotionStep) Size() (n int) {
 		l = m.Config.Size()
 		n += 1 + l + sovGenerated(uint64(l))
 	}
+	if m.Retry != nil {
+		l = m.Retry.Size()
+		n += 1 + l + sovGenerated(uint64(l))
+	}
+	return n
+}
+
+func (m *PromotionStepRetry) Size() (n int) {
+	if m == nil {
+		return 0
+	}
+	var l int
+	_ = l
+	n += 1 + sovGenerated(uint64(m.Attempts))
 	return n
 }
 
@@ -6738,6 +6827,7 @@ func (this *PromotionStatus) String() string {
 		`HealthChecks:` + repeatedStringForHealthChecks + `,`,
 		`CurrentStep:` + fmt.Sprintf("%v", this.CurrentStep) + `,`,
 		`State:` + strings.Replace(fmt.Sprintf("%v", this.State), "JSON", "v11.JSON", 1) + `,`,
+		`CurrentStepAttempt:` + fmt.Sprintf("%v", this.CurrentStepAttempt) + `,`,
 		`}`,
 	}, "")
 	return s
@@ -6750,6 +6840,17 @@ func (this *PromotionStep) String() string {
 		`Uses:` + fmt.Sprintf("%v", this.Uses) + `,`,
 		`As:` + fmt.Sprintf("%v", this.As) + `,`,
 		`Config:` + strings.Replace(fmt.Sprintf("%v", this.Config), "JSON", "v11.JSON", 1) + `,`,
+		`Retry:` + strings.Replace(this.Retry.String(), "PromotionStepRetry", "PromotionStepRetry", 1) + `,`,
+		`}`,
+	}, "")
+	return s
+}
+func (this *PromotionStepRetry) String() string {
+	if this == nil {
+		return "nil"
+	}
+	s := strings.Join([]string{`&PromotionStepRetry{`,
+		`Attempts:` + fmt.Sprintf("%v", this.Attempts) + `,`,
 		`}`,
 	}, "")
 	return s
@@ -14090,6 +14191,25 @@ func (m *PromotionStatus) Unmarshal(dAtA []byte) error {
 				return err
 			}
 			iNdEx = postIndex
+		case 11:
+			if wireType != 0 {
+				return fmt.Errorf("proto: wrong wireType = %d for field CurrentStepAttempt", wireType)
+			}
+			m.CurrentStepAttempt = 0
+			for shift := uint(0); ; shift += 7 {
+				if shift >= 64 {
+					return ErrIntOverflowGenerated
+				}
+				if iNdEx >= l {
+					return io.ErrUnexpectedEOF
+				}
+				b := dAtA[iNdEx]
+				iNdEx++
+				m.CurrentStepAttempt |= int64(b&0x7F) << shift
+				if b < 0x80 {
+					break
+				}
+			}
 		default:
 			iNdEx = preIndex
 			skippy, err := skipGenerated(dAtA[iNdEx:])
@@ -14240,6 +14360,111 @@ func (m *PromotionStep) Unmarshal(dAtA []byte) error {
 				return err
 			}
 			iNdEx = postIndex
+		case 4:
+			if wireType != 2 {
+				return fmt.Errorf("proto: wrong wireType = %d for field Retry", wireType)
+			}
+			var msglen int
+			for shift := uint(0); ; shift += 7 {
+				if shift >= 64 {
+					return ErrIntOverflowGenerated
+				}
+				if iNdEx >= l {
+					return io.ErrUnexpectedEOF
+				}
+				b := dAtA[iNdEx]
+				iNdEx++
+				msglen |= int(b&0x7F) << shift
+				if b < 0x80 {
+					break
+				}
+			}
+			if msglen < 0 {
+				return ErrInvalidLengthGenerated
+			}
+			postIndex := iNdEx + msglen
+			if postIndex < 0 {
+				return ErrInvalidLengthGenerated
+			}
+			if postIndex > l {
+				return io.ErrUnexpectedEOF
+			}
+			if m.Retry == nil {
+				m.Retry = &PromotionStepRetry{}
+			}
+			if err := m.Retry.Unmarshal(dAtA[iNdEx:postIndex]); err != nil {
+				return err
+			}
+			iNdEx = postIndex
+		default:
+			iNdEx = preIndex
+			skippy, err := skipGenerated(dAtA[iNdEx:])
+			if err != nil {
+				return err
+			}
+			if (skippy < 0) || (iNdEx+skippy) < 0 {
+				return ErrInvalidLengthGenerated
+			}
+			if (iNdEx + skippy) > l {
+				return io.ErrUnexpectedEOF
+			}
+			iNdEx += skippy
+		}
+	}
+
+	if iNdEx > l {
+		return io.ErrUnexpectedEOF
+	}
+	return nil
+}
+func (m *PromotionStepRetry) Unmarshal(dAtA []byte) error {
+	l := len(dAtA)
+	iNdEx := 0
+	for iNdEx < l {
+		preIndex := iNdEx
+		var wire uint64
+		for shift := uint(0); ; shift += 7 {
+			if shift >= 64 {
+				return ErrIntOverflowGenerated
+			}
+			if iNdEx >= l {
+				return io.ErrUnexpectedEOF
+			}
+			b := dAtA[iNdEx]
+			iNdEx++
+			wire |= uint64(b&0x7F) << shift
+			if b < 0x80 {
+				break
+			}
+		}
+		fieldNum := int32(wire >> 3)
+		wireType := int(wire & 0x7)
+		if wireType == 4 {
+			return fmt.Errorf("proto: PromotionStepRetry: wiretype end group for non-group")
+		}
+		if fieldNum <= 0 {
+			return fmt.Errorf("proto: PromotionStepRetry: illegal tag %d (wire type %d)", fieldNum, wire)
+		}
+		switch fieldNum {
+		case 1:
+			if wireType != 0 {
+				return fmt.Errorf("proto: wrong wireType = %d for field Attempts", wireType)
+			}
+			m.Attempts = 0
+			for shift := uint(0); ; shift += 7 {
+				if shift >= 64 {
+					return ErrIntOverflowGenerated
+				}
+				if iNdEx >= l {
+					return io.ErrUnexpectedEOF
+				}
+				b := dAtA[iNdEx]
+				iNdEx++
+				m.Attempts |= int64(b&0x7F) << shift
+				if b < 0x80 {
+					break
+				}
+			}
 		default:
 			iNdEx = preIndex
 			skippy, err := skipGenerated(dAtA[iNdEx:])
diff --git a/api/v1alpha1/generated.proto b/api/v1alpha1/generated.proto
index 02eaa310..08f411a1 100644
--- a/api/v1alpha1/generated.proto
+++ b/api/v1alpha1/generated.proto
@@ -900,6 +900,10 @@ message PromotionStatus {
   // subsequent reconciliations attempts.
   optional int64 currentStep = 9;
 
+  // CurrentStepAttempt is the number of times the current step has been
+  // attempted.
+  optional int64 currentStepAttempt = 11;
+
   // State stores the state of the promotion process between reconciliation
   // attempts.
   optional .k8s.io.apiextensions_apiserver.pkg.apis.apiextensions.v1.JSON state = 10;
@@ -915,6 +919,9 @@ message PromotionStep {
   // As is the alias this step can be referred to as.
   optional string as = 2;
 
+  // Retry is the retry policy for this step.
+  optional PromotionStepRetry retry = 4;
+
   // Config is opaque configuration for the PromotionStep that is understood
   // only by each PromotionStep's implementation. It is legal to utilize
   // expressions in defining values at any level of this block.
@@ -922,6 +929,19 @@ message PromotionStep {
   optional .k8s.io.apiextensions_apiserver.pkg.apis.apiextensions.v1.JSON config = 3;
 }
 
+// PromotionStepRetry describes the retry policy for a PromotionStep.
+message PromotionStepRetry {
+  // Attempts is the number of times the step can be attempted before the
+  // PromotionStep is marked as failed.
+  //
+  // If this field is set to 1, the step will not be retried. If this
+  // field is set to -1, the step will be retried indefinitely.
+  //
+  // The default of this field depends on the step being executed. Refer to
+  // the documentation for the specific step for more information.
+  optional int64 attempts = 1;
+}
+
 // PromotionTemplate defines a template for a Promotion that can be used to
 // incorporate Freight into a Stage.
 message PromotionTemplate {
diff --git a/api/v1alpha1/promotion_types.go b/api/v1alpha1/promotion_types.go
index a5b35d48..7ae3a365 100644
--- a/api/v1alpha1/promotion_types.go
+++ b/api/v1alpha1/promotion_types.go
@@ -109,6 +109,27 @@ type PromotionVariable struct {
 	Value string `json:"value" protobuf:"bytes,2,opt,name=value"`
 }
 
+// PromotionStepRetry describes the retry policy for a PromotionStep.
+type PromotionStepRetry struct {
+	// Attempts is the number of times the step can be attempted before the
+	// PromotionStep is marked as failed.
+	//
+	// If this field is set to 1, the step will not be retried. If this
+	// field is set to -1, the step will be retried indefinitely.
+	//
+	// The default of this field depends on the step being executed. Refer to
+	// the documentation for the specific step for more information.
+	Attempts int64 `json:"attempts,omitempty" protobuf:"varint,1,opt,name=attempts"`
+}
+
+// GetAttempts returns the Attempts field with the given fallback value.
+func (r *PromotionStepRetry) GetAttempts(fallback int64) int64 {
+	if r == nil || r.Attempts == 0 {
+		return fallback
+	}
+	return r.Attempts
+}
+
 // PromotionStep describes a directive to be executed as part of a Promotion.
 type PromotionStep struct {
 	// Uses identifies a runner that can execute this step.
@@ -117,6 +138,8 @@ type PromotionStep struct {
 	Uses string `json:"uses" protobuf:"bytes,1,opt,name=uses"`
 	// As is the alias this step can be referred to as.
 	As string `json:"as,omitempty" protobuf:"bytes,2,opt,name=as"`
+	// Retry is the retry policy for this step.
+	Retry *PromotionStepRetry `json:"retry,omitempty" protobuf:"bytes,4,opt,name=retry"`
 	// Config is opaque configuration for the PromotionStep that is understood
 	// only by each PromotionStep's implementation. It is legal to utilize
 	// expressions in defining values at any level of this block.
@@ -154,6 +177,9 @@ type PromotionStatus struct {
 	// permits steps that have already run successfully to be skipped on
 	// subsequent reconciliations attempts.
 	CurrentStep int64 `json:"currentStep,omitempty" protobuf:"varint,9,opt,name=currentStep"`
+	// CurrentStepAttempt is the number of times the current step has been
+	// attempted.
+	CurrentStepAttempt int64 `json:"currentStepAttempt,omitempty" protobuf:"varint,11,opt,name=currentStepAttempt"`
 	// State stores the state of the promotion process between reconciliation
 	// attempts.
 	State *apiextensionsv1.JSON `json:"state,omitempty" protobuf:"bytes,10,opt,name=state"`
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index fb6807be..efe94eef 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -976,6 +976,11 @@ func (in *PromotionStatus) DeepCopy() *PromotionStatus {
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *PromotionStep) DeepCopyInto(out *PromotionStep) {
 	*out = *in
+	if in.Retry != nil {
+		in, out := &in.Retry, &out.Retry
+		*out = new(PromotionStepRetry)
+		**out = **in
+	}
 	if in.Config != nil {
 		in, out := &in.Config, &out.Config
 		*out = new(apiextensionsv1.JSON)
@@ -993,6 +998,21 @@ func (in *PromotionStep) DeepCopy() *PromotionStep {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PromotionStepRetry) DeepCopyInto(out *PromotionStepRetry) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PromotionStepRetry.
+func (in *PromotionStepRetry) DeepCopy() *PromotionStepRetry {
+	if in == nil {
+		return nil
+	}
+	out := new(PromotionStepRetry)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *PromotionTemplate) DeepCopyInto(out *PromotionTemplate) {
 	*out = *in
diff --git a/charts/kargo/resources/crds/kargo.akuity.io_promotions.yaml b/charts/kargo/resources/crds/kargo.akuity.io_promotions.yaml
index eb19c781..124e0067 100644
--- a/charts/kargo/resources/crds/kargo.akuity.io_promotions.yaml
+++ b/charts/kargo/resources/crds/kargo.akuity.io_promotions.yaml
@@ -95,6 +95,22 @@ spec:
                         expressions in defining values at any level of this block.
                         See https://docs.kargo.io/references/expression-language for details.
                       x-kubernetes-preserve-unknown-fields: true
+                    retry:
+                      description: Retry is the retry policy for this step.
+                      properties:
+                        attempts:
+                          description: |-
+                            Attempts is the number of times the step can be attempted before the
+                            PromotionStep is marked as failed.
+
+                            If this field is set to 1, the step will not be retried. If this
+                            field is set to -1, the step will be retried indefinitely.
+
+                            The default of this field depends on the step being executed. Refer to
+                            the documentation for the specific step for more information.
+                          format: int64
+                          type: integer
+                      type: object
                     uses:
                       description: Uses identifies a runner that can execute this
                         step.
@@ -145,6 +161,12 @@ spec:
                   subsequent reconciliations attempts.
                 format: int64
                 type: integer
+              currentStepAttempt:
+                description: |-
+                  CurrentStepAttempt is the number of times the current step has been
+                  attempted.
+                format: int64
+                type: integer
               finishedAt:
                 description: FinishedAt is the time when the promotion was completed.
                 format: date-time
diff --git a/charts/kargo/resources/crds/kargo.akuity.io_stages.yaml b/charts/kargo/resources/crds/kargo.akuity.io_stages.yaml
index 890cfbc9..c2d17a87 100644
--- a/charts/kargo/resources/crds/kargo.akuity.io_stages.yaml
+++ b/charts/kargo/resources/crds/kargo.akuity.io_stages.yaml
@@ -88,6 +88,22 @@ spec:
                                 expressions in defining values at any level of this block.
                                 See https://docs.kargo.io/references/expression-language for details.
                               x-kubernetes-preserve-unknown-fields: true
+                            retry:
+                              description: Retry is the retry policy for this step.
+                              properties:
+                                attempts:
+                                  description: |-
+                                    Attempts is the number of times the step can be attempted before the
+                                    PromotionStep is marked as failed.
+
+                                    If this field is set to 1, the step will not be retried. If this
+                                    field is set to -1, the step will be retried indefinitely.
+
+                                    The default of this field depends on the step being executed. Refer to
+                                    the documentation for the specific step for more information.
+                                  format: int64
+                                  type: integer
+                              type: object
                             uses:
                               description: Uses identifies a runner that can execute
                                 this step.
@@ -469,6 +485,12 @@ spec:
                           subsequent reconciliations attempts.
                         format: int64
                         type: integer
+                      currentStepAttempt:
+                        description: |-
+                          CurrentStepAttempt is the number of times the current step has been
+                          attempted.
+                        format: int64
+                        type: integer
                       finishedAt:
                         description: FinishedAt is the time when the promotion was
                           completed.
@@ -1290,6 +1312,12 @@ spec:
                           subsequent reconciliations attempts.
                         format: int64
                         type: integer
+                      currentStepAttempt:
+                        description: |-
+                          CurrentStepAttempt is the number of times the current step has been
+                          attempted.
+                        format: int64
+                        type: integer
                       finishedAt:
                         description: FinishedAt is the time when the promotion was
                           completed.
diff --git a/internal/controller/promotions/promotions.go b/internal/controller/promotions/promotions.go
index 35c2cc14..4b89815c 100644
--- a/internal/controller/promotions/promotions.go
+++ b/internal/controller/promotions/promotions.go
@@ -468,6 +468,7 @@ func (r *reconciler) promote(
 		steps[i] = directives.PromotionStep{
 			Kind:   step.Uses,
 			Alias:  step.As,
+			Retry:  step.Retry,
 			Config: step.Config.Raw,
 		}
 	}
@@ -481,6 +482,7 @@ func (r *reconciler) promote(
 		FreightRequests: stage.Spec.RequestedFreight,
 		Freight:         *workingPromo.Status.FreightCollection.DeepCopy(),
 		StartFromStep:   promo.Status.CurrentStep,
+		Attempts:        promo.Status.CurrentStepAttempt,
 		State:           directives.State(workingPromo.Status.GetState()),
 		Vars:            workingPromo.Spec.Vars,
 	}
@@ -490,6 +492,7 @@ func (r *reconciler) promote(
 		// allows individual steps to self-discover that they've run before and
 		// examine the results of their own previous execution.
 		promoCtx.StartFromStep = 0
+		promoCtx.Attempts = 0
 	} else if !os.IsExist(err) {
 		return nil, fmt.Errorf("error creating working directory: %w", err)
 	}
@@ -505,6 +508,7 @@ func (r *reconciler) promote(
 	workingPromo.Status.Phase = res.Status
 	workingPromo.Status.Message = res.Message
 	workingPromo.Status.CurrentStep = res.CurrentStep
+	workingPromo.Status.CurrentStepAttempt = res.Attempt
 	workingPromo.Status.State = &apiextensionsv1.JSON{Raw: res.State.ToJSON()}
 	if res.Status == kargoapi.PromotionPhaseSucceeded {
 		var healthChecks []kargoapi.HealthCheckStep
diff --git a/internal/directives/argocd_revisions.go b/internal/directives/argocd_revisions.go
index acaf966c..13c2b26a 100644
--- a/internal/directives/argocd_revisions.go
+++ b/internal/directives/argocd_revisions.go
@@ -177,7 +177,7 @@ func getCommitFromStep(sharedState State, stepAlias string) (string, error) {
 		return "",
 			fmt.Errorf("output from step with alias %q is not a map[string]any", stepAlias)
 	}
-	commitAny, exists := stepOutputMap[commitKey]
+	commitAny, exists := stepOutputMap[stateKeyCommit]
 	if !exists {
 		return "",
 			fmt.Errorf("no commit found in output from step with alias %q", stepAlias)
diff --git a/internal/directives/argocd_updater.go b/internal/directives/argocd_updater.go
index 148fc2a3..e8c72184 100644
--- a/internal/directives/argocd_updater.go
+++ b/internal/directives/argocd_updater.go
@@ -127,6 +127,11 @@ func (a *argocdUpdater) Name() string {
 	return "argocd-update"
 }
 
+// DefaultAttempts implements the RetryableStepRunner interface.
+func (a *argocdUpdater) DefaultAttempts() int64 {
+	return -1
+}
+
 // RunPromotionStep implements the PromotionStepRunner interface.
 func (a *argocdUpdater) RunPromotionStep(
 	ctx context.Context,
diff --git a/internal/directives/git_commiter.go b/internal/directives/git_commiter.go
index 9825dfc2..9dccb067 100644
--- a/internal/directives/git_commiter.go
+++ b/internal/directives/git_commiter.go
@@ -11,7 +11,8 @@ import (
 	"github.com/akuity/kargo/internal/controller/git"
 )
 
-const commitKey = "commit"
+// stateKeyCommit is the key used to store the commit ID in the shared State.
+const stateKeyCommit = "commit"
 
 func init() {
 	builtins.RegisterPromotionStepRunner(newGitCommitter(), nil)
@@ -111,7 +112,7 @@ func (g *gitCommitter) runPromotionStep(
 	}
 	return PromotionStepResult{
 		Status: kargoapi.PromotionPhaseSucceeded,
-		Output: map[string]any{commitKey: commitID},
+		Output: map[string]any{stateKeyCommit: commitID},
 	}, nil
 }
 
diff --git a/internal/directives/git_pr_opener.go b/internal/directives/git_pr_opener.go
index 0b979c43..5810ac16 100644
--- a/internal/directives/git_pr_opener.go
+++ b/internal/directives/git_pr_opener.go
@@ -18,7 +18,8 @@ import (
 	_ "github.com/akuity/kargo/internal/gitprovider/gitlab" // GitLab provider registration
 )
 
-const prNumberKey = "prNumber"
+// stateKeyPRNumber is the key used to store the PR number in the shared State.
+const stateKeyPRNumber = "prNumber"
 
 func init() {
 	builtins.RegisterPromotionStepRunner(
@@ -83,7 +84,7 @@ func (g *gitPROpener) runPromotionStep(
 		return PromotionStepResult{
 			Status: kargoapi.PromotionPhaseSucceeded,
 			Output: map[string]any{
-				prNumberKey: prNumber,
+				stateKeyPRNumber: prNumber,
 			},
 		}, nil
 	}
@@ -161,7 +162,7 @@ func (g *gitPROpener) runPromotionStep(
 		return PromotionStepResult{
 			Status: kargoapi.PromotionPhaseSucceeded,
 			Output: map[string]any{
-				prNumberKey: pr.Number,
+				stateKeyPRNumber: pr.Number,
 			},
 		}, nil
 	}
@@ -219,7 +220,7 @@ func (g *gitPROpener) runPromotionStep(
 	return PromotionStepResult{
 		Status: kargoapi.PromotionPhaseSucceeded,
 		Output: map[string]any{
-			prNumberKey: pr.Number,
+			stateKeyPRNumber: pr.Number,
 		},
 	}, nil
 }
@@ -243,7 +244,7 @@ func (g *gitPROpener) getPRNumber(
 			stepCtx.Alias,
 		)
 	}
-	prNumberAny, exists := stepOutputMap[prNumberKey]
+	prNumberAny, exists := stepOutputMap[stateKeyPRNumber]
 	if !exists {
 		return -1, nil
 	}
@@ -283,7 +284,7 @@ func (g *gitPROpener) getSourceBranch(
 				cfg.SourceBranchFromStep,
 			)
 		}
-		sourceBranchAny, exists := stepOutputMap[branchKey]
+		sourceBranchAny, exists := stepOutputMap[stateKeyBranch]
 		if !exists {
 			return "", fmt.Errorf(
 				"no branch found in output from step with alias %q",
diff --git a/internal/directives/git_pr_waiter.go b/internal/directives/git_pr_waiter.go
index 2d29b4ff..531a13b4 100644
--- a/internal/directives/git_pr_waiter.go
+++ b/internal/directives/git_pr_waiter.go
@@ -38,6 +38,11 @@ func (g *gitPRWaiter) Name() string {
 	return "git-wait-for-pr"
 }
 
+// DefaultAttempts implements the RetryableStepRunner interface.
+func (g *gitPRWaiter) DefaultAttempts() int64 {
+	return -1
+}
+
 // RunPromotionStep implements the PromotionStepRunner interface.
 func (g *gitPRWaiter) RunPromotionStep(
 	ctx context.Context,
@@ -121,7 +126,7 @@ func (g *gitPRWaiter) runPromotionStep(
 	}
 	return PromotionStepResult{
 		Status: kargoapi.PromotionPhaseSucceeded,
-		Output: map[string]any{commitKey: pr.MergeCommitSHA},
+		Output: map[string]any{stateKeyCommit: pr.MergeCommitSHA},
 	}, nil
 }
 
@@ -150,7 +155,7 @@ func (g *gitPRWaiter) getPRNumber(
 			cfg.PRNumberFromStep,
 		)
 	}
-	prNumberAny, exists := stepOutputMap[prNumberKey]
+	prNumberAny, exists := stepOutputMap[stateKeyPRNumber]
 	if !exists {
 		return 0, fmt.Errorf(
 			"no PR number found in output from step with alias %q",
diff --git a/internal/directives/git_pusher.go b/internal/directives/git_pusher.go
index 8d37f78b..9f835e72 100644
--- a/internal/directives/git_pusher.go
+++ b/internal/directives/git_pusher.go
@@ -12,7 +12,9 @@ import (
 	"github.com/akuity/kargo/internal/credentials"
 )
 
-const branchKey = "branch"
+// stateKeyBranch is the key used to store the branch that was pushed to in the
+// shared State.
+const stateKeyBranch = "branch"
 
 func init() {
 	builtins.RegisterPromotionStepRunner(
@@ -134,8 +136,8 @@ func (g *gitPushPusher) runPromotionStep(
 	return PromotionStepResult{
 		Status: kargoapi.PromotionPhaseSucceeded,
 		Output: map[string]any{
-			branchKey: targetBranch,
-			commitKey: commitID,
+			stateKeyBranch: targetBranch,
+			stateKeyCommit: commitID,
 		},
 	}, nil
 }
diff --git a/internal/directives/promotions.go b/internal/directives/promotions.go
index 789d56b6..7c965d79 100644
--- a/internal/directives/promotions.go
+++ b/internal/directives/promotions.go
@@ -27,6 +27,14 @@ type PromotionStepRunner interface {
 	RunPromotionStep(context.Context, *PromotionStepContext) (PromotionStepResult, error)
 }
 
+// RetryableStepRunner is an interface for PromotionStepRunners that can be
+// retried in the event of a failure.
+type RetryableStepRunner interface {
+	// DefaultAttempts returns the default number of attempts the step is
+	// allowed to make before failing.
+	DefaultAttempts() int64
+}
+
 // PromotionContext is the context of a user-defined promotion process that is
 // executed by the Engine.
 type PromotionContext struct {
@@ -50,13 +58,16 @@ type PromotionContext struct {
 	// resolve.
 	FreightRequests []kargoapi.FreightRequest
 	// Freight is the collection of all Freight referenced by the Promotion. This
-	// collection contains both the Freight that is actively being promoted as
-	// well as any Freight that has been inherited from the target Stage's current
+	// collection contains both the Freight that is actively being promoted and
+	// any Freight that has been inherited from the target Stage's current
 	// state.
 	Freight kargoapi.FreightCollection
-	// SharedState is the index of the step from which the promotion should begin
-	// execution.
+	// StartFromStep is the index of the step from which the promotion should
+	// begin execution.
 	StartFromStep int64
+	// Attempts is the number of attempts that have been made to execute
+	// the current step.
+	Attempts int64
 	// State is the current state of the promotion process.
 	State State
 	// Vars is a list of variables definitions that can be used by the
@@ -78,11 +89,25 @@ type PromotionStep struct {
 	// step will be keyed to this alias by the Engine and made accessible to
 	// subsequent steps.
 	Alias string
+	// Retry is the retry configuration for the PromotionStep.
+	Retry *kargoapi.PromotionStepRetry
 	// Config is an opaque JSON to be passed to the PromotionStepRunner executing
 	// this step.
 	Config []byte
 }
 
+// GetMaxAttempts returns the maximum number of attempts that can be made to
+// execute the step using the provided runner. If the runner is a
+// RetryableStepRunner, the value of its retry configuration is used as the
+// maximum default. Otherwise, the default is 1.
+func (s *PromotionStep) GetMaxAttempts(runner any) int64 {
+	fallback := int64(1)
+	if retryCfg, isRetryable := runner.(RetryableStepRunner); isRetryable {
+		fallback = retryCfg.DefaultAttempts()
+	}
+	return s.Retry.GetAttempts(fallback)
+}
+
 // GetConfig returns the Config unmarshalled into a map. Any expr-lang
 // expressions are evaluated in the context of the provided arguments
 // prior to unmarshaling.
@@ -145,6 +170,8 @@ func (s *PromotionStep) GetConfig(
 	return config, nil
 }
 
+// GetVars returns the variables defined in the PromotionStep. The variables are
+// evaluated in the context of the provided PromotionContext.
 func (s *PromotionStep) GetVars(promoCtx PromotionContext) (map[string]any, error) {
 	vars := make(map[string]any, len(promoCtx.Vars))
 	for _, v := range promoCtx.Vars {
@@ -184,9 +211,11 @@ type PromotionResult struct {
 	// health check processes.
 	HealthCheckSteps []HealthCheckStep
 	// If the promotion process remains in-progress, perhaps waiting for a change
-	// in some external state, the value of this field will indicated where to
+	// in some external state, the value of this field will indicate where to
 	// resume the process in the next reconciliation.
 	CurrentStep int64
+	// Attempt tracks the current execution attempt of the current step.
+	Attempt int64
 	// State is the current state of the promotion process.
 	State State
 }
diff --git a/internal/directives/simple_engine.go b/internal/directives/simple_engine.go
index d504c91e..b56a0871 100644
--- a/internal/directives/simple_engine.go
+++ b/internal/directives/simple_engine.go
@@ -1,18 +1,10 @@
 package directives
 
 import (
-	"context"
-	"encoding/json"
-	"fmt"
-	"os"
 	"regexp"
-	"strings"
 
-	corev1 "k8s.io/api/core/v1"
-	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
-	kargoapi "github.com/akuity/kargo/api/v1alpha1"
 	"github.com/akuity/kargo/internal/credentials"
 )
 
@@ -43,232 +35,3 @@ func NewSimpleEngine(
 		argoCDClient:  argoCDClient,
 	}
 }
-
-// Promote implements the Engine interface.
-func (e *SimpleEngine) Promote(
-	ctx context.Context,
-	promoCtx PromotionContext,
-	steps []PromotionStep,
-) (PromotionResult, error) {
-	workDir := promoCtx.WorkDir
-	if workDir == "" {
-		var err error
-		workDir, err = os.MkdirTemp("", "run-")
-		if err != nil {
-			return PromotionResult{
-					Status:      kargoapi.PromotionPhaseErrored,
-					CurrentStep: 0,
-				},
-				fmt.Errorf("temporary working directory creation failed: %w", err)
-		}
-		defer os.RemoveAll(workDir)
-	}
-
-	var err error
-	promoCtx.Secrets, err = e.getSecretsMap(ctx, promoCtx.Project)
-	if err != nil {
-		return PromotionResult{
-				Status:      kargoapi.PromotionPhaseErrored,
-				CurrentStep: 0,
-			},
-			err
-	}
-
-	// Initialize the shared state that will be passed to each step.
-	state := promoCtx.State.DeepCopy()
-	if state == nil {
-		state = make(State)
-	}
-	var healthCheckSteps []HealthCheckStep
-
-	for i := promoCtx.StartFromStep; i < int64(len(steps)); i++ {
-		step := steps[i]
-		select {
-		case <-ctx.Done():
-			return PromotionResult{
-				Status:      kargoapi.PromotionPhaseErrored,
-				CurrentStep: i,
-				State:       state,
-			}, ctx.Err()
-		default:
-		}
-		reg, err := e.registry.GetPromotionStepRunnerRegistration(step.Kind)
-		if err != nil {
-			return PromotionResult{
-					Status:      kargoapi.PromotionPhaseErrored,
-					CurrentStep: i,
-					State:       state,
-				},
-				fmt.Errorf("no runner registered for step kind %q: %w", step.Kind, err)
-		}
-
-		stateCopy := state.DeepCopy()
-
-		step.Alias = strings.TrimSpace(step.Alias)
-		if step.Alias == "" {
-			step.Alias = fmt.Sprintf("step-%d", i)
-		} else if ReservedStepAliasRegex.MatchString(step.Alias) {
-			// A webhook enforces this regex as well, but we're checking here to
-			// account for the possibility of EXISTING Stages with a promotionTemplate
-			// containing a step with a now-reserved alias.
-			return PromotionResult{
-				Status:      kargoapi.PromotionPhaseErrored,
-				CurrentStep: i,
-				State:       state,
-			}, fmt.Errorf("step alias %q is forbidden", step.Alias)
-		}
-
-		stepCfg, err := step.GetConfig(ctx, e.kargoClient, promoCtx, stateCopy)
-		if err != nil {
-			return PromotionResult{
-					Status:      kargoapi.PromotionPhaseErrored,
-					CurrentStep: i,
-					State:       state,
-				},
-				fmt.Errorf("failed to get step config: %w", err)
-		}
-		stepCtx := &PromotionStepContext{
-			UIBaseURL:       promoCtx.UIBaseURL,
-			WorkDir:         workDir,
-			SharedState:     stateCopy,
-			Alias:           step.Alias,
-			Config:          stepCfg,
-			Project:         promoCtx.Project,
-			Stage:           promoCtx.Stage,
-			Promotion:       promoCtx.Promotion,
-			FreightRequests: promoCtx.FreightRequests,
-			Freight:         promoCtx.Freight,
-		}
-		// Selectively provide these capabilities via the PromotionStepContext.
-		if reg.Permissions.AllowCredentialsDB {
-			stepCtx.CredentialsDB = e.credentialsDB
-		}
-		if reg.Permissions.AllowKargoClient {
-			stepCtx.KargoClient = e.kargoClient
-		}
-		if reg.Permissions.AllowArgoCDClient {
-			stepCtx.ArgoCDClient = e.argoCDClient
-		}
-
-		result, err := reg.Runner.RunPromotionStep(ctx, stepCtx)
-		state[step.Alias] = result.Output
-		if err != nil {
-			return PromotionResult{
-					Status:      kargoapi.PromotionPhaseErrored,
-					CurrentStep: i,
-					State:       state,
-				},
-				fmt.Errorf("failed to run step %q: %w", step.Kind, err)
-		}
-
-		if result.Status != kargoapi.PromotionPhaseSucceeded {
-			return PromotionResult{
-				Status:      result.Status,
-				Message:     result.Message,
-				CurrentStep: i,
-				State:       state,
-			}, nil
-		}
-
-		if result.HealthCheckStep != nil {
-			healthCheckSteps = append(healthCheckSteps, *result.HealthCheckStep)
-		}
-	}
-	return PromotionResult{
-		Status:           kargoapi.PromotionPhaseSucceeded,
-		HealthCheckSteps: healthCheckSteps,
-		CurrentStep:      int64(len(steps)) - 1,
-		State:            state,
-	}, nil
-}
-
-// CheckHealth implements the Engine interface.
-func (e *SimpleEngine) CheckHealth(
-	ctx context.Context,
-	healthCtx HealthCheckContext,
-	steps []HealthCheckStep,
-) kargoapi.Health {
-	healthStatus := kargoapi.HealthStateHealthy
-	healthIssues := []string{}
-	healthOutput := make([]State, 0, len(steps))
-stepLoop:
-	for _, step := range steps {
-		select {
-		case <-ctx.Done():
-			healthStatus = healthStatus.Merge(kargoapi.HealthStateUnknown)
-			healthIssues = append(healthIssues, ctx.Err().Error())
-			break stepLoop
-		default:
-		}
-		reg, err := e.registry.GetHealthCheckStepRunnerRegistration(step.Kind)
-		if err != nil {
-			healthStatus = healthStatus.Merge(kargoapi.HealthStateUnknown)
-			healthIssues = append(
-				healthIssues,
-				fmt.Sprintf("no runner registered for step kind %q: %s", step.Kind, err.Error()),
-			)
-			continue
-		}
-		stepCtx := &HealthCheckStepContext{
-			Config:  step.Config.DeepCopy(),
-			Project: healthCtx.Project,
-			Stage:   healthCtx.Stage,
-		}
-		// Selectively provide these capabilities via the PromotionStepContext.
-		if reg.Permissions.AllowCredentialsDB {
-			stepCtx.CredentialsDB = e.credentialsDB
-		}
-		if reg.Permissions.AllowKargoClient {
-			stepCtx.KargoClient = e.kargoClient
-		}
-		if reg.Permissions.AllowArgoCDClient {
-			stepCtx.ArgoCDClient = e.argoCDClient
-		}
-		result := reg.Runner.RunHealthCheckStep(ctx, stepCtx)
-		healthStatus = healthStatus.Merge(result.Status)
-		healthIssues = append(healthIssues, result.Issues...)
-		healthOutput = append(healthOutput, result.Output)
-	}
-	if len(healthOutput) == 0 {
-		return kargoapi.Health{
-			Status: healthStatus,
-			Issues: healthIssues,
-		}
-	}
-	bytes, err := json.Marshal(healthOutput)
-	if err != nil {
-		// Leave the status alone. Whatever it was determined to be was correct.
-		healthIssues = append(
-			healthIssues,
-			fmt.Sprintf("failed to marshal health output: %s", err.Error()),
-		)
-	}
-	return kargoapi.Health{
-		Status: healthStatus,
-		Issues: healthIssues,
-		Output: &apiextensionsv1.JSON{Raw: bytes},
-	}
-}
-
-func (e *SimpleEngine) getSecretsMap(
-	ctx context.Context,
-	project string,
-) (map[string]map[string]string, error) {
-	secrets := corev1.SecretList{}
-	if err := e.kargoClient.List(
-		ctx,
-		&secrets,
-		client.InNamespace(project),
-	); err != nil {
-		return nil,
-			fmt.Errorf("error listing Secrets for Project %q: %w", project, err)
-	}
-	secretsMap := make(map[string]map[string]string, len(secrets.Items))
-	for _, secret := range secrets.Items {
-		secretsMap[secret.Name] = make(map[string]string, len(secret.Data))
-		for key, value := range secret.Data {
-			secretsMap[secret.Name][key] = string(value)
-		}
-	}
-	return secretsMap, nil
-}
diff --git a/internal/directives/simple_engine_health.go b/internal/directives/simple_engine_health.go
new file mode 100644
index 00000000..1c785ffe
--- /dev/null
+++ b/internal/directives/simple_engine_health.go
@@ -0,0 +1,115 @@
+package directives
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+
+	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+)
+
+// CheckHealth implements the Engine interface.
+func (e *SimpleEngine) CheckHealth(
+	ctx context.Context,
+	healthCtx HealthCheckContext,
+	steps []HealthCheckStep,
+) kargoapi.Health {
+	status, issues, output := e.executeHealthChecks(ctx, healthCtx, steps)
+	if len(output) == 0 {
+		return kargoapi.Health{
+			Status: status,
+			Issues: issues,
+		}
+	}
+
+	b, err := json.Marshal(output)
+	if err != nil {
+		issues = append(issues, fmt.Sprintf("failed to marshal health output: %s", err.Error()))
+	}
+
+	return kargoapi.Health{
+		Status: status,
+		Issues: issues,
+		Output: &apiextensionsv1.JSON{Raw: b},
+	}
+}
+
+// executeHealthChecks executes a list of HealthCheckSteps in sequence.
+func (e *SimpleEngine) executeHealthChecks(
+	ctx context.Context,
+	healthCtx HealthCheckContext,
+	steps []HealthCheckStep,
+) (kargoapi.HealthState, []string, []State) {
+	var (
+		aggregatedStatus = kargoapi.HealthStateHealthy
+		aggregatedIssues []string
+		aggregatedOutput = make([]State, 0, len(steps))
+	)
+
+	for _, step := range steps {
+		select {
+		case <-ctx.Done():
+			aggregatedStatus = aggregatedStatus.Merge(kargoapi.HealthStateUnknown)
+			aggregatedIssues = append(aggregatedIssues, ctx.Err().Error())
+			return aggregatedStatus, aggregatedIssues, aggregatedOutput
+		default:
+		}
+
+		result := e.executeHealthCheck(ctx, healthCtx, step)
+		aggregatedStatus = aggregatedStatus.Merge(result.Status)
+		aggregatedIssues = append(aggregatedIssues, result.Issues...)
+
+		if result.Output != nil {
+			aggregatedOutput = append(aggregatedOutput, result.Output)
+		}
+	}
+
+	return aggregatedStatus, aggregatedIssues, aggregatedOutput
+}
+
+// executeHealthCheck executes a single HealthCheckStep.
+func (e *SimpleEngine) executeHealthCheck(
+	ctx context.Context,
+	healthCtx HealthCheckContext,
+	step HealthCheckStep,
+) HealthCheckStepResult {
+	reg, err := e.registry.GetHealthCheckStepRunnerRegistration(step.Kind)
+	if err != nil {
+		return HealthCheckStepResult{
+			Status: kargoapi.HealthStateUnknown,
+			Issues: []string{
+				fmt.Sprintf("no runner registered for step kind %q: %s", step.Kind, err.Error()),
+			},
+		}
+	}
+
+	stepCtx := e.prepareHealthCheckStepContext(healthCtx, step, reg)
+	return reg.Runner.RunHealthCheckStep(ctx, stepCtx)
+}
+
+// prepareHealthCheckStepContext prepares a HealthCheckStepContext for a HealthCheckStep.
+func (e *SimpleEngine) prepareHealthCheckStepContext(
+	healthCtx HealthCheckContext,
+	step HealthCheckStep,
+	reg HealthCheckStepRunnerRegistration,
+) *HealthCheckStepContext {
+	stepCtx := &HealthCheckStepContext{
+		Config:  step.Config.DeepCopy(),
+		Project: healthCtx.Project,
+		Stage:   healthCtx.Stage,
+	}
+
+	if reg.Permissions.AllowCredentialsDB {
+		stepCtx.CredentialsDB = e.credentialsDB
+	}
+	if reg.Permissions.AllowKargoClient {
+		stepCtx.KargoClient = e.kargoClient
+	}
+	if reg.Permissions.AllowArgoCDClient {
+		stepCtx.ArgoCDClient = e.argoCDClient
+	}
+
+	return stepCtx
+}
diff --git a/internal/directives/simple_engine_promote.go b/internal/directives/simple_engine_promote.go
new file mode 100644
index 00000000..b6259365
--- /dev/null
+++ b/internal/directives/simple_engine_promote.go
@@ -0,0 +1,286 @@
+package directives
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"strings"
+
+	corev1 "k8s.io/api/core/v1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+)
+
+// Promote implements the Engine interface.
+func (e *SimpleEngine) Promote(
+	ctx context.Context,
+	promoCtx PromotionContext,
+	steps []PromotionStep,
+) (PromotionResult, error) {
+	workDir, err := e.setupWorkDir(promoCtx.WorkDir)
+	if err != nil {
+		return PromotionResult{Status: kargoapi.PromotionPhaseErrored}, err
+	}
+	if workDir != promoCtx.WorkDir {
+		defer os.RemoveAll(workDir)
+	}
+
+	if promoCtx.Secrets, err = e.getProjectSecrets(ctx, promoCtx.Project); err != nil {
+		return PromotionResult{Status: kargoapi.PromotionPhaseErrored}, err
+	}
+
+	result, err := e.executeSteps(ctx, promoCtx, steps, workDir)
+	if err != nil {
+		return result, fmt.Errorf("step execution failed: %w", err)
+	}
+
+	return result, nil
+}
+
+// executeSteps executes a list of PromotionSteps in sequence.
+func (e *SimpleEngine) executeSteps(
+	ctx context.Context,
+	promoCtx PromotionContext,
+	steps []PromotionStep,
+	workDir string,
+) (PromotionResult, error) {
+	// Initialize the state which will be passed to each step.
+	// This is the state that will be updated by each step,
+	// and returned as the final state after all steps have
+	// run.
+	state := promoCtx.State.DeepCopy()
+	if state == nil {
+		state = make(State)
+	}
+
+	var (
+		healthChecks []HealthCheckStep
+		err          error
+		attempt      = promoCtx.Attempts
+	)
+
+	// Execute each step in sequence, starting from the step index
+	// specified in the PromotionContext if provided.
+	for i := promoCtx.StartFromStep; i < int64(len(steps)); i++ {
+		select {
+		case <-ctx.Done():
+			return PromotionResult{
+				Status:      kargoapi.PromotionPhaseErrored,
+				CurrentStep: i,
+				State:       state,
+			}, ctx.Err()
+		default:
+		}
+
+		// Prepare the step for execution by setting the alias.
+		step := steps[i]
+		if step.Alias, err = e.stepAlias(step.Alias, i); err != nil {
+			return PromotionResult{
+				Status:      kargoapi.PromotionPhaseErrored,
+				CurrentStep: i,
+				State:       state,
+			}, err
+		}
+
+		// Get the PromotionStepRunner for the step.
+		reg, err := e.registry.GetPromotionStepRunnerRegistration(step.Kind)
+		if err != nil {
+			return PromotionResult{
+				Status:      kargoapi.PromotionPhaseErrored,
+				CurrentStep: i,
+				State:       state,
+			}, err
+		}
+
+		// Check if the step has exceeded the maximum number of attempts.
+		maxAttempts := step.GetMaxAttempts(reg.Runner)
+		if maxAttempts > 0 && attempt >= maxAttempts {
+			return PromotionResult{
+				Status:      kargoapi.PromotionPhaseErrored,
+				CurrentStep: i,
+				State:       state,
+				Attempt:     attempt,
+			}, fmt.Errorf("step %q exceeded max attempts", step.Alias)
+		}
+
+		// Count the attempt we are about to make.
+		attempt++
+
+		// Execute the step.
+		result, err := e.executeStep(ctx, promoCtx, step, reg, workDir, state)
+
+		// If the step failed, and the maximum number of attempts has not been
+		// reached, we are still "Running" the step and will retry it.
+		if err != nil || result.Status == kargoapi.PromotionPhaseErrored || result.Status == kargoapi.PromotionPhaseFailed {
+			if maxAttempts < 0 || attempt < maxAttempts {
+				var message strings.Builder
+				_, _ = message.WriteString(fmt.Sprintf("step %q failed (attempt %d)", step.Alias, attempt))
+				if result.Message != "" {
+					_, _ = message.WriteString(": ")
+					_, _ = message.WriteString(result.Message)
+				}
+				if err != nil {
+					_, _ = message.WriteString(": ")
+					_, _ = message.WriteString(err.Error())
+				}
+
+				// Update the result to indicate that the step is still running.
+				result.Status = kargoapi.PromotionPhaseRunning
+				result.Message = message.String()
+
+				// Swallow the error if the step failed, as we are still
+				// retrying it.
+				err = nil
+			}
+		}
+
+		// Update the state with the step output, regardless of the result.
+		state[step.Alias] = result.Output
+
+		// If the step was not successful, return the result to wait for
+		// a next attempt or to fail the promotion.
+		if result.Status != kargoapi.PromotionPhaseSucceeded {
+			return PromotionResult{
+				Status:      result.Status,
+				Message:     result.Message,
+				CurrentStep: i,
+				Attempt:     attempt,
+				State:       state,
+			}, err
+		}
+
+		// If the step was successful, reset the attempts counter and add its
+		// health check to the list.
+		attempt = 0
+		if healthCheck := result.HealthCheckStep; healthCheck != nil {
+			healthChecks = append(healthChecks, *healthCheck)
+		}
+	}
+
+	// All steps have succeeded, return the final state.
+	return PromotionResult{
+		Status:           kargoapi.PromotionPhaseSucceeded,
+		HealthCheckSteps: healthChecks,
+		CurrentStep:      int64(len(steps)) - 1,
+		Attempt:          0,
+		State:            state,
+	}, nil
+}
+
+// executeStep executes a single PromotionStep.
+func (e *SimpleEngine) executeStep(
+	ctx context.Context,
+	promoCtx PromotionContext,
+	step PromotionStep,
+	reg PromotionStepRunnerRegistration,
+	workDir string,
+	state State,
+) (PromotionStepResult, error) {
+	stepCtx, err := e.preparePromotionStepContext(ctx, promoCtx, step, reg.Permissions, workDir, state)
+	if err != nil {
+		return PromotionStepResult{
+			Status: kargoapi.PromotionPhaseErrored,
+		}, err
+	}
+
+	result, err := reg.Runner.RunPromotionStep(ctx, stepCtx)
+	if err != nil {
+		err = fmt.Errorf("failed to run step %q: %w", step.Kind, err)
+	}
+	return result, err
+}
+
+// preparePromotionStepContext prepares a PromotionStepContext for a PromotionStep.
+func (e *SimpleEngine) preparePromotionStepContext(
+	ctx context.Context,
+	promoCtx PromotionContext,
+	step PromotionStep,
+	permissions StepRunnerPermissions,
+	workDir string,
+	state State,
+) (*PromotionStepContext, error) {
+	stateCopy := state.DeepCopy()
+
+	stepCfg, err := step.GetConfig(ctx, e.kargoClient, promoCtx, stateCopy)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get step config: %w", err)
+	}
+
+	stepCtx := &PromotionStepContext{
+		UIBaseURL:       promoCtx.UIBaseURL,
+		WorkDir:         workDir,
+		SharedState:     stateCopy,
+		Alias:           step.Alias,
+		Config:          stepCfg,
+		Project:         promoCtx.Project,
+		Stage:           promoCtx.Stage,
+		Promotion:       promoCtx.Promotion,
+		FreightRequests: promoCtx.FreightRequests,
+		Freight:         promoCtx.Freight,
+	}
+
+	if permissions.AllowCredentialsDB {
+		stepCtx.CredentialsDB = e.credentialsDB
+	}
+	if permissions.AllowKargoClient {
+		stepCtx.KargoClient = e.kargoClient
+	}
+	if permissions.AllowArgoCDClient {
+		stepCtx.ArgoCDClient = e.argoCDClient
+	}
+
+	return stepCtx, nil
+}
+
+// stepAlias returns the alias for a step. If the alias is empty, a default
+// alias is returned based on the step index.
+func (e *SimpleEngine) stepAlias(alias string, index int64) (string, error) {
+	if alias = strings.TrimSpace(alias); alias != "" {
+		// A webhook enforces this regex as well, but we're checking here to
+		// account for the possibility of EXISTING Stages with a promotionTemplate
+		// containing a step with a now-reserved alias.
+		if ReservedStepAliasRegex.MatchString(alias) {
+			return "", fmt.Errorf("step alias %q is forbidden", alias)
+		}
+		return alias, nil
+	}
+	return fmt.Sprintf("step-%d", index), nil
+}
+
+// setupWorkDir creates a temporary working directory if one is not provided.
+func (e *SimpleEngine) setupWorkDir(existingDir string) (string, error) {
+	if existingDir != "" {
+		return existingDir, nil
+	}
+
+	workDir, err := os.MkdirTemp("", "run-")
+	if err != nil {
+		return "", fmt.Errorf("temporary working directory creation failed: %w", err)
+	}
+	return workDir, nil
+}
+
+// getProjectSecrets returns a map of all Secrets in the Project. The returned
+// map is keyed by Secret name and contains a map of Secret data.
+func (e *SimpleEngine) getProjectSecrets(
+	ctx context.Context,
+	project string,
+) (map[string]map[string]string, error) {
+	secrets := corev1.SecretList{}
+	if err := e.kargoClient.List(
+		ctx,
+		&secrets,
+		client.InNamespace(project),
+	); err != nil {
+		return nil, fmt.Errorf("error listing Secrets for Project %q: %w", project, err)
+	}
+	secretsMap := make(map[string]map[string]string, len(secrets.Items))
+	for _, secret := range secrets.Items {
+		secretsMap[secret.Name] = make(map[string]string, len(secret.Data))
+		for key, value := range secret.Data {
+			secretsMap[secret.Name][key] = string(value)
+		}
+	}
+	return secretsMap, nil
+}
diff --git a/ui/src/gen/schema/promotions.kargo.akuity.io_v1alpha1.json b/ui/src/gen/schema/promotions.kargo.akuity.io_v1alpha1.json
index 27c269f1..afd56da1 100644
--- a/ui/src/gen/schema/promotions.kargo.akuity.io_v1alpha1.json
+++ b/ui/src/gen/schema/promotions.kargo.akuity.io_v1alpha1.json
@@ -40,6 +40,19 @@
                 "description": "Config is opaque configuration for the PromotionStep that is understood\nonly by each PromotionStep's implementation. It is legal to utilize\nexpressions in defining values at any level of this block.\nSee https://docs.kargo.io/references/expression-language for details.",
                 "x-kubernetes-preserve-unknown-fields": true
               },
+              "retry": {
+                "description": "Retry is the retry policy for this step.",
+                "properties": {
+                  "attempts": {
+                    "description": "Attempts is the number of times the step can be attempted before the\nPromotionStep is marked as failed.\n\nIf this field is set to 1, the step will not be retried. If this\nfield is set to -1, the step will be retried indefinitely.\n\nThe default of this field depends on the step being executed. Refer to\nthe documentation for the specific step for more information.",
+                    "format": "int64",
+                    "maximum": 9223372036854776000,
+                    "minimum": -9223372036854776000,
+                    "type": "integer"
+                  }
+                },
+                "type": "object"
+              },
               "uses": {
                 "description": "Uses identifies a runner that can execute this step.",
                 "minLength": 1,
@@ -94,6 +107,13 @@
           "minimum": -9223372036854776000,
           "type": "integer"
         },
+        "currentStepAttempt": {
+          "description": "CurrentStepAttempt is the number of times the current step has been\nattempted.",
+          "format": "int64",
+          "maximum": 9223372036854776000,
+          "minimum": -9223372036854776000,
+          "type": "integer"
+        },
         "finishedAt": {
           "description": "FinishedAt is the time when the promotion was completed.",
           "format": "date-time",
diff --git a/ui/src/gen/schema/stages.kargo.akuity.io_v1alpha1.json b/ui/src/gen/schema/stages.kargo.akuity.io_v1alpha1.json
index c789dc92..40a55c06 100644
--- a/ui/src/gen/schema/stages.kargo.akuity.io_v1alpha1.json
+++ b/ui/src/gen/schema/stages.kargo.akuity.io_v1alpha1.json
@@ -35,6 +35,19 @@
                         "description": "Config is opaque configuration for the PromotionStep that is understood\nonly by each PromotionStep's implementation. It is legal to utilize\nexpressions in defining values at any level of this block.\nSee https://docs.kargo.io/references/expression-language for details.",
                         "x-kubernetes-preserve-unknown-fields": true
                       },
+                      "retry": {
+                        "description": "Retry is the retry policy for this step.",
+                        "properties": {
+                          "attempts": {
+                            "description": "Attempts is the number of times the step can be attempted before the\nPromotionStep is marked as failed.\n\nIf this field is set to 1, the step will not be retried. If this\nfield is set to -1, the step will be retried indefinitely.\n\nThe default of this field depends on the step being executed. Refer to\nthe documentation for the specific step for more information.",
+                            "format": "int64",
+                            "maximum": 9223372036854776000,
+                            "minimum": -9223372036854776000,
+                            "type": "integer"
+                          }
+                        },
+                        "type": "object"
+                      },
                       "uses": {
                         "description": "Uses identifies a runner that can execute this step.",
                         "minLength": 1,
@@ -412,6 +425,13 @@
                   "minimum": -9223372036854776000,
                   "type": "integer"
                 },
+                "currentStepAttempt": {
+                  "description": "CurrentStepAttempt is the number of times the current step has been\nattempted.",
+                  "format": "int64",
+                  "maximum": 9223372036854776000,
+                  "minimum": -9223372036854776000,
+                  "type": "integer"
+                },
                 "finishedAt": {
                   "description": "FinishedAt is the time when the promotion was completed.",
                   "format": "date-time",
@@ -1201,6 +1221,13 @@
                   "minimum": -9223372036854776000,
                   "type": "integer"
                 },
+                "currentStepAttempt": {
+                  "description": "CurrentStepAttempt is the number of times the current step has been\nattempted.",
+                  "format": "int64",
+                  "maximum": 9223372036854776000,
+                  "minimum": -9223372036854776000,
+                  "type": "integer"
+                },
                 "finishedAt": {
                   "description": "FinishedAt is the time when the promotion was completed.",
                   "format": "date-time",
diff --git a/ui/src/gen/v1alpha1/generated_pb.ts b/ui/src/gen/v1alpha1/generated_pb.ts
index af35da16..c4ee4ec7 100644
--- a/ui/src/gen/v1alpha1/generated_pb.ts
+++ b/ui/src/gen/v1alpha1/generated_pb.ts
@@ -2767,6 +2767,14 @@ export class PromotionStatus extends Message<PromotionStatus> {
    */
   currentStep?: bigint;
 
+  /**
+   * CurrentStepAttempt is the number of times the current step has been
+   * attempted.
+   *
+   * @generated from field: optional int64 currentStepAttempt = 11;
+   */
+  currentStepAttempt?: bigint;
+
   /**
    * State stores the state of the promotion process between reconciliation
    * attempts.
@@ -2791,6 +2799,7 @@ export class PromotionStatus extends Message<PromotionStatus> {
     { no: 8, name: "healthChecks", kind: "message", T: HealthCheckStep, repeated: true },
     { no: 6, name: "finishedAt", kind: "message", T: Time, opt: true },
     { no: 9, name: "currentStep", kind: "scalar", T: 3 /* ScalarType.INT64 */, opt: true },
+    { no: 11, name: "currentStepAttempt", kind: "scalar", T: 3 /* ScalarType.INT64 */, opt: true },
     { no: 10, name: "state", kind: "message", T: JSON, opt: true },
   ]);
 
@@ -2833,6 +2842,13 @@ export class PromotionStep extends Message<PromotionStep> {
    */
   as?: string;
 
+  /**
+   * Retry is the retry policy for this step.
+   *
+   * @generated from field: optional github.com.akuity.kargo.api.v1alpha1.PromotionStepRetry retry = 4;
+   */
+  retry?: PromotionStepRetry;
+
   /**
    * Config is opaque configuration for the PromotionStep that is understood
    * only by each PromotionStep's implementation. It is legal to utilize
@@ -2853,6 +2869,7 @@ export class PromotionStep extends Message<PromotionStep> {
   static readonly fields: FieldList = proto2.util.newFieldList(() => [
     { no: 1, name: "uses", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true },
     { no: 2, name: "as", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true },
+    { no: 4, name: "retry", kind: "message", T: PromotionStepRetry, opt: true },
     { no: 3, name: "config", kind: "message", T: JSON, opt: true },
   ]);
 
@@ -2873,6 +2890,54 @@ export class PromotionStep extends Message<PromotionStep> {
   }
 }
 
+/**
+ * PromotionStepRetry describes the retry policy for a PromotionStep.
+ *
+ * @generated from message github.com.akuity.kargo.api.v1alpha1.PromotionStepRetry
+ */
+export class PromotionStepRetry extends Message<PromotionStepRetry> {
+  /**
+   * Attempts is the number of times the step can be attempted before the
+   * PromotionStep is marked as failed.
+   *
+   * If this field is set to 1, the step will not be retried. If this
+   * field is set to -1, the step will be retried indefinitely.
+   *
+   * The default of this field depends on the step being executed. Refer to
+   * the documentation for the specific step for more information.
+   *
+   * @generated from field: optional int64 attempts = 1;
+   */
+  attempts?: bigint;
+
+  constructor(data?: PartialMessage<PromotionStepRetry>) {
+    super();
+    proto2.util.initPartial(data, this);
+  }
+
+  static readonly runtime: typeof proto2 = proto2;
+  static readonly typeName = "github.com.akuity.kargo.api.v1alpha1.PromotionStepRetry";
+  static readonly fields: FieldList = proto2.util.newFieldList(() => [
+    { no: 1, name: "attempts", kind: "scalar", T: 3 /* ScalarType.INT64 */, opt: true },
+  ]);
+
+  static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): PromotionStepRetry {
+    return new PromotionStepRetry().fromBinary(bytes, options);
+  }
+
+  static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): PromotionStepRetry {
+    return new PromotionStepRetry().fromJson(jsonValue, options);
+  }
+
+  static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): PromotionStepRetry {
+    return new PromotionStepRetry().fromJsonString(jsonString, options);
+  }
+
+  static equals(a: PromotionStepRetry | PlainMessage<PromotionStepRetry> | undefined, b: PromotionStepRetry | PlainMessage<PromotionStepRetry> | undefined): boolean {
+    return proto2.util.equals(PromotionStepRetry, a, b);
+  }
+}
+
 /**
  * PromotionTemplate defines a template for a Promotion that can be used to
  * incorporate Freight into a Stage.


Test patch:
diff --git a/api/v1alpha1/promotion_types_test.go b/api/v1alpha1/promotion_types_test.go
new file mode 100644
index 00000000..98066278
--- /dev/null
+++ b/api/v1alpha1/promotion_types_test.go
@@ -0,0 +1,39 @@
+package v1alpha1
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestPromotionRetry_GetAttempts(t *testing.T) {
+	tests := []struct {
+		name     string
+		retry    *PromotionStepRetry
+		fallback int64
+		want     int64
+	}{
+		{
+			name:     "retry is nil",
+			retry:    nil,
+			fallback: 1,
+			want:     1,
+		},
+		{
+			name:     "attempts is not set",
+			retry:    &PromotionStepRetry{},
+			fallback: -1,
+			want:     -1,
+		},
+		{
+			name: "attempts is set",
+			retry: &PromotionStepRetry{
+				Attempts: 3,
+			},
+			want: 3,
+		},
+	}
+	for _, tt := range tests {
+		require.Equal(t, tt.want, tt.retry.GetAttempts(tt.fallback))
+	}
+}
diff --git a/internal/directives/git_commiter_test.go b/internal/directives/git_commiter_test.go
index ae59b163..7a62be37 100644
--- a/internal/directives/git_commiter_test.go
+++ b/internal/directives/git_commiter_test.go
@@ -225,7 +225,7 @@ func Test_gitCommitter_runPromotionStep(t *testing.T) {
 	require.Equal(t, kargoapi.PromotionPhaseSucceeded, res.Status)
 	expectedCommit, err := workTree.LastCommitID()
 	require.NoError(t, err)
-	actualCommit, ok := res.Output[commitKey]
+	actualCommit, ok := res.Output[stateKeyCommit]
 	require.True(t, ok)
 	require.Equal(t, expectedCommit, actualCommit)
 	lastCommitMsg, err := workTree.CommitMessage("HEAD")
diff --git a/internal/directives/git_pr_opener_test.go b/internal/directives/git_pr_opener_test.go
index 00d0e511..bb4f9074 100644
--- a/internal/directives/git_pr_opener_test.go
+++ b/internal/directives/git_pr_opener_test.go
@@ -202,7 +202,7 @@ func Test_gitPROpener_runPromotionStep(t *testing.T) {
 			CredentialsDB: &credentials.FakeDB{},
 			SharedState: State{
 				"fake-step": map[string]any{
-					branchKey: testSourceBranch,
+					stateKeyBranch: testSourceBranch,
 				},
 			},
 		},
@@ -216,7 +216,7 @@ func Test_gitPROpener_runPromotionStep(t *testing.T) {
 		},
 	)
 	require.NoError(t, err)
-	prNumber, ok := res.Output[prNumberKey]
+	prNumber, ok := res.Output[stateKeyPRNumber]
 	require.True(t, ok)
 	require.Equal(t, testPRNumber, prNumber)
 
diff --git a/internal/directives/git_pusher_test.go b/internal/directives/git_pusher_test.go
index e900dcbb..7b5618c6 100644
--- a/internal/directives/git_pusher_test.go
+++ b/internal/directives/git_pusher_test.go
@@ -200,12 +200,12 @@ func Test_gitPusher_runPromotionStep(t *testing.T) {
 		},
 	)
 	require.NoError(t, err)
-	branchName, ok := res.Output[branchKey]
+	branchName, ok := res.Output[stateKeyBranch]
 	require.True(t, ok)
 	require.Equal(t, "kargo/promotion/fake-promotion", branchName)
 	expectedCommit, err := workTree.LastCommitID()
 	require.NoError(t, err)
-	actualCommit, ok := res.Output[commitKey]
+	actualCommit, ok := res.Output[stateKeyCommit]
 	require.True(t, ok)
 	require.Equal(t, expectedCommit, actualCommit)
 }
diff --git a/internal/directives/promotions_test.go b/internal/directives/promotions_test.go
index cd86d3a8..217440ab 100644
--- a/internal/directives/promotions_test.go
+++ b/internal/directives/promotions_test.go
@@ -4,11 +4,82 @@ import (
 	"context"
 	"testing"
 
+	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
 )
 
+type mockRetryableRunner struct {
+	defaultAttempts int64
+}
+
+func (m mockRetryableRunner) DefaultAttempts() int64 {
+	return m.defaultAttempts
+}
+
+func TestPromotionStep_GetMaxAttempts(t *testing.T) {
+	tests := []struct {
+		name       string
+		step       *PromotionStep
+		runner     any
+		assertions func(t *testing.T, result int64)
+	}{
+		{
+			name: "returns 1 with no retry config",
+			step: &PromotionStep{
+				Retry: nil,
+			},
+			assertions: func(t *testing.T, result int64) {
+				assert.Equal(t, int64(1), result)
+			},
+		},
+		{
+			name: "returns configured attempts for non-retryable runner",
+			step: &PromotionStep{
+				Retry: &kargoapi.PromotionStepRetry{
+					Attempts: 5,
+				},
+			},
+			runner: nil,
+			assertions: func(t *testing.T, result int64) {
+				assert.Equal(t, int64(5), result)
+			},
+		},
+		{
+			name: "returns configured attempts for retryable runner",
+			step: &PromotionStep{
+				Retry: &kargoapi.PromotionStepRetry{
+					Attempts: 5,
+				},
+			},
+			runner: mockRetryableRunner{defaultAttempts: 3},
+			assertions: func(t *testing.T, result int64) {
+				assert.Equal(t, int64(5), result)
+			},
+		},
+		{
+			name: "returns default attempts when retry config returns 0",
+			step: &PromotionStep{
+				Retry: &kargoapi.PromotionStepRetry{
+					Attempts: 0,
+				},
+			},
+			runner: mockRetryableRunner{defaultAttempts: 3},
+			assertions: func(t *testing.T, result int64) {
+				assert.Equal(t, int64(3), result)
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := tt.step.GetMaxAttempts(tt.runner)
+			tt.assertions(t, result)
+		})
+	}
+}
+
 func TestPromotionStep_GetConfig(t *testing.T) {
 	promoCtx := PromotionContext{
 		Project:   "fake-project",
diff --git a/internal/directives/simple_engine_health_test.go b/internal/directives/simple_engine_health_test.go
new file mode 100644
index 00000000..91a70de1
--- /dev/null
+++ b/internal/directives/simple_engine_health_test.go
@@ -0,0 +1,332 @@
+package directives
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	"github.com/akuity/kargo/internal/credentials"
+)
+
+func TestSimpleEngine_CheckHealth(t *testing.T) {
+	tests := []struct {
+		name       string
+		healthCtx  HealthCheckContext
+		steps      []HealthCheckStep
+		assertions func(*testing.T, kargoapi.Health)
+	}{
+		{
+			name: "successful health check",
+			steps: []HealthCheckStep{
+				{Kind: "success-check"},
+			},
+			assertions: func(t *testing.T, health kargoapi.Health) {
+				assert.Equal(t, kargoapi.HealthStateHealthy, health.Status)
+				assert.Empty(t, health.Issues)
+				assert.NotNil(t, health.Output)
+				assert.JSONEq(t, `[{"test":"success"}]`, string(health.Output.Raw))
+			},
+		},
+		{
+			name: "multiple successful health checks",
+			steps: []HealthCheckStep{
+				{Kind: "success-check"},
+				{Kind: "success-check"},
+			},
+			assertions: func(t *testing.T, health kargoapi.Health) {
+				assert.Equal(t, kargoapi.HealthStateHealthy, health.Status)
+				assert.Empty(t, health.Issues)
+				assert.NotNil(t, health.Output)
+				assert.JSONEq(t, `[{"test":"success"},{"test":"success"}]`, string(health.Output.Raw))
+			},
+		},
+		{
+			name: "failed health check",
+			steps: []HealthCheckStep{
+				{Kind: "error-check"},
+			},
+			assertions: func(t *testing.T, health kargoapi.Health) {
+				assert.Equal(t, kargoapi.HealthStateUnhealthy, health.Status)
+				assert.Contains(t, health.Issues, "health check failed")
+				assert.NotNil(t, health.Output)
+			},
+		},
+		{
+			name: "context cancellation",
+			steps: []HealthCheckStep{
+				{Kind: "context-waiter"},
+			},
+			assertions: func(t *testing.T, health kargoapi.Health) {
+				assert.Equal(t, kargoapi.HealthStateUnknown, health.Status)
+				assert.Contains(t, health.Issues, context.Canceled.Error())
+				assert.Nil(t, health.Output)
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ctx, cancel := context.WithCancel(context.Background())
+			defer cancel()
+
+			testRegistry := NewStepRunnerRegistry()
+			testRegistry.RegisterHealthCheckStepRunner(
+				&mockHealthCheckStepRunner{
+					name: "success-check",
+					runResult: HealthCheckStepResult{
+						Status: kargoapi.HealthStateHealthy,
+						Output: State{"test": "success"},
+					},
+				},
+				nil,
+			)
+			testRegistry.RegisterHealthCheckStepRunner(
+				&mockHealthCheckStepRunner{
+					name: "error-check",
+					runResult: HealthCheckStepResult{
+						Status: kargoapi.HealthStateUnhealthy,
+						Issues: []string{"health check failed"},
+						Output: State{"test": "error"},
+					},
+				},
+				nil,
+			)
+			testRegistry.RegisterHealthCheckStepRunner(
+				&mockHealthCheckStepRunner{
+					name: "context-waiter",
+					runFunc: func(ctx context.Context, _ *HealthCheckStepContext) HealthCheckStepResult {
+						cancel()
+						<-ctx.Done()
+						return HealthCheckStepResult{
+							Status: kargoapi.HealthStateUnknown,
+							Issues: []string{ctx.Err().Error()},
+						}
+					},
+				},
+				nil,
+			)
+
+			engine := &SimpleEngine{
+				registry: testRegistry,
+			}
+
+			health := engine.CheckHealth(ctx, tt.healthCtx, tt.steps)
+			tt.assertions(t, health)
+		})
+	}
+}
+
+func TestSimpleEngine_executeHealthChecks(t *testing.T) {
+	tests := []struct {
+		name       string
+		healthCtx  HealthCheckContext
+		steps      []HealthCheckStep
+		assertions func(*testing.T, kargoapi.HealthState, []string, []State)
+	}{
+		{
+			name: "aggregate multiple healthy checks",
+			steps: []HealthCheckStep{
+				{Kind: "success-check"},
+				{Kind: "success-check"},
+			},
+			assertions: func(t *testing.T, status kargoapi.HealthState, issues []string, output []State) {
+				assert.Equal(t, kargoapi.HealthStateHealthy, status)
+				assert.Empty(t, issues)
+				assert.Len(t, output, 2)
+				for _, o := range output {
+					assert.Equal(t, "success", o["test"])
+				}
+			},
+		},
+		{
+			name: "merge different health states",
+			steps: []HealthCheckStep{
+				{Kind: "success-check"},
+				{Kind: "error-check"},
+			},
+			assertions: func(t *testing.T, status kargoapi.HealthState, issues []string, output []State) {
+				assert.Equal(t, kargoapi.HealthStateUnhealthy, status)
+				assert.Contains(t, issues, "health check failed")
+				assert.Len(t, output, 2)
+			},
+		},
+		{
+			name: "context cancellation",
+			steps: []HealthCheckStep{
+				{Kind: "context-waiter"},
+				{Kind: "success-check"}, // Should not execute
+			},
+			assertions: func(t *testing.T, status kargoapi.HealthState, issues []string, output []State) {
+				assert.Equal(t, kargoapi.HealthStateUnknown, status)
+				assert.Contains(t, issues, context.Canceled.Error())
+				assert.Empty(t, output)
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ctx, cancel := context.WithCancel(context.Background())
+			defer cancel()
+
+			testRegistry := NewStepRunnerRegistry()
+			testRegistry.RegisterHealthCheckStepRunner(
+				&mockHealthCheckStepRunner{
+					name: "success-check",
+					runResult: HealthCheckStepResult{
+						Status: kargoapi.HealthStateHealthy,
+						Output: State{"test": "success"},
+					},
+				},
+				nil,
+			)
+			testRegistry.RegisterHealthCheckStepRunner(
+				&mockHealthCheckStepRunner{
+					name: "error-check",
+					runResult: HealthCheckStepResult{
+						Status: kargoapi.HealthStateUnhealthy,
+						Issues: []string{"health check failed"},
+						Output: State{"test": "error"},
+					},
+				},
+				nil,
+			)
+			testRegistry.RegisterHealthCheckStepRunner(
+				&mockHealthCheckStepRunner{
+					name: "context-waiter",
+					runFunc: func(ctx context.Context, _ *HealthCheckStepContext) HealthCheckStepResult {
+						cancel()
+						<-ctx.Done()
+						return HealthCheckStepResult{
+							Status: kargoapi.HealthStateUnknown,
+							Issues: []string{ctx.Err().Error()},
+						}
+					},
+				},
+				nil,
+			)
+
+			engine := &SimpleEngine{
+				registry: testRegistry,
+			}
+
+			status, issues, output := engine.executeHealthChecks(ctx, tt.healthCtx, tt.steps)
+			tt.assertions(t, status, issues, output)
+		})
+	}
+}
+
+func TestSimpleEngine_executeHealthCheck(t *testing.T) {
+	tests := []struct {
+		name       string
+		healthCtx  HealthCheckContext
+		step       HealthCheckStep
+		assertions func(*testing.T, HealthCheckStepResult)
+	}{
+		{
+			name: "successful execution",
+			step: HealthCheckStep{Kind: "success-check"},
+			assertions: func(t *testing.T, result HealthCheckStepResult) {
+				assert.Equal(t, kargoapi.HealthStateHealthy, result.Status)
+				assert.Empty(t, result.Issues)
+			},
+		},
+		{
+			name: "unregistered runner",
+			step: HealthCheckStep{Kind: "unknown"},
+			assertions: func(t *testing.T, result HealthCheckStepResult) {
+				assert.Equal(t, kargoapi.HealthStateUnknown, result.Status)
+				assert.Contains(t, result.Issues[0], "no runner registered for step kind")
+				assert.Contains(t, result.Issues[0], "unknown")
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			testRegistry := NewStepRunnerRegistry()
+			testRegistry.RegisterHealthCheckStepRunner(
+				&mockHealthCheckStepRunner{
+					name: "success-check",
+					runResult: HealthCheckStepResult{
+						Status: kargoapi.HealthStateHealthy,
+					},
+				},
+				nil,
+			)
+
+			engine := &SimpleEngine{
+				registry: testRegistry,
+			}
+
+			result := engine.executeHealthCheck(context.Background(), tt.healthCtx, tt.step)
+			tt.assertions(t, result)
+		})
+	}
+}
+
+func TestSimpleEngine_prepareHealthCheckStepContext(t *testing.T) {
+	tests := []struct {
+		name        string
+		healthCtx   HealthCheckContext
+		step        HealthCheckStep
+		permissions StepRunnerPermissions
+		assertions  func(*testing.T, *HealthCheckStepContext)
+	}{
+		{
+			name: "context with all permissions",
+			healthCtx: HealthCheckContext{
+				Project: "test-project",
+				Stage:   "test-stage",
+			},
+			step: HealthCheckStep{
+				Config: map[string]any{
+					"key": "value",
+				},
+			},
+			permissions: StepRunnerPermissions{
+				AllowCredentialsDB: true,
+				AllowKargoClient:   true,
+				AllowArgoCDClient:  true,
+			},
+			assertions: func(t *testing.T, ctx *HealthCheckStepContext) {
+				assert.Equal(t, "test-project", ctx.Project)
+				assert.Equal(t, "test-stage", ctx.Stage)
+				assert.NotNil(t, ctx.Config)
+				assert.NotNil(t, ctx.CredentialsDB)
+				assert.NotNil(t, ctx.KargoClient)
+				assert.NotNil(t, ctx.ArgoCDClient)
+			},
+		},
+		{
+			name:        "context without permissions",
+			step:        HealthCheckStep{},
+			permissions: StepRunnerPermissions{},
+			assertions: func(t *testing.T, ctx *HealthCheckStepContext) {
+				assert.Nil(t, ctx.CredentialsDB)
+				assert.Nil(t, ctx.KargoClient)
+				assert.Nil(t, ctx.ArgoCDClient)
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			engine := &SimpleEngine{
+				credentialsDB: &credentials.FakeDB{},
+				kargoClient:   fake.NewClientBuilder().Build(),
+				argoCDClient:  fake.NewClientBuilder().Build(),
+			}
+
+			reg := HealthCheckStepRunnerRegistration{
+				Permissions: tt.permissions,
+			}
+
+			ctx := engine.prepareHealthCheckStepContext(tt.healthCtx, tt.step, reg)
+			tt.assertions(t, ctx)
+		})
+	}
+}
diff --git a/internal/directives/simple_engine_promote_test.go b/internal/directives/simple_engine_promote_test.go
new file mode 100644
index 00000000..ed1a5cc4
--- /dev/null
+++ b/internal/directives/simple_engine_promote_test.go
@@ -0,0 +1,598 @@
+package directives
+
+import (
+	"context"
+	"errors"
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+	"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
+
+	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	"github.com/akuity/kargo/internal/credentials"
+)
+
+func TestSimpleEngine_Promote(t *testing.T) {
+	tests := []struct {
+		name        string
+		promoCtx    PromotionContext
+		steps       []PromotionStep
+		interceptor interceptor.Funcs
+		assertions  func(*testing.T, PromotionResult, error)
+	}{
+		{
+			name: "successful promotion",
+			promoCtx: PromotionContext{
+				Project: "test-project",
+				State:   State{"existing": "state"},
+			},
+			steps: []PromotionStep{
+				{Kind: "success-step"},
+			},
+			assertions: func(t *testing.T, result PromotionResult, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, kargoapi.PromotionPhaseSucceeded, result.Status)
+				assert.NotNil(t, result.State)
+				assert.Equal(t, "state", result.State["existing"])
+				assert.Equal(t, int64(0), result.CurrentStep)
+			},
+		},
+		{
+			name: "failed promotion",
+			promoCtx: PromotionContext{
+				Project: "test-project",
+			},
+			steps: []PromotionStep{
+				{Kind: "error-step"},
+			},
+			assertions: func(t *testing.T, result PromotionResult, err error) {
+				assert.Error(t, err)
+				assert.Equal(t, kargoapi.PromotionPhaseErrored, result.Status)
+				assert.ErrorContains(t, err, "step execution failed")
+			},
+		},
+		{
+			name: "context cancellation",
+			promoCtx: PromotionContext{
+				Project: "test-project",
+			},
+			steps: []PromotionStep{
+				{Kind: "context-waiter"},
+			},
+			assertions: func(t *testing.T, result PromotionResult, err error) {
+				assert.Error(t, err)
+				assert.Equal(t, kargoapi.PromotionPhaseErrored, result.Status)
+				assert.ErrorIs(t, err, context.Canceled)
+			},
+		},
+		{
+			name: "secrets retrieval failure",
+			promoCtx: PromotionContext{
+				Project: "test-project",
+			},
+			steps: []PromotionStep{
+				{Kind: "success-step"},
+			},
+			interceptor: interceptor.Funcs{
+				List: func(context.Context, client.WithWatch, client.ObjectList, ...client.ListOption) error {
+					return errors.New("something went wrong")
+				},
+			},
+			assertions: func(t *testing.T, result PromotionResult, err error) {
+				assert.ErrorContains(t, err, "error listing Secrets for Project")
+				assert.ErrorContains(t, err, "something went wrong")
+				assert.Equal(t, kargoapi.PromotionPhaseErrored, result.Status)
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ctx, cancel := context.WithCancel(context.Background())
+			defer cancel()
+
+			testRegistry := NewStepRunnerRegistry()
+			testRegistry.RegisterPromotionStepRunner(
+				&mockPromotionStepRunner{
+					name:      "success-step",
+					runResult: PromotionStepResult{Status: kargoapi.PromotionPhaseSucceeded},
+				},
+				&StepRunnerPermissions{},
+			)
+			testRegistry.RegisterPromotionStepRunner(
+				&mockPromotionStepRunner{
+					name:      "error-step",
+					runResult: PromotionStepResult{Status: kargoapi.PromotionPhaseErrored},
+					runErr:    errors.New("something went wrong"),
+				},
+				&StepRunnerPermissions{},
+			)
+			testRegistry.RegisterPromotionStepRunner(
+				&mockPromotionStepRunner{
+					name: "context-waiter",
+					runFunc: func(ctx context.Context, _ *PromotionStepContext) (PromotionStepResult, error) {
+						cancel() // Cancel context immediately
+						<-ctx.Done()
+						return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored}, ctx.Err()
+					},
+				},
+				&StepRunnerPermissions{},
+			)
+
+			engine := &SimpleEngine{
+				registry: testRegistry,
+				kargoClient: fake.NewClientBuilder().
+					WithInterceptorFuncs(tt.interceptor).
+					Build(),
+			}
+
+			result, err := engine.Promote(ctx, tt.promoCtx, tt.steps)
+			tt.assertions(t, result, err)
+		})
+	}
+}
+
+func TestSimpleEngine_executeSteps(t *testing.T) {
+	tests := []struct {
+		name       string
+		promoCtx   PromotionContext
+		steps      []PromotionStep
+		assertions func(*testing.T, PromotionResult, error)
+	}{
+		{
+			name: "execute all steps successfully",
+			steps: []PromotionStep{
+				{Kind: "success-step", Alias: "step1"},
+				{Kind: "success-step", Alias: "step2"},
+			},
+			assertions: func(t *testing.T, result PromotionResult, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, kargoapi.PromotionPhaseSucceeded, result.Status)
+				assert.Equal(t, int64(1), result.CurrentStep)
+				assert.Equal(t, int64(0), result.Attempt)
+
+				// Verify state contains outputs from both steps
+				assert.Equal(t, State{
+					"step1": map[string]any{
+						"key": "value",
+					},
+					"step2": map[string]any{
+						"key": "value",
+					},
+				}, result.State)
+			},
+		},
+		{
+			name: "start from middle step",
+			promoCtx: PromotionContext{
+				StartFromStep: 1,
+			},
+			steps: []PromotionStep{
+				{Kind: "error-step", Alias: "step1"}, // This step should be skipped
+				{Kind: "success-step", Alias: "step2"},
+			},
+			assertions: func(t *testing.T, result PromotionResult, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, kargoapi.PromotionPhaseSucceeded, result.Status)
+				assert.Equal(t, int64(1), result.CurrentStep)
+				assert.Equal(t, int64(0), result.Attempt)
+
+				// Verify only second step output is in state
+				assert.Equal(t, State{
+					"step2": map[string]any{
+						"key": "value",
+					},
+				}, result.State)
+			},
+		},
+		{
+			name: "fail on step execution",
+			steps: []PromotionStep{
+				{Kind: "success-step", Alias: "step1"},
+				{Kind: "error-step", Alias: "step2"},
+			},
+			assertions: func(t *testing.T, result PromotionResult, err error) {
+				assert.ErrorContains(t, err, "something went wrong")
+				assert.Equal(t, kargoapi.PromotionPhaseErrored, result.Status)
+				assert.Equal(t, int64(1), result.CurrentStep)
+				assert.Equal(t, int64(1), result.Attempt)
+
+				// Verify first step output is preserved in state
+				assert.Equal(t, State{
+					"step1": map[string]any{
+						"key": "value",
+					},
+					"step2": map[string]any(nil),
+				}, result.State)
+			},
+		},
+		{
+			name: "fail on invalid step alias",
+			steps: []PromotionStep{
+				{Kind: "success-step", Alias: "step-1"},
+			},
+			assertions: func(t *testing.T, result PromotionResult, err error) {
+				assert.ErrorContains(t, err, "is forbidden")
+				assert.Equal(t, kargoapi.PromotionPhaseErrored, result.Status)
+				assert.Equal(t, int64(0), result.CurrentStep)
+			},
+		},
+		{
+			name: "context cancellation",
+			steps: []PromotionStep{
+				{Kind: "context-waiter", Alias: "step1"},
+			},
+			assertions: func(t *testing.T, result PromotionResult, err error) {
+				assert.ErrorIs(t, err, context.Canceled)
+				assert.Equal(t, kargoapi.PromotionPhaseErrored, result.Status)
+				assert.Equal(t, int64(0), result.CurrentStep)
+			},
+		},
+		{
+			name: "retry failed step within max attempts",
+			promoCtx: PromotionContext{
+				Attempts: 1,
+			},
+			steps: []PromotionStep{
+				{
+					Kind:  "error-step",
+					Alias: "step1",
+					Retry: &kargoapi.PromotionStepRetry{Attempts: 3},
+				},
+			},
+			assertions: func(t *testing.T, result PromotionResult, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, kargoapi.PromotionPhaseRunning, result.Status)
+				assert.Equal(t, int64(0), result.CurrentStep)
+				assert.Equal(t, int64(2), result.Attempt)
+				assert.Contains(t, result.Message, "attempt 2")
+			},
+		},
+		{
+			name: "max attempts exceeded",
+			promoCtx: PromotionContext{
+				Attempts: 3,
+			},
+			steps: []PromotionStep{
+				{
+					Kind:  "error-step",
+					Alias: "step1",
+					Retry: &kargoapi.PromotionStepRetry{Attempts: 3},
+				},
+			},
+			assertions: func(t *testing.T, result PromotionResult, err error) {
+				assert.Error(t, err)
+				assert.Equal(t, kargoapi.PromotionPhaseErrored, result.Status)
+				assert.Equal(t, int64(0), result.CurrentStep)
+				assert.Equal(t, int64(3), result.Attempt)
+				assert.Contains(t, err.Error(), "exceeded max attempts")
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ctx, cancel := context.WithCancel(context.Background())
+			defer cancel()
+
+			testRegistry := NewStepRunnerRegistry()
+			testRegistry.RegisterPromotionStepRunner(
+				&mockPromotionStepRunner{
+					name: "success-step",
+					runResult: PromotionStepResult{
+						Status: kargoapi.PromotionPhaseSucceeded,
+						Output: map[string]any{"key": "value"},
+					},
+				},
+				&StepRunnerPermissions{},
+			)
+			testRegistry.RegisterPromotionStepRunner(
+				&mockPromotionStepRunner{
+					name:      "error-step",
+					runResult: PromotionStepResult{Status: kargoapi.PromotionPhaseErrored},
+					runErr:    errors.New("something went wrong"),
+				},
+				&StepRunnerPermissions{},
+			)
+			testRegistry.RegisterPromotionStepRunner(
+				&mockPromotionStepRunner{
+					name: "context-waiter",
+					runFunc: func(ctx context.Context, _ *PromotionStepContext) (PromotionStepResult, error) {
+						cancel()
+						<-ctx.Done()
+						return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored}, ctx.Err()
+					},
+				},
+				&StepRunnerPermissions{},
+			)
+
+			engine := &SimpleEngine{
+				registry:    testRegistry,
+				kargoClient: fake.NewClientBuilder().Build(),
+			}
+
+			result, err := engine.executeSteps(ctx, tt.promoCtx, tt.steps, t.TempDir())
+			tt.assertions(t, result, err)
+		})
+	}
+}
+
+func TestSimpleEngine_executeStep(t *testing.T) {
+	tests := []struct {
+		name       string
+		promoCtx   PromotionContext
+		step       PromotionStep
+		reg        PromotionStepRunnerRegistration
+		assertions func(*testing.T, PromotionStepResult, error)
+	}{
+		{
+			name: "successful step execution",
+			reg: PromotionStepRunnerRegistration{
+				Runner: &mockPromotionStepRunner{
+					name: "success-step",
+					runResult: PromotionStepResult{
+						Status: kargoapi.PromotionPhaseSucceeded,
+					},
+				},
+			},
+			assertions: func(t *testing.T, result PromotionStepResult, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, kargoapi.PromotionPhaseSucceeded, result.Status)
+			},
+		},
+		{
+			name: "step execution failure",
+			step: PromotionStep{Kind: "error-step"},
+			reg: PromotionStepRunnerRegistration{
+				Runner: &mockPromotionStepRunner{
+					name: "error-step",
+					runResult: PromotionStepResult{
+						Status: kargoapi.PromotionPhaseErrored,
+					},
+					runErr: errors.New("something went wrong"),
+				},
+			},
+			assertions: func(t *testing.T, result PromotionStepResult, err error) {
+				assert.ErrorContains(t, err, "failed to run step \"error-step\"")
+				assert.ErrorContains(t, err, "something went wrong")
+				assert.Equal(t, kargoapi.PromotionPhaseErrored, result.Status)
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			engine := &SimpleEngine{
+				kargoClient: fake.NewClientBuilder().Build(),
+			}
+
+			result, err := engine.executeStep(context.Background(), tt.promoCtx, tt.step, tt.reg, t.TempDir(), make(State))
+			tt.assertions(t, result, err)
+		})
+	}
+}
+
+func TestSimpleEngine_preparePromotionStepContext(t *testing.T) {
+	tests := []struct {
+		name        string
+		promoCtx    PromotionContext
+		step        PromotionStep
+		permissions StepRunnerPermissions
+		assertions  func(*testing.T, *PromotionStepContext, error)
+	}{
+		{
+			name: "successful context preparation",
+			promoCtx: PromotionContext{
+				Project:   "test-project",
+				Stage:     "test-stage",
+				UIBaseURL: "http://test",
+			},
+			step: PromotionStep{Kind: "test-step"},
+			permissions: StepRunnerPermissions{
+				AllowCredentialsDB: true,
+				AllowKargoClient:   true,
+				AllowArgoCDClient:  true,
+			},
+			assertions: func(t *testing.T, ctx *PromotionStepContext, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, "test-project", ctx.Project)
+				assert.Equal(t, "test-stage", ctx.Stage)
+				assert.Equal(t, "http://test", ctx.UIBaseURL)
+				assert.NotNil(t, ctx.CredentialsDB)
+				assert.NotNil(t, ctx.KargoClient)
+				assert.NotNil(t, ctx.ArgoCDClient)
+			},
+		},
+		{
+			name:        "permissions control client access",
+			promoCtx:    PromotionContext{},
+			step:        PromotionStep{Kind: "test-step"},
+			permissions: StepRunnerPermissions{},
+			assertions: func(t *testing.T, ctx *PromotionStepContext, err error) {
+				assert.NoError(t, err)
+				assert.Nil(t, ctx.CredentialsDB)
+				assert.Nil(t, ctx.KargoClient)
+				assert.Nil(t, ctx.ArgoCDClient)
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			engine := &SimpleEngine{
+				registry:      NewStepRunnerRegistry(),
+				kargoClient:   fake.NewClientBuilder().Build(),
+				argoCDClient:  fake.NewClientBuilder().Build(),
+				credentialsDB: &credentials.FakeDB{},
+			}
+
+			stepCtx, err := engine.preparePromotionStepContext(
+				context.Background(),
+				tt.promoCtx,
+				tt.step,
+				tt.permissions,
+				t.TempDir(),
+				make(State),
+			)
+			tt.assertions(t, stepCtx, err)
+		})
+	}
+}
+
+func TestSimpleEngine_stepAlias(t *testing.T) {
+	tests := []struct {
+		name       string
+		alias      string
+		index      int64
+		assertions func(*testing.T, string, error)
+	}{
+		{
+			name:  "use provided alias",
+			alias: "custom-step",
+			assertions: func(t *testing.T, alias string, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, "custom-step", alias)
+			},
+		},
+		{
+			name:  "generate default alias",
+			index: 42,
+			assertions: func(t *testing.T, alias string, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, "step-42", alias)
+			},
+		},
+		{
+			name:  "reject reserved alias",
+			alias: "step-1",
+			assertions: func(t *testing.T, _ string, err error) {
+				assert.ErrorContains(t, err, "forbidden")
+			},
+		},
+		{
+			name:  "trim whitespace",
+			alias: "  step  ",
+			assertions: func(t *testing.T, alias string, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, "step", alias)
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			engine := &SimpleEngine{}
+			alias, err := engine.stepAlias(tt.alias, tt.index)
+			tt.assertions(t, alias, err)
+		})
+	}
+}
+
+func TestSimpleEngine_setupWorkDir(t *testing.T) {
+	tmpDir := t.TempDir()
+
+	tests := []struct {
+		name        string
+		existingDir string
+		assertions  func(*testing.T, string, error)
+	}{
+		{
+			name:        "use existing directory",
+			existingDir: tmpDir,
+			assertions: func(t *testing.T, dir string, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, tmpDir, dir)
+			},
+		},
+		{
+			name: "create new directory",
+			assertions: func(t *testing.T, dir string, err error) {
+				assert.NoError(t, err)
+				assert.DirExists(t, dir)
+				t.Cleanup(func() {
+					_ = os.RemoveAll(dir)
+				})
+				assert.Contains(t, dir, "run-")
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			engine := &SimpleEngine{}
+			dir, err := engine.setupWorkDir(tt.existingDir)
+			tt.assertions(t, dir, err)
+		})
+	}
+}
+
+func TestSimpleEngine_getProjectSecrets(t *testing.T) {
+	tests := []struct {
+		name        string
+		project     string
+		objects     []client.Object
+		interceptor interceptor.Funcs
+		assertions  func(*testing.T, map[string]map[string]string, error)
+	}{
+		{
+			name:    "successful retrieval",
+			project: "test-project",
+			objects: []client.Object{
+				&corev1.Secret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "test-secret",
+						Namespace: "test-project",
+					},
+					Data: map[string][]byte{
+						"key1": []byte("value1"),
+						"key2": []byte("value2"),
+					},
+				},
+			},
+			assertions: func(t *testing.T, secrets map[string]map[string]string, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, "value1", secrets["test-secret"]["key1"])
+				assert.Equal(t, "value2", secrets["test-secret"]["key2"])
+			},
+		},
+		{
+			name:    "list error",
+			project: "test-project",
+			interceptor: interceptor.Funcs{
+				List: func(context.Context, client.WithWatch, client.ObjectList, ...client.ListOption) error {
+					return errors.New("list error")
+				},
+			},
+			assertions: func(t *testing.T, _ map[string]map[string]string, err error) {
+				assert.ErrorContains(t, err, "error listing Secrets")
+			},
+		},
+		{
+			name:    "no secrets",
+			project: "empty-project",
+			assertions: func(t *testing.T, secrets map[string]map[string]string, err error) {
+				assert.NoError(t, err)
+				assert.Empty(t, secrets)
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			engine := &SimpleEngine{
+				kargoClient: fake.NewClientBuilder().
+					WithObjects(tt.objects...).
+					WithInterceptorFuncs(tt.interceptor).
+					Build(),
+			}
+
+			secrets, err := engine.getProjectSecrets(context.Background(), tt.project)
+			tt.assertions(t, secrets, err)
+		})
+	}
+}
diff --git a/internal/directives/simple_engine_test.go b/internal/directives/simple_engine_test.go
deleted file mode 100644
index 1d130a45..00000000
--- a/internal/directives/simple_engine_test.go
+++ /dev/null
@@ -1,316 +0,0 @@
-package directives
-
-import (
-	"context"
-	"encoding/json"
-	"errors"
-	"strings"
-	"testing"
-	"time"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-	"sigs.k8s.io/controller-runtime/pkg/client/fake"
-	"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
-
-	kargoapi "github.com/akuity/kargo/api/v1alpha1"
-)
-
-func TestSimpleEngine_Promote(t *testing.T) {
-	testHealthCheckStep := HealthCheckStep{
-		Kind: "fake",
-		Config: Config{
-			"fake-key": "fake-value",
-		},
-	}
-
-	errorResult := PromotionStepResult{Status: kargoapi.PromotionPhaseErrored}
-	successResult := PromotionStepResult{
-		Status:          kargoapi.PromotionPhaseSucceeded,
-		HealthCheckStep: &testHealthCheckStep,
-	}
-
-	const successStepName = "success"
-	const errorStepName = "failure"
-	const contextWaiterStep = "waiter"
-	testRegistry := NewStepRunnerRegistry()
-	testRegistry.RegisterPromotionStepRunner(
-		&mockPromotionStepRunner{
-			name:      successStepName,
-			runResult: successResult,
-		},
-		nil,
-	)
-	testRegistry.RegisterPromotionStepRunner(
-		&mockPromotionStepRunner{
-			name:      errorStepName,
-			runResult: errorResult,
-			runErr:    errors.New("something went wrong"),
-		},
-		nil,
-	)
-	testRegistry.RegisterPromotionStepRunner(
-		&mockPromotionStepRunner{
-			name: contextWaiterStep,
-			runFunc: func(
-				ctx context.Context,
-				_ *PromotionStepContext,
-			) (PromotionStepResult, error) {
-				<-ctx.Done() // Wait for context to be canceled
-				return successResult, nil
-			},
-		},
-		nil,
-	)
-
-	tests := []struct {
-		name       string
-		steps      []PromotionStep
-		ctx        context.Context
-		client     client.Client
-		assertions func(*testing.T, PromotionResult, error)
-	}{
-		{
-			name: "failure: error getting secrets",
-			ctx:  context.Background(),
-			client: fake.NewClientBuilder().WithInterceptorFuncs(
-				interceptor.Funcs{
-					List: func(context.Context, client.WithWatch, client.ObjectList, ...client.ListOption) error {
-						return errors.New("something went wrong")
-					},
-				},
-			).Build(),
-			assertions: func(t *testing.T, res PromotionResult, err error) {
-				assert.Equal(t, kargoapi.PromotionPhaseErrored, res.Status)
-				assert.ErrorContains(t, err, "error listing Secrets for Project")
-				assert.ErrorContains(t, err, "something went wrong")
-			},
-		},
-		{
-			name:   "success: single step",
-			steps:  []PromotionStep{{Kind: successStepName}},
-			ctx:    context.Background(),
-			client: fake.NewClientBuilder().Build(),
-			assertions: func(t *testing.T, res PromotionResult, err error) {
-				assert.Equal(t, kargoapi.PromotionPhaseSucceeded, res.Status)
-				assert.Equal(t, []HealthCheckStep{testHealthCheckStep}, res.HealthCheckSteps)
-				assert.NoError(t, err)
-			},
-		},
-		{
-			name: "success: multiple steps",
-			steps: []PromotionStep{
-				{Kind: successStepName},
-				{Kind: successStepName},
-			},
-			ctx:    context.Background(),
-			client: fake.NewClientBuilder().Build(),
-			assertions: func(t *testing.T, res PromotionResult, err error) {
-				assert.Equal(t, kargoapi.PromotionPhaseSucceeded, res.Status)
-				assert.Equal(
-					t,
-					[]HealthCheckStep{testHealthCheckStep, testHealthCheckStep},
-					res.HealthCheckSteps,
-				)
-				assert.NoError(t, err)
-			},
-		},
-		{
-			name:   "failure: runner not found",
-			steps:  []PromotionStep{{Kind: "unknown"}},
-			ctx:    context.Background(),
-			client: fake.NewClientBuilder().Build(),
-			assertions: func(t *testing.T, res PromotionResult, err error) {
-				assert.Equal(t, kargoapi.PromotionPhaseErrored, res.Status)
-				assert.ErrorContains(t, err, "not found")
-			},
-		},
-		{
-			name:   "failure: runner returns error",
-			steps:  []PromotionStep{{Kind: errorStepName}},
-			ctx:    context.Background(),
-			client: fake.NewClientBuilder().Build(),
-			assertions: func(t *testing.T, res PromotionResult, err error) {
-				assert.Equal(t, kargoapi.PromotionPhaseErrored, res.Status)
-				assert.ErrorContains(t, err, "something went wrong")
-			},
-		},
-		{
-			name: "failure: context canceled",
-			steps: []PromotionStep{
-				{Kind: contextWaiterStep},
-				{Kind: contextWaiterStep}, // This runner should not be executed
-			},
-			ctx: func() context.Context {
-				ctx, cancel := context.WithCancel(context.Background())
-				go func() {
-					time.Sleep(10 * time.Millisecond)
-					cancel()
-				}()
-				return ctx
-			}(),
-			client: fake.NewClientBuilder().Build(),
-			assertions: func(t *testing.T, res PromotionResult, err error) {
-				assert.Equal(t, kargoapi.PromotionPhaseErrored, res.Status)
-				assert.ErrorIs(t, err, context.Canceled)
-			},
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			engine := NewSimpleEngine(nil, nil, nil)
-			engine.registry = testRegistry
-			engine.kargoClient = tt.client
-			res, err := engine.Promote(tt.ctx, PromotionContext{}, tt.steps)
-			tt.assertions(t, res, err)
-		})
-	}
-}
-
-func TestSimpleEngine_CheckHealth(t *testing.T) {
-	testOutput := map[string]any{
-		"fake-key": "fake-value",
-	}
-	testOutputBytes, err := json.Marshal(testOutput)
-	require.NoError(t, err)
-
-	const healthyStepName = "healthy"
-	const unhealthyStepName = "unhealthy"
-	const contextWaiterStep = "waiter"
-	testRegistry := NewStepRunnerRegistry()
-	testRegistry.RegisterHealthCheckStepRunner(
-		&mockHealthCheckStepRunner{
-			name: healthyStepName,
-			runResult: HealthCheckStepResult{
-				Status: kargoapi.HealthStateHealthy,
-				Output: testOutput,
-			},
-		},
-		nil,
-	)
-	testRegistry.RegisterHealthCheckStepRunner(
-		&mockHealthCheckStepRunner{
-			name: unhealthyStepName,
-			runResult: HealthCheckStepResult{
-				Status: kargoapi.HealthStateUnhealthy,
-				Issues: []string{"something went wrong"},
-				Output: testOutput,
-			},
-		},
-		nil,
-	)
-	testRegistry.RegisterHealthCheckStepRunner(
-		&mockHealthCheckStepRunner{
-			name: contextWaiterStep,
-			runFunc: func(ctx context.Context, _ *HealthCheckStepContext) HealthCheckStepResult {
-				<-ctx.Done() // Wait for context to be canceled
-				return HealthCheckStepResult{
-					Status: kargoapi.HealthStateHealthy,
-					Output: testOutput,
-				}
-			},
-		},
-		nil,
-	)
-
-	tests := []struct {
-		name       string
-		steps      []HealthCheckStep
-		ctx        context.Context
-		assertions func(*testing.T, kargoapi.Health)
-	}{
-		{
-			name:  "healthy: single step",
-			steps: []HealthCheckStep{{Kind: healthyStepName}},
-			ctx:   context.Background(),
-			assertions: func(t *testing.T, res kargoapi.Health) {
-				require.Equal(t, kargoapi.HealthStateHealthy, res.Status)
-				require.Empty(t, res.Issues)
-				require.NotNil(t, res.Output)
-				require.NotEmpty(t, res.Output.Raw)
-				require.Equal(t, 1, strings.Count(string(res.Output.Raw), string(testOutputBytes)))
-			},
-		},
-		{
-			name:  "healthy: multiple steps",
-			steps: []HealthCheckStep{{Kind: healthyStepName}, {Kind: healthyStepName}},
-			ctx:   context.Background(),
-			assertions: func(t *testing.T, res kargoapi.Health) {
-				require.Equal(t, kargoapi.HealthStateHealthy, res.Status)
-				require.Empty(t, res.Issues)
-				require.NotNil(t, res.Output)
-				require.NotEmpty(t, res.Output.Raw)
-				require.Equal(t, 2, strings.Count(string(res.Output.Raw), string(testOutputBytes)))
-			},
-		},
-		{
-			name:  "unknown: runner not found",
-			steps: []HealthCheckStep{{Kind: healthyStepName}, {Kind: "unknown"}},
-			ctx:   context.Background(),
-			assertions: func(t *testing.T, res kargoapi.Health) {
-				// First step healthy + second step not found == unknown
-				require.Equal(t, kargoapi.HealthStateUnknown, res.Status)
-				require.Len(t, res.Issues, 1)
-				require.Contains(t, res.Issues[0], "no runner registered for step kind")
-				require.NotNil(t, res.Output)
-				require.NotEmpty(t, res.Output.Raw)
-				// We should still get the output from the first step
-				require.Equal(t, 1, strings.Count(string(res.Output.Raw), string(testOutputBytes)))
-			},
-		},
-		{
-			name:  "unhealthy: a step returns unhealthy",
-			steps: []HealthCheckStep{{Kind: unhealthyStepName}, {Kind: healthyStepName}},
-			ctx:   context.Background(),
-			assertions: func(t *testing.T, res kargoapi.Health) {
-				// First step unhealthy + second step unhealthy == unhealthy
-				require.Equal(t, kargoapi.HealthStateUnhealthy, res.Status)
-				require.Len(t, res.Issues, 1)
-				require.Equal(t, "something went wrong", res.Issues[0])
-				require.NotNil(t, res.Output)
-				require.NotEmpty(t, res.Output.Raw)
-				// We should still get the output from both steps
-				require.Equal(t, 2, strings.Count(string(res.Output.Raw), string(testOutputBytes)))
-			},
-		},
-		{
-			name: "unknown: context canceled",
-			steps: []HealthCheckStep{
-				{Kind: contextWaiterStep},
-				{Kind: contextWaiterStep},
-			},
-			ctx: func() context.Context {
-				ctx, cancel := context.WithCancel(context.Background())
-				go func() {
-					time.Sleep(10 * time.Millisecond)
-					cancel()
-				}()
-				return ctx
-			}(),
-			assertions: func(t *testing.T, res kargoapi.Health) {
-				// First step healthy + context canceled == unknown
-				require.Equal(t, kargoapi.HealthStateUnknown, res.Status)
-				require.Len(t, res.Issues, 1)
-				require.Equal(t, context.Canceled.Error(), res.Issues[0])
-				require.NotNil(t, res.Output)
-				require.NotEmpty(t, res.Output.Raw)
-				// We should have output from one step
-				require.Equal(t, 1, strings.Count(string(res.Output.Raw), string(testOutputBytes)))
-			},
-		},
-	}
-
-	engine := NewSimpleEngine(nil, nil, nil)
-	engine.registry = testRegistry
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			tt.assertions(
-				t,
-				engine.CheckHealth(tt.ctx, HealthCheckContext{}, tt.steps),
-			)
-		})
-	}
-}