diff --git a/api/v1alpha1/agentteam_types.go b/api/v1alpha1/agentteam_types.go index 6c5dcf2..046e701 100644 --- a/api/v1alpha1/agentteam_types.go +++ b/api/v1alpha1/agentteam_types.go @@ -413,10 +413,18 @@ type LifecycleSpec struct { BudgetLimit *string `json:"budgetLimit,omitempty"` // OnComplete determines what happens when the team finishes. - // +kubebuilder:validation:Enum=create-pr;push-branch;notify;none + // +kubebuilder:validation:Enum=create-pr;push-branch;notify;deliver;none // +kubebuilder:default="notify" OnComplete string `json:"onComplete,omitempty"` + // Delivery is the list of artifact delivery targets fired when + // OnComplete=deliver. Each target is dispatched independently; + // per-target success/failure is recorded in status.delivery[]. + // Delivery failure is best-effort — the team is not rolled back to + // Failed if a target rejects the request. + // +optional + Delivery []DeliveryTarget `json:"delivery,omitempty"` + // PullRequest configures PR creation when onComplete is "create-pr". // +optional PullRequest *PullRequestSpec `json:"pullRequest,omitempty"` @@ -464,6 +472,76 @@ type LifecycleSpec struct { ConsolidatedBranchTemplate string `json:"consolidatedBranchTemplate,omitempty"` } +// DeliveryTarget describes one artifact delivery destination fired when +// OnComplete=deliver. The Type discriminator selects which fields are +// meaningful — webhook + slack are functional in v0.8.0; email and +// google-drive are accepted at the API level and dispatched to senders +// that currently return a "not implemented" error recorded in +// status.delivery[]. +// +// Across all types the operator never persists credentials itself; the +// sender pulls them from CredentialsSecret at dispatch time so a +// compromised operator pod can't enumerate Slack tokens / SMTP +// passwords / Drive service-account keys at rest. +type DeliveryTarget struct { + // Type names the delivery backend. + // +kubebuilder:validation:Enum=webhook;slack;email;google-drive + Type string `json:"type"` + + // ArtifactPath is the file path within the team's output workspace + // (typically /workspace/output) to attach to or send as the + // delivery body. Optional for delivery types that carry their + // message inline (e.g. a plain Slack notification with no file). + // +optional + ArtifactPath string `json:"artifactPath,omitempty"` + + // Message is the human-readable text that accompanies the delivery + // (Slack message text, webhook notes, email body lead-in). + // +optional + Message string `json:"message,omitempty"` + + // URL is the destination for the webhook delivery type. + // +optional + URL string `json:"url,omitempty"` + + // Channel is the destination for the slack delivery type + // (e.g. "#reports"). The slack sender reads + // CredentialsSecret["slack-webhook-url"] to know where to post. + // +optional + Channel string `json:"channel,omitempty"` + + // To is the recipient list for the email delivery type. + // +optional + To []string `json:"to,omitempty"` + + // Subject is the message subject for the email delivery type. + // +optional + Subject string `json:"subject,omitempty"` + + // AttachmentPath is a file path within the team's output workspace + // to attach to the email delivery. Equivalent to ArtifactPath but + // kept distinct because some emails attach + reference a separate + // artifact in the body. + // +optional + AttachmentPath string `json:"attachmentPath,omitempty"` + + // Folder is the destination folder for the google-drive delivery + // type. + // +optional + Folder string `json:"folder,omitempty"` + + // CredentialsSecret names a Secret in the team's namespace carrying + // authentication for this target. Expected keys per type: + // + // - slack: "slack-webhook-url" — full https://hooks.slack.com/... URL + // - email: "smtp-host", "smtp-port", "smtp-username", "smtp-password" + // - google-drive: "service-account.json" + // + // Not required for webhook; the URL is in the spec. + // +optional + CredentialsSecret string `json:"credentialsSecret,omitempty"` +} + // PullRequestSpec configures automatic PR creation. type PullRequestSpec struct { // TargetBranch is the branch to open the PR against. @@ -594,6 +672,13 @@ type AgentTeamStatus struct { // +optional Pipeline *PipelineStatus `json:"pipeline,omitempty"` + // Delivery records the outcome of every DeliveryTarget dispatched + // by OnComplete=deliver. Populated once executeOnComplete has run; + // each entry is independent — partial success is normal and the + // team is not rolled back when individual targets fail. + // +optional + Delivery []DeliveryStatus `json:"delivery,omitempty"` + // Conditions represent the latest available observations. // +optional Conditions []metav1.Condition `json:"conditions,omitempty"` @@ -633,6 +718,28 @@ type TeammateStatus struct { RestartCount int32 `json:"restartCount,omitempty"` } +// DeliveryStatus records the result of a single DeliveryTarget dispatch. +type DeliveryStatus struct { + // Type mirrors the originating DeliveryTarget.Type. + Type string `json:"type"` + + // Target is a short human-readable label describing where this + // delivery went (e.g. "#reports" for slack, the URL for webhook). + // +optional + Target string `json:"target,omitempty"` + + // DeliveredAt is when the sender finished — whether successfully + // or not. + DeliveredAt metav1.Time `json:"deliveredAt"` + + // Success is true iff the sender returned no error. + Success bool `json:"success"` + + // Error carries the sender's failure message when Success is false. + // +optional + Error string `json:"error,omitempty"` +} + // PipelineStatus reports stage-level progress for a pipelined team. type PipelineStatus struct { // CurrentStage names the lowest-indexed stage that has not yet diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 78e8f3d..a94b092 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -415,6 +415,13 @@ func (in *AgentTeamStatus) DeepCopyInto(out *AgentTeamStatus) { *out = new(PipelineStatus) (*in).DeepCopyInto(*out) } + if in.Delivery != nil { + in, out := &in.Delivery, &out.Delivery + *out = make([]DeliveryStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions *out = make([]v1.Condition, len(*in)) @@ -757,6 +764,42 @@ func (in *CoordinationSpec) DeepCopy() *CoordinationSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DeliveryStatus) DeepCopyInto(out *DeliveryStatus) { + *out = *in + in.DeliveredAt.DeepCopyInto(&out.DeliveredAt) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeliveryStatus. +func (in *DeliveryStatus) DeepCopy() *DeliveryStatus { + if in == nil { + return nil + } + out := new(DeliveryStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DeliveryTarget) DeepCopyInto(out *DeliveryTarget) { + *out = *in + if in.To != nil { + in, out := &in.To, &out.To + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeliveryTarget. +func (in *DeliveryTarget) DeepCopy() *DeliveryTarget { + if in == nil { + return nil + } + out := new(DeliveryTarget) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *InputSpec) DeepCopyInto(out *InputSpec) { *out = *in @@ -806,6 +849,13 @@ func (in *LifecycleSpec) DeepCopyInto(out *LifecycleSpec) { *out = new(string) **out = **in } + if in.Delivery != nil { + in, out := &in.Delivery, &out.Delivery + *out = make([]DeliveryTarget, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.PullRequest != nil { in, out := &in.PullRequest, &out.PullRequest *out = new(PullRequestSpec) diff --git a/charts/kagents/crds/kagents.dev_agentteamruns.yaml b/charts/kagents/crds/kagents.dev_agentteamruns.yaml index addf5a5..e48869e 100644 --- a/charts/kagents/crds/kagents.dev_agentteamruns.yaml +++ b/charts/kagents/crds/kagents.dev_agentteamruns.yaml @@ -241,6 +241,94 @@ spec: branch name pushed by OnComplete=push-branch. Available variables: .TeamName, .Namespace. When empty, defaults to "teams/{{.TeamName}}". type: string + delivery: + description: |- + Delivery is the list of artifact delivery targets fired when + OnComplete=deliver. Each target is dispatched independently; + per-target success/failure is recorded in status.delivery[]. + Delivery failure is best-effort — the team is not rolled back to + Failed if a target rejects the request. + items: + description: |- + DeliveryTarget describes one artifact delivery destination fired when + OnComplete=deliver. The Type discriminator selects which fields are + meaningful — webhook + slack are functional in v0.8.0; email and + google-drive are accepted at the API level and dispatched to senders + that currently return a "not implemented" error recorded in + status.delivery[]. + + Across all types the operator never persists credentials itself; the + sender pulls them from CredentialsSecret at dispatch time so a + compromised operator pod can't enumerate Slack tokens / SMTP + passwords / Drive service-account keys at rest. + properties: + artifactPath: + description: |- + ArtifactPath is the file path within the team's output workspace + (typically /workspace/output) to attach to or send as the + delivery body. Optional for delivery types that carry their + message inline (e.g. a plain Slack notification with no file). + type: string + attachmentPath: + description: |- + AttachmentPath is a file path within the team's output workspace + to attach to the email delivery. Equivalent to ArtifactPath but + kept distinct because some emails attach + reference a separate + artifact in the body. + type: string + channel: + description: |- + Channel is the destination for the slack delivery type + (e.g. "#reports"). The slack sender reads + CredentialsSecret["slack-webhook-url"] to know where to post. + type: string + credentialsSecret: + description: |- + CredentialsSecret names a Secret in the team's namespace carrying + authentication for this target. Expected keys per type: + + - slack: "slack-webhook-url" — full https://hooks.slack.com/... URL + - email: "smtp-host", "smtp-port", "smtp-username", "smtp-password" + - google-drive: "service-account.json" + + Not required for webhook; the URL is in the spec. + type: string + folder: + description: |- + Folder is the destination folder for the google-drive delivery + type. + type: string + message: + description: |- + Message is the human-readable text that accompanies the delivery + (Slack message text, webhook notes, email body lead-in). + type: string + subject: + description: Subject is the message subject for the email + delivery type. + type: string + to: + description: To is the recipient list for the email delivery + type. + items: + type: string + type: array + type: + description: Type names the delivery backend. + enum: + - webhook + - slack + - email + - google-drive + type: string + url: + description: URL is the destination for the webhook delivery + type. + type: string + required: + - type + type: object + type: array gitCredentialsSecret: description: |- GitCredentialsSecret names a Secret in the team's namespace carrying git @@ -274,6 +362,7 @@ spec: - create-pr - push-branch - notify + - deliver - none type: string prTitleTemplate: @@ -509,6 +598,43 @@ spec: Populated once the push-branch Job succeeds; OnComplete=create-pr reads this as the PR head branch when set, in place of Spec.Repository.Branch. type: string + delivery: + description: |- + Delivery records the outcome of every DeliveryTarget dispatched + by OnComplete=deliver. Populated once executeOnComplete has run; + each entry is independent — partial success is normal and the + team is not rolled back when individual targets fail. + items: + description: DeliveryStatus records the result of a single DeliveryTarget + dispatch. + properties: + deliveredAt: + description: |- + DeliveredAt is when the sender finished — whether successfully + or not. + format: date-time + type: string + error: + description: Error carries the sender's failure message when + Success is false. + type: string + success: + description: Success is true iff the sender returned no error. + type: boolean + target: + description: |- + Target is a short human-readable label describing where this + delivery went (e.g. "#reports" for slack, the URL for webhook). + type: string + type: + description: Type mirrors the originating DeliveryTarget.Type. + type: string + required: + - deliveredAt + - success + - type + type: object + type: array estimatedCost: description: EstimatedCost is the estimated API cost in USD (e.g. "4.50"). diff --git a/charts/kagents/crds/kagents.dev_agentteams.yaml b/charts/kagents/crds/kagents.dev_agentteams.yaml index 8d4a2e7..5d3f1bd 100644 --- a/charts/kagents/crds/kagents.dev_agentteams.yaml +++ b/charts/kagents/crds/kagents.dev_agentteams.yaml @@ -298,6 +298,94 @@ spec: branch name pushed by OnComplete=push-branch. Available variables: .TeamName, .Namespace. When empty, defaults to "teams/{{.TeamName}}". type: string + delivery: + description: |- + Delivery is the list of artifact delivery targets fired when + OnComplete=deliver. Each target is dispatched independently; + per-target success/failure is recorded in status.delivery[]. + Delivery failure is best-effort — the team is not rolled back to + Failed if a target rejects the request. + items: + description: |- + DeliveryTarget describes one artifact delivery destination fired when + OnComplete=deliver. The Type discriminator selects which fields are + meaningful — webhook + slack are functional in v0.8.0; email and + google-drive are accepted at the API level and dispatched to senders + that currently return a "not implemented" error recorded in + status.delivery[]. + + Across all types the operator never persists credentials itself; the + sender pulls them from CredentialsSecret at dispatch time so a + compromised operator pod can't enumerate Slack tokens / SMTP + passwords / Drive service-account keys at rest. + properties: + artifactPath: + description: |- + ArtifactPath is the file path within the team's output workspace + (typically /workspace/output) to attach to or send as the + delivery body. Optional for delivery types that carry their + message inline (e.g. a plain Slack notification with no file). + type: string + attachmentPath: + description: |- + AttachmentPath is a file path within the team's output workspace + to attach to the email delivery. Equivalent to ArtifactPath but + kept distinct because some emails attach + reference a separate + artifact in the body. + type: string + channel: + description: |- + Channel is the destination for the slack delivery type + (e.g. "#reports"). The slack sender reads + CredentialsSecret["slack-webhook-url"] to know where to post. + type: string + credentialsSecret: + description: |- + CredentialsSecret names a Secret in the team's namespace carrying + authentication for this target. Expected keys per type: + + - slack: "slack-webhook-url" — full https://hooks.slack.com/... URL + - email: "smtp-host", "smtp-port", "smtp-username", "smtp-password" + - google-drive: "service-account.json" + + Not required for webhook; the URL is in the spec. + type: string + folder: + description: |- + Folder is the destination folder for the google-drive delivery + type. + type: string + message: + description: |- + Message is the human-readable text that accompanies the delivery + (Slack message text, webhook notes, email body lead-in). + type: string + subject: + description: Subject is the message subject for the email + delivery type. + type: string + to: + description: To is the recipient list for the email delivery + type. + items: + type: string + type: array + type: + description: Type names the delivery backend. + enum: + - webhook + - slack + - email + - google-drive + type: string + url: + description: URL is the destination for the webhook delivery + type. + type: string + required: + - type + type: object + type: array gitCredentialsSecret: description: |- GitCredentialsSecret names a Secret in the team's namespace carrying git @@ -331,6 +419,7 @@ spec: - create-pr - push-branch - notify + - deliver - none type: string prTitleTemplate: @@ -919,6 +1008,43 @@ spec: Populated once the push-branch Job succeeds; OnComplete=create-pr reads this as the PR head branch when set, in place of Spec.Repository.Branch. type: string + delivery: + description: |- + Delivery records the outcome of every DeliveryTarget dispatched + by OnComplete=deliver. Populated once executeOnComplete has run; + each entry is independent — partial success is normal and the + team is not rolled back when individual targets fail. + items: + description: DeliveryStatus records the result of a single DeliveryTarget + dispatch. + properties: + deliveredAt: + description: |- + DeliveredAt is when the sender finished — whether successfully + or not. + format: date-time + type: string + error: + description: Error carries the sender's failure message when + Success is false. + type: string + success: + description: Success is true iff the sender returned no error. + type: boolean + target: + description: |- + Target is a short human-readable label describing where this + delivery went (e.g. "#reports" for slack, the URL for webhook). + type: string + type: + description: Type mirrors the originating DeliveryTarget.Type. + type: string + required: + - deliveredAt + - success + - type + type: object + type: array estimatedCost: description: EstimatedCost is the estimated API cost in USD (e.g. "4.50"). diff --git a/charts/kagents/crds/kagents.dev_agentteamschedules.yaml b/charts/kagents/crds/kagents.dev_agentteamschedules.yaml index e23cba7..2ca45e8 100644 --- a/charts/kagents/crds/kagents.dev_agentteamschedules.yaml +++ b/charts/kagents/crds/kagents.dev_agentteamschedules.yaml @@ -260,6 +260,94 @@ spec: branch name pushed by OnComplete=push-branch. Available variables: .TeamName, .Namespace. When empty, defaults to "teams/{{.TeamName}}". type: string + delivery: + description: |- + Delivery is the list of artifact delivery targets fired when + OnComplete=deliver. Each target is dispatched independently; + per-target success/failure is recorded in status.delivery[]. + Delivery failure is best-effort — the team is not rolled back to + Failed if a target rejects the request. + items: + description: |- + DeliveryTarget describes one artifact delivery destination fired when + OnComplete=deliver. The Type discriminator selects which fields are + meaningful — webhook + slack are functional in v0.8.0; email and + google-drive are accepted at the API level and dispatched to senders + that currently return a "not implemented" error recorded in + status.delivery[]. + + Across all types the operator never persists credentials itself; the + sender pulls them from CredentialsSecret at dispatch time so a + compromised operator pod can't enumerate Slack tokens / SMTP + passwords / Drive service-account keys at rest. + properties: + artifactPath: + description: |- + ArtifactPath is the file path within the team's output workspace + (typically /workspace/output) to attach to or send as the + delivery body. Optional for delivery types that carry their + message inline (e.g. a plain Slack notification with no file). + type: string + attachmentPath: + description: |- + AttachmentPath is a file path within the team's output workspace + to attach to the email delivery. Equivalent to ArtifactPath but + kept distinct because some emails attach + reference a separate + artifact in the body. + type: string + channel: + description: |- + Channel is the destination for the slack delivery type + (e.g. "#reports"). The slack sender reads + CredentialsSecret["slack-webhook-url"] to know where to post. + type: string + credentialsSecret: + description: |- + CredentialsSecret names a Secret in the team's namespace carrying + authentication for this target. Expected keys per type: + + - slack: "slack-webhook-url" — full https://hooks.slack.com/... URL + - email: "smtp-host", "smtp-port", "smtp-username", "smtp-password" + - google-drive: "service-account.json" + + Not required for webhook; the URL is in the spec. + type: string + folder: + description: |- + Folder is the destination folder for the google-drive delivery + type. + type: string + message: + description: |- + Message is the human-readable text that accompanies the delivery + (Slack message text, webhook notes, email body lead-in). + type: string + subject: + description: Subject is the message subject for the email + delivery type. + type: string + to: + description: To is the recipient list for the email delivery + type. + items: + type: string + type: array + type: + description: Type names the delivery backend. + enum: + - webhook + - slack + - email + - google-drive + type: string + url: + description: URL is the destination for the webhook delivery + type. + type: string + required: + - type + type: object + type: array gitCredentialsSecret: description: |- GitCredentialsSecret names a Secret in the team's namespace carrying git @@ -293,6 +381,7 @@ spec: - create-pr - push-branch - notify + - deliver - none type: string prTitleTemplate: diff --git a/charts/kagents/crds/kagents.dev_agentteamtemplates.yaml b/charts/kagents/crds/kagents.dev_agentteamtemplates.yaml index 12beb8e..49435c8 100644 --- a/charts/kagents/crds/kagents.dev_agentteamtemplates.yaml +++ b/charts/kagents/crds/kagents.dev_agentteamtemplates.yaml @@ -134,6 +134,94 @@ spec: branch name pushed by OnComplete=push-branch. Available variables: .TeamName, .Namespace. When empty, defaults to "teams/{{.TeamName}}". type: string + delivery: + description: |- + Delivery is the list of artifact delivery targets fired when + OnComplete=deliver. Each target is dispatched independently; + per-target success/failure is recorded in status.delivery[]. + Delivery failure is best-effort — the team is not rolled back to + Failed if a target rejects the request. + items: + description: |- + DeliveryTarget describes one artifact delivery destination fired when + OnComplete=deliver. The Type discriminator selects which fields are + meaningful — webhook + slack are functional in v0.8.0; email and + google-drive are accepted at the API level and dispatched to senders + that currently return a "not implemented" error recorded in + status.delivery[]. + + Across all types the operator never persists credentials itself; the + sender pulls them from CredentialsSecret at dispatch time so a + compromised operator pod can't enumerate Slack tokens / SMTP + passwords / Drive service-account keys at rest. + properties: + artifactPath: + description: |- + ArtifactPath is the file path within the team's output workspace + (typically /workspace/output) to attach to or send as the + delivery body. Optional for delivery types that carry their + message inline (e.g. a plain Slack notification with no file). + type: string + attachmentPath: + description: |- + AttachmentPath is a file path within the team's output workspace + to attach to the email delivery. Equivalent to ArtifactPath but + kept distinct because some emails attach + reference a separate + artifact in the body. + type: string + channel: + description: |- + Channel is the destination for the slack delivery type + (e.g. "#reports"). The slack sender reads + CredentialsSecret["slack-webhook-url"] to know where to post. + type: string + credentialsSecret: + description: |- + CredentialsSecret names a Secret in the team's namespace carrying + authentication for this target. Expected keys per type: + + - slack: "slack-webhook-url" — full https://hooks.slack.com/... URL + - email: "smtp-host", "smtp-port", "smtp-username", "smtp-password" + - google-drive: "service-account.json" + + Not required for webhook; the URL is in the spec. + type: string + folder: + description: |- + Folder is the destination folder for the google-drive delivery + type. + type: string + message: + description: |- + Message is the human-readable text that accompanies the delivery + (Slack message text, webhook notes, email body lead-in). + type: string + subject: + description: Subject is the message subject for the email + delivery type. + type: string + to: + description: To is the recipient list for the email delivery + type. + items: + type: string + type: array + type: + description: Type names the delivery backend. + enum: + - webhook + - slack + - email + - google-drive + type: string + url: + description: URL is the destination for the webhook delivery + type. + type: string + required: + - type + type: object + type: array gitCredentialsSecret: description: |- GitCredentialsSecret names a Secret in the team's namespace carrying git @@ -167,6 +255,7 @@ spec: - create-pr - push-branch - notify + - deliver - none type: string prTitleTemplate: diff --git a/charts/kagents/crds/kagents.dev_agentteamtriggers.yaml b/charts/kagents/crds/kagents.dev_agentteamtriggers.yaml index 3834628..4cc54d1 100644 --- a/charts/kagents/crds/kagents.dev_agentteamtriggers.yaml +++ b/charts/kagents/crds/kagents.dev_agentteamtriggers.yaml @@ -271,6 +271,94 @@ spec: branch name pushed by OnComplete=push-branch. Available variables: .TeamName, .Namespace. When empty, defaults to "teams/{{.TeamName}}". type: string + delivery: + description: |- + Delivery is the list of artifact delivery targets fired when + OnComplete=deliver. Each target is dispatched independently; + per-target success/failure is recorded in status.delivery[]. + Delivery failure is best-effort — the team is not rolled back to + Failed if a target rejects the request. + items: + description: |- + DeliveryTarget describes one artifact delivery destination fired when + OnComplete=deliver. The Type discriminator selects which fields are + meaningful — webhook + slack are functional in v0.8.0; email and + google-drive are accepted at the API level and dispatched to senders + that currently return a "not implemented" error recorded in + status.delivery[]. + + Across all types the operator never persists credentials itself; the + sender pulls them from CredentialsSecret at dispatch time so a + compromised operator pod can't enumerate Slack tokens / SMTP + passwords / Drive service-account keys at rest. + properties: + artifactPath: + description: |- + ArtifactPath is the file path within the team's output workspace + (typically /workspace/output) to attach to or send as the + delivery body. Optional for delivery types that carry their + message inline (e.g. a plain Slack notification with no file). + type: string + attachmentPath: + description: |- + AttachmentPath is a file path within the team's output workspace + to attach to the email delivery. Equivalent to ArtifactPath but + kept distinct because some emails attach + reference a separate + artifact in the body. + type: string + channel: + description: |- + Channel is the destination for the slack delivery type + (e.g. "#reports"). The slack sender reads + CredentialsSecret["slack-webhook-url"] to know where to post. + type: string + credentialsSecret: + description: |- + CredentialsSecret names a Secret in the team's namespace carrying + authentication for this target. Expected keys per type: + + - slack: "slack-webhook-url" — full https://hooks.slack.com/... URL + - email: "smtp-host", "smtp-port", "smtp-username", "smtp-password" + - google-drive: "service-account.json" + + Not required for webhook; the URL is in the spec. + type: string + folder: + description: |- + Folder is the destination folder for the google-drive delivery + type. + type: string + message: + description: |- + Message is the human-readable text that accompanies the delivery + (Slack message text, webhook notes, email body lead-in). + type: string + subject: + description: Subject is the message subject for the email + delivery type. + type: string + to: + description: To is the recipient list for the email delivery + type. + items: + type: string + type: array + type: + description: Type names the delivery backend. + enum: + - webhook + - slack + - email + - google-drive + type: string + url: + description: URL is the destination for the webhook delivery + type. + type: string + required: + - type + type: object + type: array gitCredentialsSecret: description: |- GitCredentialsSecret names a Secret in the team's namespace carrying git @@ -304,6 +392,7 @@ spec: - create-pr - push-branch - notify + - deliver - none type: string prTitleTemplate: diff --git a/cmd/manager/main.go b/cmd/manager/main.go index b161664..fecaebf 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -16,6 +16,7 @@ import ( claudev1alpha1 "github.com/amcheste/kagents/api/v1alpha1" "github.com/amcheste/kagents/internal/controller" + "github.com/amcheste/kagents/internal/delivery" "github.com/amcheste/kagents/internal/harness" "github.com/amcheste/kagents/internal/metrics" ) @@ -76,6 +77,7 @@ func main() { InitImage: initImage, SkipInitScript: skipInitScript, Harnesses: harness.DefaultRegistry(), + Delivery: delivery.NewDispatcher(), } if agentCommand != "" { reconciler.AgentCommand = strings.Split(agentCommand, ",") diff --git a/config/crd/bases/kagents.dev_agentteamruns.yaml b/config/crd/bases/kagents.dev_agentteamruns.yaml index addf5a5..e48869e 100644 --- a/config/crd/bases/kagents.dev_agentteamruns.yaml +++ b/config/crd/bases/kagents.dev_agentteamruns.yaml @@ -241,6 +241,94 @@ spec: branch name pushed by OnComplete=push-branch. Available variables: .TeamName, .Namespace. When empty, defaults to "teams/{{.TeamName}}". type: string + delivery: + description: |- + Delivery is the list of artifact delivery targets fired when + OnComplete=deliver. Each target is dispatched independently; + per-target success/failure is recorded in status.delivery[]. + Delivery failure is best-effort — the team is not rolled back to + Failed if a target rejects the request. + items: + description: |- + DeliveryTarget describes one artifact delivery destination fired when + OnComplete=deliver. The Type discriminator selects which fields are + meaningful — webhook + slack are functional in v0.8.0; email and + google-drive are accepted at the API level and dispatched to senders + that currently return a "not implemented" error recorded in + status.delivery[]. + + Across all types the operator never persists credentials itself; the + sender pulls them from CredentialsSecret at dispatch time so a + compromised operator pod can't enumerate Slack tokens / SMTP + passwords / Drive service-account keys at rest. + properties: + artifactPath: + description: |- + ArtifactPath is the file path within the team's output workspace + (typically /workspace/output) to attach to or send as the + delivery body. Optional for delivery types that carry their + message inline (e.g. a plain Slack notification with no file). + type: string + attachmentPath: + description: |- + AttachmentPath is a file path within the team's output workspace + to attach to the email delivery. Equivalent to ArtifactPath but + kept distinct because some emails attach + reference a separate + artifact in the body. + type: string + channel: + description: |- + Channel is the destination for the slack delivery type + (e.g. "#reports"). The slack sender reads + CredentialsSecret["slack-webhook-url"] to know where to post. + type: string + credentialsSecret: + description: |- + CredentialsSecret names a Secret in the team's namespace carrying + authentication for this target. Expected keys per type: + + - slack: "slack-webhook-url" — full https://hooks.slack.com/... URL + - email: "smtp-host", "smtp-port", "smtp-username", "smtp-password" + - google-drive: "service-account.json" + + Not required for webhook; the URL is in the spec. + type: string + folder: + description: |- + Folder is the destination folder for the google-drive delivery + type. + type: string + message: + description: |- + Message is the human-readable text that accompanies the delivery + (Slack message text, webhook notes, email body lead-in). + type: string + subject: + description: Subject is the message subject for the email + delivery type. + type: string + to: + description: To is the recipient list for the email delivery + type. + items: + type: string + type: array + type: + description: Type names the delivery backend. + enum: + - webhook + - slack + - email + - google-drive + type: string + url: + description: URL is the destination for the webhook delivery + type. + type: string + required: + - type + type: object + type: array gitCredentialsSecret: description: |- GitCredentialsSecret names a Secret in the team's namespace carrying git @@ -274,6 +362,7 @@ spec: - create-pr - push-branch - notify + - deliver - none type: string prTitleTemplate: @@ -509,6 +598,43 @@ spec: Populated once the push-branch Job succeeds; OnComplete=create-pr reads this as the PR head branch when set, in place of Spec.Repository.Branch. type: string + delivery: + description: |- + Delivery records the outcome of every DeliveryTarget dispatched + by OnComplete=deliver. Populated once executeOnComplete has run; + each entry is independent — partial success is normal and the + team is not rolled back when individual targets fail. + items: + description: DeliveryStatus records the result of a single DeliveryTarget + dispatch. + properties: + deliveredAt: + description: |- + DeliveredAt is when the sender finished — whether successfully + or not. + format: date-time + type: string + error: + description: Error carries the sender's failure message when + Success is false. + type: string + success: + description: Success is true iff the sender returned no error. + type: boolean + target: + description: |- + Target is a short human-readable label describing where this + delivery went (e.g. "#reports" for slack, the URL for webhook). + type: string + type: + description: Type mirrors the originating DeliveryTarget.Type. + type: string + required: + - deliveredAt + - success + - type + type: object + type: array estimatedCost: description: EstimatedCost is the estimated API cost in USD (e.g. "4.50"). diff --git a/config/crd/bases/kagents.dev_agentteams.yaml b/config/crd/bases/kagents.dev_agentteams.yaml index 8d4a2e7..5d3f1bd 100644 --- a/config/crd/bases/kagents.dev_agentteams.yaml +++ b/config/crd/bases/kagents.dev_agentteams.yaml @@ -298,6 +298,94 @@ spec: branch name pushed by OnComplete=push-branch. Available variables: .TeamName, .Namespace. When empty, defaults to "teams/{{.TeamName}}". type: string + delivery: + description: |- + Delivery is the list of artifact delivery targets fired when + OnComplete=deliver. Each target is dispatched independently; + per-target success/failure is recorded in status.delivery[]. + Delivery failure is best-effort — the team is not rolled back to + Failed if a target rejects the request. + items: + description: |- + DeliveryTarget describes one artifact delivery destination fired when + OnComplete=deliver. The Type discriminator selects which fields are + meaningful — webhook + slack are functional in v0.8.0; email and + google-drive are accepted at the API level and dispatched to senders + that currently return a "not implemented" error recorded in + status.delivery[]. + + Across all types the operator never persists credentials itself; the + sender pulls them from CredentialsSecret at dispatch time so a + compromised operator pod can't enumerate Slack tokens / SMTP + passwords / Drive service-account keys at rest. + properties: + artifactPath: + description: |- + ArtifactPath is the file path within the team's output workspace + (typically /workspace/output) to attach to or send as the + delivery body. Optional for delivery types that carry their + message inline (e.g. a plain Slack notification with no file). + type: string + attachmentPath: + description: |- + AttachmentPath is a file path within the team's output workspace + to attach to the email delivery. Equivalent to ArtifactPath but + kept distinct because some emails attach + reference a separate + artifact in the body. + type: string + channel: + description: |- + Channel is the destination for the slack delivery type + (e.g. "#reports"). The slack sender reads + CredentialsSecret["slack-webhook-url"] to know where to post. + type: string + credentialsSecret: + description: |- + CredentialsSecret names a Secret in the team's namespace carrying + authentication for this target. Expected keys per type: + + - slack: "slack-webhook-url" — full https://hooks.slack.com/... URL + - email: "smtp-host", "smtp-port", "smtp-username", "smtp-password" + - google-drive: "service-account.json" + + Not required for webhook; the URL is in the spec. + type: string + folder: + description: |- + Folder is the destination folder for the google-drive delivery + type. + type: string + message: + description: |- + Message is the human-readable text that accompanies the delivery + (Slack message text, webhook notes, email body lead-in). + type: string + subject: + description: Subject is the message subject for the email + delivery type. + type: string + to: + description: To is the recipient list for the email delivery + type. + items: + type: string + type: array + type: + description: Type names the delivery backend. + enum: + - webhook + - slack + - email + - google-drive + type: string + url: + description: URL is the destination for the webhook delivery + type. + type: string + required: + - type + type: object + type: array gitCredentialsSecret: description: |- GitCredentialsSecret names a Secret in the team's namespace carrying git @@ -331,6 +419,7 @@ spec: - create-pr - push-branch - notify + - deliver - none type: string prTitleTemplate: @@ -919,6 +1008,43 @@ spec: Populated once the push-branch Job succeeds; OnComplete=create-pr reads this as the PR head branch when set, in place of Spec.Repository.Branch. type: string + delivery: + description: |- + Delivery records the outcome of every DeliveryTarget dispatched + by OnComplete=deliver. Populated once executeOnComplete has run; + each entry is independent — partial success is normal and the + team is not rolled back when individual targets fail. + items: + description: DeliveryStatus records the result of a single DeliveryTarget + dispatch. + properties: + deliveredAt: + description: |- + DeliveredAt is when the sender finished — whether successfully + or not. + format: date-time + type: string + error: + description: Error carries the sender's failure message when + Success is false. + type: string + success: + description: Success is true iff the sender returned no error. + type: boolean + target: + description: |- + Target is a short human-readable label describing where this + delivery went (e.g. "#reports" for slack, the URL for webhook). + type: string + type: + description: Type mirrors the originating DeliveryTarget.Type. + type: string + required: + - deliveredAt + - success + - type + type: object + type: array estimatedCost: description: EstimatedCost is the estimated API cost in USD (e.g. "4.50"). diff --git a/config/crd/bases/kagents.dev_agentteamschedules.yaml b/config/crd/bases/kagents.dev_agentteamschedules.yaml index e23cba7..2ca45e8 100644 --- a/config/crd/bases/kagents.dev_agentteamschedules.yaml +++ b/config/crd/bases/kagents.dev_agentteamschedules.yaml @@ -260,6 +260,94 @@ spec: branch name pushed by OnComplete=push-branch. Available variables: .TeamName, .Namespace. When empty, defaults to "teams/{{.TeamName}}". type: string + delivery: + description: |- + Delivery is the list of artifact delivery targets fired when + OnComplete=deliver. Each target is dispatched independently; + per-target success/failure is recorded in status.delivery[]. + Delivery failure is best-effort — the team is not rolled back to + Failed if a target rejects the request. + items: + description: |- + DeliveryTarget describes one artifact delivery destination fired when + OnComplete=deliver. The Type discriminator selects which fields are + meaningful — webhook + slack are functional in v0.8.0; email and + google-drive are accepted at the API level and dispatched to senders + that currently return a "not implemented" error recorded in + status.delivery[]. + + Across all types the operator never persists credentials itself; the + sender pulls them from CredentialsSecret at dispatch time so a + compromised operator pod can't enumerate Slack tokens / SMTP + passwords / Drive service-account keys at rest. + properties: + artifactPath: + description: |- + ArtifactPath is the file path within the team's output workspace + (typically /workspace/output) to attach to or send as the + delivery body. Optional for delivery types that carry their + message inline (e.g. a plain Slack notification with no file). + type: string + attachmentPath: + description: |- + AttachmentPath is a file path within the team's output workspace + to attach to the email delivery. Equivalent to ArtifactPath but + kept distinct because some emails attach + reference a separate + artifact in the body. + type: string + channel: + description: |- + Channel is the destination for the slack delivery type + (e.g. "#reports"). The slack sender reads + CredentialsSecret["slack-webhook-url"] to know where to post. + type: string + credentialsSecret: + description: |- + CredentialsSecret names a Secret in the team's namespace carrying + authentication for this target. Expected keys per type: + + - slack: "slack-webhook-url" — full https://hooks.slack.com/... URL + - email: "smtp-host", "smtp-port", "smtp-username", "smtp-password" + - google-drive: "service-account.json" + + Not required for webhook; the URL is in the spec. + type: string + folder: + description: |- + Folder is the destination folder for the google-drive delivery + type. + type: string + message: + description: |- + Message is the human-readable text that accompanies the delivery + (Slack message text, webhook notes, email body lead-in). + type: string + subject: + description: Subject is the message subject for the email + delivery type. + type: string + to: + description: To is the recipient list for the email delivery + type. + items: + type: string + type: array + type: + description: Type names the delivery backend. + enum: + - webhook + - slack + - email + - google-drive + type: string + url: + description: URL is the destination for the webhook delivery + type. + type: string + required: + - type + type: object + type: array gitCredentialsSecret: description: |- GitCredentialsSecret names a Secret in the team's namespace carrying git @@ -293,6 +381,7 @@ spec: - create-pr - push-branch - notify + - deliver - none type: string prTitleTemplate: diff --git a/config/crd/bases/kagents.dev_agentteamtemplates.yaml b/config/crd/bases/kagents.dev_agentteamtemplates.yaml index 12beb8e..49435c8 100644 --- a/config/crd/bases/kagents.dev_agentteamtemplates.yaml +++ b/config/crd/bases/kagents.dev_agentteamtemplates.yaml @@ -134,6 +134,94 @@ spec: branch name pushed by OnComplete=push-branch. Available variables: .TeamName, .Namespace. When empty, defaults to "teams/{{.TeamName}}". type: string + delivery: + description: |- + Delivery is the list of artifact delivery targets fired when + OnComplete=deliver. Each target is dispatched independently; + per-target success/failure is recorded in status.delivery[]. + Delivery failure is best-effort — the team is not rolled back to + Failed if a target rejects the request. + items: + description: |- + DeliveryTarget describes one artifact delivery destination fired when + OnComplete=deliver. The Type discriminator selects which fields are + meaningful — webhook + slack are functional in v0.8.0; email and + google-drive are accepted at the API level and dispatched to senders + that currently return a "not implemented" error recorded in + status.delivery[]. + + Across all types the operator never persists credentials itself; the + sender pulls them from CredentialsSecret at dispatch time so a + compromised operator pod can't enumerate Slack tokens / SMTP + passwords / Drive service-account keys at rest. + properties: + artifactPath: + description: |- + ArtifactPath is the file path within the team's output workspace + (typically /workspace/output) to attach to or send as the + delivery body. Optional for delivery types that carry their + message inline (e.g. a plain Slack notification with no file). + type: string + attachmentPath: + description: |- + AttachmentPath is a file path within the team's output workspace + to attach to the email delivery. Equivalent to ArtifactPath but + kept distinct because some emails attach + reference a separate + artifact in the body. + type: string + channel: + description: |- + Channel is the destination for the slack delivery type + (e.g. "#reports"). The slack sender reads + CredentialsSecret["slack-webhook-url"] to know where to post. + type: string + credentialsSecret: + description: |- + CredentialsSecret names a Secret in the team's namespace carrying + authentication for this target. Expected keys per type: + + - slack: "slack-webhook-url" — full https://hooks.slack.com/... URL + - email: "smtp-host", "smtp-port", "smtp-username", "smtp-password" + - google-drive: "service-account.json" + + Not required for webhook; the URL is in the spec. + type: string + folder: + description: |- + Folder is the destination folder for the google-drive delivery + type. + type: string + message: + description: |- + Message is the human-readable text that accompanies the delivery + (Slack message text, webhook notes, email body lead-in). + type: string + subject: + description: Subject is the message subject for the email + delivery type. + type: string + to: + description: To is the recipient list for the email delivery + type. + items: + type: string + type: array + type: + description: Type names the delivery backend. + enum: + - webhook + - slack + - email + - google-drive + type: string + url: + description: URL is the destination for the webhook delivery + type. + type: string + required: + - type + type: object + type: array gitCredentialsSecret: description: |- GitCredentialsSecret names a Secret in the team's namespace carrying git @@ -167,6 +255,7 @@ spec: - create-pr - push-branch - notify + - deliver - none type: string prTitleTemplate: diff --git a/config/crd/bases/kagents.dev_agentteamtriggers.yaml b/config/crd/bases/kagents.dev_agentteamtriggers.yaml index 3834628..4cc54d1 100644 --- a/config/crd/bases/kagents.dev_agentteamtriggers.yaml +++ b/config/crd/bases/kagents.dev_agentteamtriggers.yaml @@ -271,6 +271,94 @@ spec: branch name pushed by OnComplete=push-branch. Available variables: .TeamName, .Namespace. When empty, defaults to "teams/{{.TeamName}}". type: string + delivery: + description: |- + Delivery is the list of artifact delivery targets fired when + OnComplete=deliver. Each target is dispatched independently; + per-target success/failure is recorded in status.delivery[]. + Delivery failure is best-effort — the team is not rolled back to + Failed if a target rejects the request. + items: + description: |- + DeliveryTarget describes one artifact delivery destination fired when + OnComplete=deliver. The Type discriminator selects which fields are + meaningful — webhook + slack are functional in v0.8.0; email and + google-drive are accepted at the API level and dispatched to senders + that currently return a "not implemented" error recorded in + status.delivery[]. + + Across all types the operator never persists credentials itself; the + sender pulls them from CredentialsSecret at dispatch time so a + compromised operator pod can't enumerate Slack tokens / SMTP + passwords / Drive service-account keys at rest. + properties: + artifactPath: + description: |- + ArtifactPath is the file path within the team's output workspace + (typically /workspace/output) to attach to or send as the + delivery body. Optional for delivery types that carry their + message inline (e.g. a plain Slack notification with no file). + type: string + attachmentPath: + description: |- + AttachmentPath is a file path within the team's output workspace + to attach to the email delivery. Equivalent to ArtifactPath but + kept distinct because some emails attach + reference a separate + artifact in the body. + type: string + channel: + description: |- + Channel is the destination for the slack delivery type + (e.g. "#reports"). The slack sender reads + CredentialsSecret["slack-webhook-url"] to know where to post. + type: string + credentialsSecret: + description: |- + CredentialsSecret names a Secret in the team's namespace carrying + authentication for this target. Expected keys per type: + + - slack: "slack-webhook-url" — full https://hooks.slack.com/... URL + - email: "smtp-host", "smtp-port", "smtp-username", "smtp-password" + - google-drive: "service-account.json" + + Not required for webhook; the URL is in the spec. + type: string + folder: + description: |- + Folder is the destination folder for the google-drive delivery + type. + type: string + message: + description: |- + Message is the human-readable text that accompanies the delivery + (Slack message text, webhook notes, email body lead-in). + type: string + subject: + description: Subject is the message subject for the email + delivery type. + type: string + to: + description: To is the recipient list for the email delivery + type. + items: + type: string + type: array + type: + description: Type names the delivery backend. + enum: + - webhook + - slack + - email + - google-drive + type: string + url: + description: URL is the destination for the webhook delivery + type. + type: string + required: + - type + type: object + type: array gitCredentialsSecret: description: |- GitCredentialsSecret names a Secret in the team's namespace carrying git @@ -304,6 +392,7 @@ spec: - create-pr - push-branch - notify + - deliver - none type: string prTitleTemplate: diff --git a/config/samples/delivery-team.yaml b/config/samples/delivery-team.yaml new file mode 100644 index 0000000..7781340 --- /dev/null +++ b/config/samples/delivery-team.yaml @@ -0,0 +1,68 @@ +# Delivery sample — fire notifications when a team completes. +# +# When the team reaches Completed phase, the operator iterates each +# entry in spec.lifecycle.delivery and dispatches it to the matching +# sender. Per-target success/failure lands in status.delivery; one +# target failing does not roll the team back to Failed. +# +# This sample uses the two MVP backends: webhook (raw HTTP POST) and +# slack (formatted message via incoming-webhook URL stored in a Secret). +# The API also defines `email` and `google-drive` types — they parse +# successfully but their senders return "not implemented" so the team +# will record a per-target failure if either is used today. +# +# Setup before applying: +# kubectl -n cowork-agents create secret generic slack-creds \ +# --from-literal=slack-webhook-url='https://hooks.slack.com/services/T000/B000/XXX' +# +# Apply with: +# kubectl apply -n cowork-agents -f config/samples/delivery-team.yaml +apiVersion: kagents.dev/v1alpha1 +kind: AgentTeam +metadata: + name: weekly-digest + namespace: cowork-agents +spec: + workspace: + output: + mountPath: "/workspace/output" + size: "1Gi" + + auth: + apiKeySecret: "anthropic-api-key" + + lead: + model: "opus" + prompt: | + Produce a one-page weekly status digest summarizing the team's + recent activity. Save as /workspace/output/digest.md. + + teammates: + - name: "writer" + model: "sonnet" + prompt: | + Write the digest content. When done, save to + /workspace/output/digest.md. + outputs: + - path: "/workspace/output/digest.md" + description: "Weekly status digest." + + lifecycle: + onComplete: deliver + delivery: + # Webhook delivery — POSTs a metadata envelope (event, team, + # namespace, phase, message, artifacts) as application/json. The + # receiver is expected to fetch artifact files separately from + # wherever they're persisted; the operator does not stream bytes. + - type: webhook + url: "https://ops.example.com/hooks/team-completed" + message: "Weekly digest finished." + + # Slack delivery — the operator loads slack-webhook-url from the + # CredentialsSecret at dispatch time and POSTs a formatted message + # to it. Channel is optional and only meaningful for generic + # incoming-webhook integrations not already scoped to a channel. + - type: slack + channel: "#weekly-digest" + message: "Fresh digest is in. Artifact list below." + credentialsSecret: "slack-creds" diff --git a/docs/reference/api/index.md b/docs/reference/api/index.md index 9e2f849..743d5e7 100644 --- a/docs/reference/api/index.md +++ b/docs/reference/api/index.md @@ -216,6 +216,7 @@ _Appears in:_ | `consolidatedBranch` _string_ | ConsolidatedBranch is the branch name pushed by OnComplete=push-branch.
Populated once the push-branch Job succeeds; OnComplete=create-pr reads
this as the PR head branch when set, in place of Spec.Repository.Branch. | | Optional: \{\}
| | `artifacts` _[ArtifactStatus](#artifactstatus) array_ | Artifacts records the files produced by teammates that declared
Outputs in their spec. Populated as each producer teammate reaches
Completed; the operator does not retroactively scan teammate pods
for undeclared files. | | Optional: \{\}
| | `pipeline` _[PipelineStatus](#pipelinestatus)_ | Pipeline reports stage-level progress when spec.pipeline is set.
Recomputed every reconcile from teammate pod phases; cleared if
spec.pipeline is removed. | | Optional: \{\}
| +| `delivery` _[DeliveryStatus](#deliverystatus) array_ | Delivery records the outcome of every DeliveryTarget dispatched
by OnComplete=deliver. Populated once executeOnComplete has run;
each entry is independent — partial success is normal and the
team is not rolled back when individual targets fail. | | Optional: \{\}
| | `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#condition-v1-meta) array_ | Conditions represent the latest available observations. | | Optional: \{\}
| @@ -446,6 +447,61 @@ _Appears in:_ | `beads` _[BeadsSpec](#beadsspec)_ | Beads configures optional Beads integration for persistent tracking. | | Optional: \{\}
| +#### DeliveryStatus + + + +DeliveryStatus records the result of a single DeliveryTarget dispatch. + + + +_Appears in:_ +- [AgentTeamStatus](#agentteamstatus) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `type` _string_ | Type mirrors the originating DeliveryTarget.Type. | | | +| `target` _string_ | Target is a short human-readable label describing where this
delivery went (e.g. "#reports" for slack, the URL for webhook). | | Optional: \{\}
| +| `deliveredAt` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#time-v1-meta)_ | DeliveredAt is when the sender finished — whether successfully
or not. | | | +| `success` _boolean_ | Success is true iff the sender returned no error. | | | +| `error` _string_ | Error carries the sender's failure message when Success is false. | | Optional: \{\}
| + + +#### DeliveryTarget + + + +DeliveryTarget describes one artifact delivery destination fired when +OnComplete=deliver. The Type discriminator selects which fields are +meaningful — webhook + slack are functional in v0.8.0; email and +google-drive are accepted at the API level and dispatched to senders +that currently return a "not implemented" error recorded in +status.delivery[]. + +Across all types the operator never persists credentials itself; the +sender pulls them from CredentialsSecret at dispatch time so a +compromised operator pod can't enumerate Slack tokens / SMTP +passwords / Drive service-account keys at rest. + + + +_Appears in:_ +- [LifecycleSpec](#lifecyclespec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `type` _string_ | Type names the delivery backend. | | Enum: [webhook slack email google-drive]
| +| `artifactPath` _string_ | ArtifactPath is the file path within the team's output workspace
(typically /workspace/output) to attach to or send as the
delivery body. Optional for delivery types that carry their
message inline (e.g. a plain Slack notification with no file). | | Optional: \{\}
| +| `message` _string_ | Message is the human-readable text that accompanies the delivery
(Slack message text, webhook notes, email body lead-in). | | Optional: \{\}
| +| `url` _string_ | URL is the destination for the webhook delivery type. | | Optional: \{\}
| +| `channel` _string_ | Channel is the destination for the slack delivery type
(e.g. "#reports"). The slack sender reads
CredentialsSecret["slack-webhook-url"] to know where to post. | | Optional: \{\}
| +| `to` _string array_ | To is the recipient list for the email delivery type. | | Optional: \{\}
| +| `subject` _string_ | Subject is the message subject for the email delivery type. | | Optional: \{\}
| +| `attachmentPath` _string_ | AttachmentPath is a file path within the team's output workspace
to attach to the email delivery. Equivalent to ArtifactPath but
kept distinct because some emails attach + reference a separate
artifact in the body. | | Optional: \{\}
| +| `folder` _string_ | Folder is the destination folder for the google-drive delivery
type. | | Optional: \{\}
| +| `credentialsSecret` _string_ | CredentialsSecret names a Secret in the team's namespace carrying
authentication for this target. Expected keys per type:
- slack: "slack-webhook-url" — full https://hooks.slack.com/... URL
- email: "smtp-host", "smtp-port", "smtp-username", "smtp-password"
- google-drive: "service-account.json"
Not required for webhook; the URL is in the spec. | | Optional: \{\}
| + + #### InputSpec @@ -512,7 +568,8 @@ _Appears in:_ | --- | --- | --- | --- | | `timeout` _string_ | Timeout is the maximum duration the team can run (e.g. "4h", "30m"). | 4h | | | `budgetLimit` _string_ | BudgetLimit is the maximum API spend in USD before the team is terminated (e.g. "10.00"). | | Optional: \{\}
| -| `onComplete` _string_ | OnComplete determines what happens when the team finishes. | notify | Enum: [create-pr push-branch notify none]
| +| `onComplete` _string_ | OnComplete determines what happens when the team finishes. | notify | Enum: [create-pr push-branch notify deliver none]
| +| `delivery` _[DeliveryTarget](#deliverytarget) array_ | Delivery is the list of artifact delivery targets fired when
OnComplete=deliver. Each target is dispatched independently;
per-target success/failure is recorded in status.delivery[].
Delivery failure is best-effort — the team is not rolled back to
Failed if a target rejects the request. | | Optional: \{\}
| | `pullRequest` _[PullRequestSpec](#pullrequestspec)_ | PullRequest configures PR creation when onComplete is "create-pr". | | Optional: \{\}
| | `approvalGates` _[ApprovalGateSpec](#approvalgatespec) array_ | ApprovalGates pause execution before specified events until human approval is recorded.
Grant approval by annotating the AgentTeam: kubectl annotate agentteam approved.kagents.dev/=true | | Optional: \{\}
| | `maxRestarts` _integer_ | MaxRestarts bounds how many times each teammate pod may be re-spawned
after a Failed phase before the team itself is marked Failed. The lead
pod is not subject to this limit; a lead crash always fails the team. | 3 | Minimum: 0
Optional: \{\}
| diff --git a/internal/controller/agentteam_controller.go b/internal/controller/agentteam_controller.go index dbcb0f2..6d353c1 100644 --- a/internal/controller/agentteam_controller.go +++ b/internal/controller/agentteam_controller.go @@ -26,6 +26,7 @@ import ( claudev1alpha1 "github.com/amcheste/kagents/api/v1alpha1" "github.com/amcheste/kagents/internal/budget" + "github.com/amcheste/kagents/internal/delivery" "github.com/amcheste/kagents/internal/github" "github.com/amcheste/kagents/internal/harness" "github.com/amcheste/kagents/internal/metrics" @@ -80,6 +81,23 @@ type AgentTeamReconciler struct { // Unit tests that construct a reconciler directly may leave this nil; // harnessFor falls back to a built-in claude-code adapter in that case. Harnesses map[string]harness.Harness + + // Delivery dispatches DeliveryTarget instances when OnComplete=deliver. + // Tests inject a Dispatcher with stub senders; production wires + // delivery.NewDispatcher() in cmd/manager. May be nil — a default + // production dispatcher is constructed on demand. + Delivery *delivery.Dispatcher +} + +// deliveryDispatcher returns the configured Delivery dispatcher or a +// freshly-built default. Centralizing the fallback keeps unit tests that +// don't populate Delivery working and gives misconfigured reconcilers a +// safe default rather than a nil-deref. +func (r *AgentTeamReconciler) deliveryDispatcher() *delivery.Dispatcher { + if r.Delivery != nil { + return r.Delivery + } + return delivery.NewDispatcher() } // harnessFor returns the [harness.Harness] adapter that should drive the @@ -1607,10 +1625,49 @@ func (r *AgentTeamReconciler) executeOnComplete(ctx context.Context, team *claud } else { log.Info("push-branch: consolidated branch pushed", "branch", team.Status.ConsolidatedBranch) } + case "deliver": + r.executeDelivery(ctx, team) } return nil } +// executeDelivery dispatches each DeliveryTarget in spec.lifecycle.delivery +// to its registered sender, recording a DeliveryStatus entry per target +// on team.Status.Delivery regardless of outcome. Failures are surfaced +// as events + status but never return as errors — the design's stance +// is that delivery is best-effort and a partial delivery (e.g. Slack +// posted, email broke) shouldn't fail the team retroactively. +// +// Idempotent at the executeOnComplete level: the caller only invokes +// this once per team's lifecycle (when transitioning to a terminal +// phase). The function itself doesn't guard against re-invocation — +// the reconciler's transition logic is the gate. +func (r *AgentTeamReconciler) executeDelivery(ctx context.Context, team *claudev1alpha1.AgentTeam) { + if team.Spec.Lifecycle == nil || len(team.Spec.Lifecycle.Delivery) == 0 { + return + } + dispatcher := r.deliveryDispatcher() + now := time.Now() + for _, target := range team.Spec.Lifecycle.Delivery { + status := claudev1alpha1.DeliveryStatus{ + Type: target.Type, + Target: delivery.TargetLabel(target), + DeliveredAt: metav1.NewTime(now), + Success: true, + } + if err := dispatcher.Send(ctx, r.Client, target, team); err != nil { + status.Success = false + status.Error = err.Error() + r.recordEvent(team, corev1.EventTypeWarning, "DeliveryFailed", + "Delivery %s to %s failed: %v", target.Type, status.Target, err) + } else { + r.recordEvent(team, corev1.EventTypeNormal, "DeliveryComplete", + "Delivery %s to %s succeeded", target.Type, status.Target) + } + team.Status.Delivery = append(team.Status.Delivery, status) + } +} + // --- Pull Request Creation --- const ( diff --git a/internal/controller/agentteam_delivery_test.go b/internal/controller/agentteam_delivery_test.go new file mode 100644 index 0000000..c925f6f --- /dev/null +++ b/internal/controller/agentteam_delivery_test.go @@ -0,0 +1,144 @@ +package controller + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + claudev1alpha1 "github.com/amcheste/kagents/api/v1alpha1" + "github.com/amcheste/kagents/internal/delivery" +) + +// deliverySchemeForTests is a local scheme so this file's tests stay +// independent of helpers in the larger test suite. +func deliverySchemeForTests(t *testing.T) *runtime.Scheme { + t.Helper() + s := runtime.NewScheme() + require.NoError(t, clientgoscheme.AddToScheme(s)) + require.NoError(t, claudev1alpha1.AddToScheme(s)) + return s +} + +// TestExecuteDelivery_RecordsStatusPerTarget exercises the full +// reconciler-side delivery path with a stub dispatcher. The point is +// to prove the operator records exactly one DeliveryStatus per target, +// captures the per-type label, and surfaces success/error correctly — +// without coupling the test to the real HTTP senders (those are +// covered in the delivery package's own tests). +func TestExecuteDelivery_RecordsStatusPerTarget(t *testing.T) { + t.Parallel() + scheme := deliverySchemeForTests(t) + team := &claudev1alpha1.AgentTeam{ + ObjectMeta: metav1.ObjectMeta{Name: "alpha", Namespace: "ns"}, + Spec: claudev1alpha1.AgentTeamSpec{ + Lifecycle: &claudev1alpha1.LifecycleSpec{ + OnComplete: "deliver", + Delivery: []claudev1alpha1.DeliveryTarget{ + {Type: "webhook", URL: "https://ops.example.com/hook"}, + {Type: "slack", Channel: "#reports", CredentialsSecret: "creds"}, + }, + }, + }, + Status: claudev1alpha1.AgentTeamStatus{Phase: "Completed"}, + } + + d := delivery.NewDispatcher() + d.SetSender("webhook", delivery.SenderFunc(func(_ context.Context, _ client.Client, _ claudev1alpha1.DeliveryTarget, _ *claudev1alpha1.AgentTeam) error { + return nil + })) + d.SetSender("slack", delivery.SenderFunc(func(_ context.Context, _ client.Client, _ claudev1alpha1.DeliveryTarget, _ *claudev1alpha1.AgentTeam) error { + return errors.New("boom") + })) + + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(team).Build() + r := &AgentTeamReconciler{Client: c, Scheme: scheme, Delivery: d} + + r.executeDelivery(context.Background(), team) + + require.Len(t, team.Status.Delivery, 2) + assert.Equal(t, "webhook", team.Status.Delivery[0].Type) + assert.Equal(t, "https://ops.example.com/hook", team.Status.Delivery[0].Target) + assert.True(t, team.Status.Delivery[0].Success) + assert.Empty(t, team.Status.Delivery[0].Error) + assert.False(t, team.Status.Delivery[0].DeliveredAt.IsZero()) + + assert.Equal(t, "slack", team.Status.Delivery[1].Type) + assert.Equal(t, "#reports", team.Status.Delivery[1].Target) + assert.False(t, team.Status.Delivery[1].Success) + assert.Contains(t, team.Status.Delivery[1].Error, "boom") +} + +// TestExecuteDelivery_NoOpOnEmpty verifies the function returns +// cleanly when no targets are configured — the OnComplete=deliver enum +// is allowed without targets (e.g. user-typed CR with empty array) +// and we don't want a stray panic or status mutation in that case. +func TestExecuteDelivery_NoOpOnEmpty(t *testing.T) { + t.Parallel() + scheme := deliverySchemeForTests(t) + team := &claudev1alpha1.AgentTeam{ + ObjectMeta: metav1.ObjectMeta{Name: "alpha", Namespace: "ns"}, + Spec: claudev1alpha1.AgentTeamSpec{ + Lifecycle: &claudev1alpha1.LifecycleSpec{OnComplete: "deliver"}, + }, + } + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(team).Build() + r := &AgentTeamReconciler{Client: c, Scheme: scheme} + r.executeDelivery(context.Background(), team) + assert.Empty(t, team.Status.Delivery) +} + +// TestExecuteDelivery_NilLifecycle defends against a programmer error +// — the OnComplete branch in executeOnComplete is unreachable if +// Lifecycle is nil, but we still want belt-and-suspenders here so a +// future refactor that exposes executeDelivery from elsewhere doesn't +// silently crash. +func TestExecuteDelivery_NilLifecycle(t *testing.T) { + t.Parallel() + scheme := deliverySchemeForTests(t) + team := &claudev1alpha1.AgentTeam{ObjectMeta: metav1.ObjectMeta{Name: "alpha", Namespace: "ns"}} + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(team).Build() + r := &AgentTeamReconciler{Client: c, Scheme: scheme} + r.executeDelivery(context.Background(), team) + assert.Empty(t, team.Status.Delivery) +} + +// TestExecuteOnComplete_DispatchesDeliver wires the higher-level entry +// point — executeOnComplete with OnComplete="deliver" — to confirm the +// switch case routes to executeDelivery. This is the only test that +// crosses the case boundary; everything else exercises executeDelivery +// directly so failure messages stay focused. +func TestExecuteOnComplete_DispatchesDeliver(t *testing.T) { + t.Parallel() + scheme := deliverySchemeForTests(t) + team := &claudev1alpha1.AgentTeam{ + ObjectMeta: metav1.ObjectMeta{Name: "alpha", Namespace: "ns"}, + Spec: claudev1alpha1.AgentTeamSpec{ + Lifecycle: &claudev1alpha1.LifecycleSpec{ + OnComplete: "deliver", + Delivery: []claudev1alpha1.DeliveryTarget{ + {Type: "webhook", URL: "https://x"}, + }, + }, + }, + } + d := delivery.NewDispatcher() + called := false + d.SetSender("webhook", delivery.SenderFunc(func(_ context.Context, _ client.Client, _ claudev1alpha1.DeliveryTarget, _ *claudev1alpha1.AgentTeam) error { + called = true + return nil + })) + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(team).Build() + r := &AgentTeamReconciler{Client: c, Scheme: scheme, Delivery: d} + + require.NoError(t, r.executeOnComplete(context.Background(), team)) + assert.True(t, called, "executeOnComplete should route OnComplete=deliver to executeDelivery") + require.Len(t, team.Status.Delivery, 1) +} diff --git a/internal/delivery/delivery.go b/internal/delivery/delivery.go new file mode 100644 index 0000000..a47426a --- /dev/null +++ b/internal/delivery/delivery.go @@ -0,0 +1,119 @@ +// Package delivery dispatches team-completion notifications to external +// systems (webhook, Slack, email, Google Drive) when an AgentTeam ends +// with OnComplete=deliver. +// +// The senders implemented here run in-process from the reconciler — fast, +// simple HTTPS requests with metadata about the completed team. They do +// NOT stream artifact file contents: the operator pod doesn't mount the +// team's output PVC, so transferring files end-to-end would require a +// Job pattern (designed for, but deferred). The MVP delivers notifications +// referencing the team's artifacts; downstream systems are expected to +// fetch the actual files from their persisted location. +// +// New backends register in NewDispatcher() — keep the surface small +// (Send is the only interface method) so adding one is contained. +package delivery + +import ( + "context" + "errors" + "fmt" + "strings" + + "sigs.k8s.io/controller-runtime/pkg/client" + + claudev1alpha1 "github.com/amcheste/kagents/api/v1alpha1" +) + +// ErrNotImplemented is returned by senders whose backend is declared in +// the API but not yet wired. Status records it as a per-target failure +// rather than failing the whole team — this is exactly the partial- +// success path the design doc calls out. +var ErrNotImplemented = errors.New("delivery type not yet implemented") + +// Sender dispatches a single DeliveryTarget. Implementations should +// return nil on success and a descriptive error on failure; the +// reconciler records the failure in status.delivery[] and does not +// surface it as a team-level failure. +type Sender interface { + Send(ctx context.Context, c client.Client, target claudev1alpha1.DeliveryTarget, team *claudev1alpha1.AgentTeam) error +} + +// SenderFunc adapts a plain function to the Sender interface — useful +// for tests that want to inject a captured-call recorder without +// declaring a new type. +type SenderFunc func(ctx context.Context, c client.Client, target claudev1alpha1.DeliveryTarget, team *claudev1alpha1.AgentTeam) error + +// Send implements Sender. +func (f SenderFunc) Send(ctx context.Context, c client.Client, target claudev1alpha1.DeliveryTarget, team *claudev1alpha1.AgentTeam) error { + return f(ctx, c, target, team) +} + +// Dispatcher selects the right Sender for a given DeliveryTarget.Type. +// One Dispatcher is shared across the reconciler for the lifetime of +// the operator; tests construct their own with stub senders. +type Dispatcher struct { + senders map[string]Sender +} + +// NewDispatcher returns a Dispatcher wired with the production senders. +// Email and Google Drive are present at the API level but currently +// return ErrNotImplemented — operators see a per-target failure in +// status.delivery rather than a runtime panic. +func NewDispatcher() *Dispatcher { + return &Dispatcher{ + senders: map[string]Sender{ + "webhook": &WebhookSender{}, + "slack": &SlackSender{}, + "email": notImplemented("email"), + "google-drive": notImplemented("google-drive"), + }, + } +} + +// SetSender overrides the sender for a single type. Tests use this to +// inject a recorder without rebuilding the whole dispatcher. +func (d *Dispatcher) SetSender(typ string, s Sender) { + if d.senders == nil { + d.senders = make(map[string]Sender) + } + d.senders[typ] = s +} + +// Send dispatches the target to its matching Sender. Returns +// ErrNotImplemented when no sender is registered for the type, so the +// reconciler can record a clean failure rather than silently dropping. +func (d *Dispatcher) Send(ctx context.Context, c client.Client, target claudev1alpha1.DeliveryTarget, team *claudev1alpha1.AgentTeam) error { + s, ok := d.senders[target.Type] + if !ok { + return fmt.Errorf("%w: %q", ErrNotImplemented, target.Type) + } + return s.Send(ctx, c, target, team) +} + +// TargetLabel produces a short human-readable description for status +// reporting (rendered into DeliveryStatus.Target). Backend-specific: +// the URL for webhook, the channel for slack, the To list for email, +// the folder for drive. +func TargetLabel(t claudev1alpha1.DeliveryTarget) string { + switch t.Type { + case "webhook": + return t.URL + case "slack": + return t.Channel + case "email": + return strings.Join(t.To, ", ") + case "google-drive": + return t.Folder + } + return "" +} + +// notImplemented returns a Sender that always fails with ErrNotImplemented +// wrapped to include the type name. Used as the registry entry for +// declared-but-unimplemented backends. +func notImplemented(typ string) Sender { + return SenderFunc(func(ctx context.Context, c client.Client, target claudev1alpha1.DeliveryTarget, team *claudev1alpha1.AgentTeam) error { + return fmt.Errorf("%w: %s", ErrNotImplemented, typ) + }) +} diff --git a/internal/delivery/delivery_test.go b/internal/delivery/delivery_test.go new file mode 100644 index 0000000..31a1386 --- /dev/null +++ b/internal/delivery/delivery_test.go @@ -0,0 +1,247 @@ +package delivery + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + claudev1alpha1 "github.com/amcheste/kagents/api/v1alpha1" +) + +func newScheme(t *testing.T) *runtime.Scheme { + t.Helper() + s := runtime.NewScheme() + require.NoError(t, clientgoscheme.AddToScheme(s)) + require.NoError(t, claudev1alpha1.AddToScheme(s)) + return s +} + +func sampleTeam() *claudev1alpha1.AgentTeam { + return &claudev1alpha1.AgentTeam{ + ObjectMeta: metav1.ObjectMeta{Name: "alpha", Namespace: "ns"}, + Status: claudev1alpha1.AgentTeamStatus{ + Phase: "Completed", + Artifacts: []claudev1alpha1.ArtifactStatus{ + {Name: "report.pdf", ProducedBy: "writer"}, + }, + }, + } +} + +// --- Dispatcher --- + +func TestDispatcher_RoutesByType(t *testing.T) { + t.Parallel() + d := NewDispatcher() + + called := false + d.SetSender("webhook", SenderFunc(func(_ context.Context, _ client.Client, _ claudev1alpha1.DeliveryTarget, _ *claudev1alpha1.AgentTeam) error { + called = true + return nil + })) + require.NoError(t, d.Send(context.Background(), nil, + claudev1alpha1.DeliveryTarget{Type: "webhook"}, sampleTeam())) + assert.True(t, called) +} + +func TestDispatcher_UnknownTypeReturnsNotImplemented(t *testing.T) { + t.Parallel() + d := NewDispatcher() + err := d.Send(context.Background(), nil, + claudev1alpha1.DeliveryTarget{Type: "carrier-pigeon"}, sampleTeam()) + require.Error(t, err) + assert.ErrorIs(t, err, ErrNotImplemented) +} + +func TestDispatcher_EmailReturnsNotImplemented(t *testing.T) { + t.Parallel() + d := NewDispatcher() + err := d.Send(context.Background(), nil, + claudev1alpha1.DeliveryTarget{Type: "email"}, sampleTeam()) + require.Error(t, err) + assert.ErrorIs(t, err, ErrNotImplemented) +} + +func TestDispatcher_GoogleDriveReturnsNotImplemented(t *testing.T) { + t.Parallel() + d := NewDispatcher() + err := d.Send(context.Background(), nil, + claudev1alpha1.DeliveryTarget{Type: "google-drive"}, sampleTeam()) + require.Error(t, err) + assert.ErrorIs(t, err, ErrNotImplemented) +} + +func TestTargetLabel(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + in claudev1alpha1.DeliveryTarget + want string + }{ + {"webhook", claudev1alpha1.DeliveryTarget{Type: "webhook", URL: "https://x"}, "https://x"}, + {"slack", claudev1alpha1.DeliveryTarget{Type: "slack", Channel: "#reports"}, "#reports"}, + {"email", claudev1alpha1.DeliveryTarget{Type: "email", To: []string{"a@b", "c@d"}}, "a@b, c@d"}, + {"google-drive", claudev1alpha1.DeliveryTarget{Type: "google-drive", Folder: "Reports/Q3"}, "Reports/Q3"}, + {"unknown", claudev1alpha1.DeliveryTarget{Type: "?"}, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, TargetLabel(tc.in)) + }) + } +} + +// --- WebhookSender --- + +func TestWebhookSender_PostsPayload(t *testing.T) { + t.Parallel() + var capturedBody []byte + var capturedCT string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedCT = r.Header.Get("Content-Type") + capturedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + sender := &WebhookSender{} + target := claudev1alpha1.DeliveryTarget{Type: "webhook", URL: srv.URL, Message: "Q3 brief done."} + require.NoError(t, sender.Send(context.Background(), nil, target, sampleTeam())) + + assert.Equal(t, "application/json", capturedCT) + var got webhookPayload + require.NoError(t, json.Unmarshal(capturedBody, &got)) + assert.Equal(t, "team.completed", got.Event) + assert.Equal(t, "alpha", got.Team) + assert.Equal(t, "ns", got.Namespace) + assert.Equal(t, "Completed", got.Phase) + assert.Equal(t, "Q3 brief done.", got.Message) + require.Len(t, got.Artifacts, 1) + assert.Equal(t, "report.pdf", got.Artifacts[0].Name) +} + +func TestWebhookSender_Non2xxIsFailure(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + sender := &WebhookSender{} + err := sender.Send(context.Background(), nil, + claudev1alpha1.DeliveryTarget{Type: "webhook", URL: srv.URL}, sampleTeam()) + require.Error(t, err) + assert.Contains(t, err.Error(), "500") +} + +func TestWebhookSender_NoURLFails(t *testing.T) { + t.Parallel() + err := (&WebhookSender{}).Send(context.Background(), nil, + claudev1alpha1.DeliveryTarget{Type: "webhook"}, sampleTeam()) + require.Error(t, err) + assert.Contains(t, err.Error(), "url") +} + +// --- SlackSender --- + +func TestSlackSender_PostsToWebhookURLFromSecret(t *testing.T) { + t.Parallel() + var capturedBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "slack-creds", Namespace: "ns"}, + Data: map[string][]byte{"slack-webhook-url": []byte(srv.URL)}, + } + c := fake.NewClientBuilder().WithScheme(newScheme(t)).WithObjects(secret).Build() + sender := &SlackSender{} + target := claudev1alpha1.DeliveryTarget{ + Type: "slack", Channel: "#reports", Message: "hello", CredentialsSecret: "slack-creds", + } + require.NoError(t, sender.Send(context.Background(), c, target, sampleTeam())) + + var got slackPayload + require.NoError(t, json.Unmarshal(capturedBody, &got)) + assert.Equal(t, "#reports", got.Channel) + assert.Contains(t, got.Text, "hello") + assert.Contains(t, got.Text, "report.pdf", "artifact list appears in the message body") +} + +func TestSlackSender_MissingSecretFails(t *testing.T) { + t.Parallel() + c := fake.NewClientBuilder().WithScheme(newScheme(t)).Build() + err := (&SlackSender{}).Send(context.Background(), c, + claudev1alpha1.DeliveryTarget{Type: "slack", Channel: "#x"}, sampleTeam()) + require.Error(t, err) +} + +func TestSlackSender_MissingWebhookURLKeyFails(t *testing.T) { + t.Parallel() + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "bad", Namespace: "ns"}, + Data: map[string][]byte{"wrong-key": []byte("x")}, + } + c := fake.NewClientBuilder().WithScheme(newScheme(t)).WithObjects(secret).Build() + err := (&SlackSender{}).Send(context.Background(), c, + claudev1alpha1.DeliveryTarget{Type: "slack", Channel: "#x", CredentialsSecret: "bad"}, sampleTeam()) + require.Error(t, err) +} + +func TestSlackSender_Non2xxIsFailure(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "creds", Namespace: "ns"}, + Data: map[string][]byte{"slack-webhook-url": []byte(srv.URL)}, + } + c := fake.NewClientBuilder().WithScheme(newScheme(t)).WithObjects(secret).Build() + err := (&SlackSender{}).Send(context.Background(), c, + claudev1alpha1.DeliveryTarget{Type: "slack", Channel: "#x", CredentialsSecret: "creds"}, sampleTeam()) + require.Error(t, err) +} + +func TestFormatSlackMessage_DefaultWhenMessageEmpty(t *testing.T) { + t.Parallel() + team := sampleTeam() + team.Status.Artifacts = nil + msg := formatSlackMessage(claudev1alpha1.DeliveryTarget{Type: "slack"}, team) + assert.Contains(t, msg, "alpha") + assert.Contains(t, msg, "Completed") +} + +func TestFormatSlackMessage_IncludesArtifactList(t *testing.T) { + t.Parallel() + msg := formatSlackMessage(claudev1alpha1.DeliveryTarget{Message: "custom"}, sampleTeam()) + assert.Contains(t, msg, "custom") + assert.Contains(t, msg, "report.pdf") + assert.Contains(t, msg, "writer") +} + +// --- ErrNotImplemented sentinel --- + +func TestErrNotImplemented_IsCheckable(t *testing.T) { + t.Parallel() + err := (&Dispatcher{}).Send(context.Background(), nil, + claudev1alpha1.DeliveryTarget{Type: "missing"}, sampleTeam()) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrNotImplemented)) +} diff --git a/internal/delivery/slack.go b/internal/delivery/slack.go new file mode 100644 index 0000000..83b0585 --- /dev/null +++ b/internal/delivery/slack.go @@ -0,0 +1,101 @@ +package delivery + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + claudev1alpha1 "github.com/amcheste/kagents/api/v1alpha1" +) + +// SlackSender posts a formatted notification to Slack via a webhook URL +// the operator pulls from the team's CredentialsSecret at dispatch time +// (key: "slack-webhook-url"). The operator never sees the webhook URL +// at rest — it's loaded only when a delivery fires. +type SlackSender struct { + // Client is the HTTP client used. Tests inject a server-rooted + // client; production leaves it nil and a default is used. + Client *http.Client +} + +// slackPayload is the minimum Slack webhook payload shape: text + +// optional channel override. Slack ignores channel when the webhook URL +// is already channel-scoped; including it is harmless and helps when +// the workspace uses a generic incoming-webhook integration. +type slackPayload struct { + Text string `json:"text"` + Channel string `json:"channel,omitempty"` +} + +// Send fetches the Slack webhook URL from CredentialsSecret and POSTs +// the formatted message. A non-2xx response is treated as failure. +func (s *SlackSender) Send(ctx context.Context, c client.Client, target claudev1alpha1.DeliveryTarget, team *claudev1alpha1.AgentTeam) error { + if target.CredentialsSecret == "" { + return fmt.Errorf("slack delivery requires credentialsSecret") + } + + var secret corev1.Secret + if err := c.Get(ctx, types.NamespacedName{Name: target.CredentialsSecret, Namespace: team.Namespace}, &secret); err != nil { + return fmt.Errorf("loading credentials %s: %w", target.CredentialsSecret, err) + } + webhookURL := string(secret.Data["slack-webhook-url"]) + if webhookURL == "" { + return fmt.Errorf("secret %s is missing key %q", target.CredentialsSecret, "slack-webhook-url") + } + + body, err := json.Marshal(slackPayload{ + Text: formatSlackMessage(target, team), + Channel: target.Channel, + }) + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + + httpClient := s.Client + if httpClient == nil { + httpClient = &http.Client{Timeout: 10 * time.Second} + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "kagents/0.8") + + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("post: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("slack returned %s", resp.Status) + } + return nil +} + +// formatSlackMessage builds a one-line summary plus an artifact list. +// Slack ignores Markdown in text by default; the asterisks below render +// as bold in mrkdwn-mode webhooks and as literal characters otherwise. +func formatSlackMessage(target claudev1alpha1.DeliveryTarget, team *claudev1alpha1.AgentTeam) string { + msg := target.Message + if msg == "" { + msg = fmt.Sprintf("Team *%s* finished with phase *%s*.", team.Name, team.Status.Phase) + } + if len(team.Status.Artifacts) == 0 { + return msg + } + buf := bytes.NewBufferString(msg) + buf.WriteString("\nArtifacts:\n") + for _, a := range team.Status.Artifacts { + fmt.Fprintf(buf, "• %s (by %s)\n", a.Name, a.ProducedBy) + } + return buf.String() +} diff --git a/internal/delivery/webhook.go b/internal/delivery/webhook.go new file mode 100644 index 0000000..25aebec --- /dev/null +++ b/internal/delivery/webhook.go @@ -0,0 +1,78 @@ +package delivery + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "sigs.k8s.io/controller-runtime/pkg/client" + + claudev1alpha1 "github.com/amcheste/kagents/api/v1alpha1" +) + +// WebhookSender POSTs a JSON metadata envelope to a configured URL. +// The payload is intentionally small — team identity, artifacts, the +// message — because the operator pod doesn't read artifact file bytes +// (see internal/delivery/delivery.go package doc). Downstream systems +// that want file contents are expected to fetch from persisted storage. +type WebhookSender struct { + // Client is the HTTP client used. Tests inject a server-rooted + // client; production leaves it nil and a default is used. + Client *http.Client +} + +// webhookPayload is the JSON shape the webhook receiver gets. Fields +// are intentionally stable (and snake_case for cross-language consumers) +// so downstream automation can rely on this contract. +type webhookPayload struct { + Event string `json:"event"` + Team string `json:"team"` + Namespace string `json:"namespace"` + Phase string `json:"phase"` + Message string `json:"message,omitempty"` + Artifacts []claudev1alpha1.ArtifactStatus `json:"artifacts,omitempty"` +} + +// Send POSTs the payload as application/json. A non-2xx response is +// treated as failure and surfaces in status.delivery[]. +func (w *WebhookSender) Send(ctx context.Context, _ client.Client, target claudev1alpha1.DeliveryTarget, team *claudev1alpha1.AgentTeam) error { + if target.URL == "" { + return fmt.Errorf("webhook delivery requires url") + } + body, err := json.Marshal(webhookPayload{ + Event: "team.completed", + Team: team.Name, + Namespace: team.Namespace, + Phase: team.Status.Phase, + Message: target.Message, + Artifacts: team.Status.Artifacts, + }) + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + + httpClient := w.Client + if httpClient == nil { + httpClient = &http.Client{Timeout: 10 * time.Second} + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.URL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "kagents/0.8") + + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("post: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("webhook returned %s", resp.Status) + } + return nil +}